Do not rebuild objects from the token. Decode it as JSON and copy only the fields you expect into a fixed shape; pickle.loads runs code chosen by the bytes. For YAML use yaml.safe_load, never yaml.load with Loader=UnsafeLoader (or yaml.Loader). Blocklisting dangerous module or class names does not work: the next gadget is in a module you did not list.
- Find every pickle.loads/pickle.load, pickle.Unpickler, yaml.load with an unsafe Loader, shelve, jsonpickle, dill and marshal call whose input can come from a request (args, form, json, headers, cookies, uploaded files).
- Replace pickle with json.loads for data you control the shape of, then validate: check the top-level type, pull out only the fields you expect, and check each field's type.
- Replace yaml.load(...) with yaml.safe_load(...); the SafeLoader has no constructors for Python objects, so object tags raise instead of running.
- If a library genuinely needs typed deserialization, restrict it: a dataclass/pydantic schema for JSON, or a pickle.Unpickler whose find_class ALLOWS only an explicit set of (module, name) pairs and rejects the rest.
- Add regression tests with this card's payloads (a pickle __reduce__ gadget and a !!python/object/apply YAML tag) next to functional tests that round-trip the seed profile and preferences.
- Sign or MAC any state you must round-trip through a client, and keep Flask debug off so deserialization errors do not leak to callers.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
def restore_profile(token: str) -> dict:
# pickle.loads runs the payload's __reduce__: it can call any importable
# callable with attacker-chosen arguments, not just rebuild a dict.
try:
raw = base64.b64decode(token, validate=True)
except (binascii.Error, ValueError) as exc:
raise ValueError("invalid token") from exc
profile = pickle.loads(raw) # noqa: S301 - the vulnerability under test
return dict(profile)With IMPL=vulnerable, 2 of 7 tests fail (2 exploit tests).
The fix
# The client token is JSON, not a pickle. json.loads only ever produces
# strings, numbers, lists, dicts, booleans and None - never an arbitrary
# object - so the payload cannot choose a type or run code. Then validate.
ALLOWED_PROFILE_FIELDS = ("username", "displayName", "theme", "role")
def restore_profile(token: str) -> dict:
try:
raw = base64.b64decode(token, validate=True)
data = json.loads(raw)
except (binascii.Error, ValueError) as exc:
raise ValueError("invalid token") from exc
if not isinstance(data, dict):
raise ValueError("profile must be an object")
profile = {}
for field in ALLOWED_PROFILE_FIELDS:
value = data.get(field)
if not isinstance(value, str):
raise ValueError(f"profile.{field} must be a string")
profile[field] = value
return profileWith IMPL=fixed, 7 of 7 tests pass.
The fix: safe YAML
# yaml.safe_load uses the SafeLoader, which has no constructors for Python
# objects: a tag such as !!python/object/apply raises instead of running.
# Then copy only the keys we expect.
ALLOWED_PREFERENCE_FIELDS = ("locale", "timezone", "flags", "recent")
def load_preferences(document: str) -> dict:
try:
data = yaml.safe_load(document)
except yaml.YAMLError as exc:
raise ValueError("invalid preferences document") from exc
if not isinstance(data, dict):
raise ValueError("preferences must be a mapping")
return {key: data[key] for key in ALLOWED_PREFERENCE_FIELDS if key in data}With IMPL=fixed, 7 of 7 tests pass.
A fix that looks safe but is not
# Blocklisting modules in Unpickler.find_class. The denylist names the usual
# RCE modules, but find_class is called for EVERY global the payload imports,
# and anything not on the list is allowed - including a gadget in some other
# module already loaded in the process.
_BLOCKED_MODULES = {"os", "nt", "posix", "subprocess", "socket", "sys", "builtins", "shutil"}
_BLOCKED_NAMES = {"system", "popen", "eval", "exec", "call", "run", "Popen"}
class _BlocklistUnpickler(pickle.Unpickler):
def find_class(self, module, name):
if module in _BLOCKED_MODULES or name in _BLOCKED_NAMES:
raise ValueError("blocked class in payload")
return super().find_class(module, name)
def restore_profile(token: str) -> dict:
try:
raw = base64.b64decode(token, validate=True)
except (binascii.Error, ValueError) as exc:
raise ValueError("invalid token") from exc
profile = _BlocklistUnpickler(io.BytesIO(raw)).load()
return dict(profile)With IMPL=wrong, 2 of 7 tests fail (2 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
# Exploit: each payload's only job is to fire the harmless marker during
# deserialization. The fix must leave the marker untouched.
def test_exploit_pickle_gadget_is_not_executed(client, tmp_path: Path):
marker = tmp_path / "pickle-marker.txt"
res = restore(client, pickle_gadget_token(str(marker)))
assert not marker.exists(), "insecure deserialization: the pickle gadget ran"
assert res.status_code == 400
def test_exploit_yaml_object_tag_is_not_executed(client, tmp_path: Path):
marker = tmp_path / "yaml-marker.txt"
res = load_preferences(client, yaml_gadget_document(str(marker)))
assert not marker.exists(), "insecure deserialization: the YAML object tag ran"
assert res.status_code == 400- pickle is not the only unsafe module: marshal, shelve (built on pickle), dill, jsonpickle and PyYAML's unsafe loaders all reconstruct arbitrary objects.
- yaml.load(data) with no Loader argument raises TypeError since PyYAML 6.0; people 'fix' it by adding Loader=yaml.Loader (or UnsafeLoader), which re-introduces the hole. Only SafeLoader/safe_load is safe.
- A pickle.Unpickler subclass that blocklists modules in find_class still calls find_class for every global the payload names; anything not on the list is allowed. Allowlist instead, or stop using pickle. (Semgrep's avoid-pickle and Bandit B301 also miss the subclass, so it looks clean.)
- The gadget does not have to be the type you expected. pickle runs the callable returned by __reduce__; the unpickled value can be the result of os.system, not your object at all.
- json is safe to parse, but json.loads still returns attacker-shaped dicts and lists. Validate structure and types before trusting them; an object_hook that builds classes from a 'type' key brings the problem back.
- Do not rely on blocking 'os.system' or 'subprocess' in the text: the attacker only needs one callable whose name you did not list, as this card's wrong.py shows.
- hmac.compare_digest-checked signatures stop a client swapping the blob, but only if the key is secret and the check runs before deserialization.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs python-flask does this for you).
- Run semgrep --config r/python.lang.security.deserialization.pickle.avoid-pickle impl and ... .avoid-pyyaml-load.avoid-pyyaml-load impl, and bandit -r impl; expect pickle/yaml findings in vulnerable.py (and the yaml finding in wrong.py), none in fixed.py.
- Against your own endpoint, post a !!python/object/apply YAML document and a base64 pickle gadget and confirm no marker file appears and the request is rejected with 400.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | python.lang.security.deserialization.pickle.avoid-pickle | Flagged impl/vulnerable.py L19 (pickle.loads). It did NOT flag impl/wrong.py: that code reads the same untrusted bytes through a pickle.Unpickler subclass (_BlocklistUnpickler(...).load()), which the pattern for pickle.load/loads does not match. The 'wrong' fix slips past the linter as well as past the attacker.Checked: Semgrep 1.180.0 (CE) |
| Semgrep | python.lang.security.deserialization.avoid-pyyaml-load.avoid-pyyaml-load | Flagged impl/vulnerable.py L29 and impl/wrong.py L50, both yaml.load(..., Loader=yaml.UnsafeLoader). No finding in impl/fixed.py, which uses yaml.safe_load.Checked: Semgrep 1.180.0 (CE) |
| CodeQL | py/unsafe-deserialization | Not run here. The Python model marks pickle.loads/load and yaml.load (without a safe loader) as unsafe-deserialization sinks (semmle/python/frameworks/Stdlib.qll and Yaml.qll), so it is expected to flag vulnerable.py; the custom Unpickler.load() in wrong.py depends on whether the model treats the subclass as a sink.Checked: github/codeql main; not executed |
| Bandit | B301pickle (loads of untrusted data) | Flagged impl/vulnerable.py L19 (pickle.loads, MEDIUM/HIGH). Like Semgrep, B301 did not flag the _BlocklistUnpickler(...).load() in impl/wrong.py. B403 (import pickle, LOW) fired on vulnerable.py and wrong.py.Checked: Bandit 1.9.4 |
| Bandit | B506yaml_load | Flagged impl/vulnerable.py L29 and impl/wrong.py L50 (yaml.load with an unsafe loader, MEDIUM/HIGH). No finding in impl/fixed.py.Checked: Bandit 1.9.4 |
| Component | Version |
|---|---|
| Language | Python |
| Runtime | CPython 3.14.8 |
| Framework | Flask 3.1.3 |
| flask | 3.1.3 |
| werkzeug | 3.1.9 |
| pyyaml | 6.0.3 |
| pickle (standard library) | Python 3.14.8 |
| pytest | 9.1.1 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the deserialization/python-flaskfolder.
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt# Expected: every test passes
IMPL=fixed .venv/bin/python -m pytest
# Expected: an exploit test fails
IMPL=vulnerable .venv/bin/python -m pytest
# Expected: a test fails
IMPL=wrong .venv/bin/python -m pytestEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs deserialization/python-flask runs all three modes and checks the expected results.
Verified
- Toolchain
- python 3.14.8
- pytest 9.1.1
- pyyaml 6.0.3
- flask 3.1.3
- Matrix
- fixed: 7 of 7 tests pass; vulnerable: 2 of 7 tests fail (2 exploit tests); wrong: 2 of 7 tests fail (2 exploit tests)
- Test results
- IMPL=fixed 7/7 pass; IMPL=vulnerable 2 exploit tests fail; IMPL=wrong 2 exploit tests fail (the blocklist is bypassed by a marker callable it never named).
- Scanners
- Semgrep 1.180.0 and Bandit 1.9.4 from a throwaway venv (.semgrep-venv), not a card dependency.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP Deserialization Cheat Sheet
- Python pickle: Restricting Globals (security)
- PyYAML documentation: loading YAML safely (safe_load)
- Python json module
- CWE-502: Deserialization of Untrusted Data
- CAPEC-586: Object Injection
- Bandit B301 (pickle) and B506 (yaml_load)
- Semgrep rule: avoid-pyyaml-load
Attack patterns: