Do not rebuild objects from client data. Parse the token as JSON with a Pydantic model (Profile.model_validate_json(raw)), which keeps only declared fields of declared types; pickle.loads runs code chosen by the bytes. Load YAML with yaml.safe_load and validate it the same way. Blocklisting module or class names does not work.
- 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: query and path parameters, bodies read with request.body(), cookies, headers and UploadFile contents.
- Replace pickle with JSON and a Pydantic model that declares the fields you expect: Model.model_validate_json(raw) parses and validates in one step, and an invalid token raises ValidationError, which you turn into a 400.
- Replace yaml.load(...) with yaml.safe_load(...), then validate the result with a Pydantic model (model_validate) before using it.
- When the body is a JSON object, declare the model as the path operation's body parameter and let FastAPI validate it; read request.body() yourself only for other formats, as this card does for YAML.
- If a library genuinely needs typed deserialization, restrict it: 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.
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: JSON validated by a Pydantic model
# The client token is JSON, not a pickle. Pydantic parses it as JSON data and
# validates it into Profile: four strings, nothing else is kept, and no type or
# callable named in the payload is ever looked up.
class Profile(BaseModel):
username: str
displayName: str
theme: str
role: str
def restore_profile(token: str) -> dict:
try:
raw = base64.b64decode(token, validate=True)
return Profile.model_validate_json(raw).model_dump()
except (binascii.Error, ValueError) as exc: # ValidationError is a ValueError
raise ValueError("invalid token") from excWith 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 validate the result into the shape we expect.
class Preferences(BaseModel):
locale: str | None = None
timezone: str | None = None
flags: dict[str, bool] | None = None
recent: list[str] | None = None
def load_preferences(document: str) -> dict:
try:
data = yaml.safe_load(document)
return Preferences.model_validate(data).model_dump(exclude_unset=True)
except (yaml.YAMLError, ValidationError) as exc:
raise ValueError("invalid preferences document") from excWith 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 raises TypeError since PyYAML 6.0; adding Loader=yaml.Loader or UnsafeLoader to silence it re-introduces the hole. Use safe_load.
- A pickle.Unpickler subclass that blocklists modules in find_class still allows every global not on the list, as this card's wrong implementation shows; Semgrep's avoid-pickle and Bandit's B301 do not flag the subclass either.
- Pydantic validation after pickle.loads is too late: the gadget runs while the bytes are unpickled, before any model sees the result.
- Do not add a model field typed Any, object or a union with arbitrary classes and then build classes from a type key in the data; that brings object construction back.
- Signing the blob (hmac.compare_digest on a MAC) stops a client swapping it, 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 deserialization/python-fastapi does this for you).
- Run semgrep --config r/python.lang.security.deserialization.pickle.avoid-pickle --config r/python.lang.security.deserialization.avoid-pyyaml-load.avoid-pyyaml-load impl and bandit -r impl; expect 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 side effect happens 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 line 19 (pickle.loads). It did not flag impl/wrong.py, which reads the same untrusted bytes through a pickle.Unpickler subclass (_BlocklistUnpickler(...).load()) that its patterns do not match.Checked: Semgrep 1.180.0 (CE); semgrep scan --config r/<id> app.py impl |
| Semgrep | python.lang.security.deserialization.avoid-pyyaml-load.avoid-pyyaml-load | Flagged impl/vulnerable.py line 29 and impl/wrong.py line 50, 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 | From the models: semmle/python/frameworks/Stdlib.qll treats pickle.load and pickle.loads as unsafe decoding, and Yaml.qll treats yaml.load with any loader other than SafeLoader, BaseLoader or their C versions as unsafe; FastApi.qll makes the token query parameter a remote source. Neither model mentions pickle.Unpickler, so the wrong implementation's subclass is probably missed. This card loads the implementation with importlib.import_module, which CodeQL does not resolve.Checked: github/codeql main at b5b8165; not executed |
| Bandit | B301pickle | Flagged impl/vulnerable.py line 19 (pickle.loads, MEDIUM/HIGH); not the _BlocklistUnpickler(...).load() in impl/wrong.py. B403 (import pickle, LOW) fired on vulnerable.py and wrong.py.Checked: Bandit 1.9.4, bandit -r impl app.py |
| Bandit | B506yaml_load | Flagged impl/vulnerable.py line 29 and impl/wrong.py line 50 (MEDIUM/HIGH); nothing in impl/fixed.py.Checked: Bandit 1.9.4 |
| Component | Version |
|---|---|
| Language | Python |
| Runtime | CPython 3.14.8 |
| Framework | FastAPI 0.142.2 (Starlette 1.7.0, Pydantic 2.13.5) |
| fastapi | 0.142.2 |
| pydantic | 2.13.5 (pydantic-core 2.46.5) |
| pyyaml | 6.0.3 |
| pickle, base64 (standard library) | Python 3.14.8 |
| httpx2 (TestClient transport, test only) | 2.13.1 |
| 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-fastapifolder.
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-fastapi runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- python CPython 3.14.8
- fastapi 0.142.2
- pydantic 2.13.5
- pyyaml 6.0.3
- pytest 9.1.1
- semgrep 1.180.0
- bandit 1.9.4
- 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
- fixed 7/7 pass; vulnerable 2/7 fail (2 exploit tests); wrong 2/7 fail (2 exploit tests: the blocklists miss the marker callable), offline after install
- Dependency freshness
- PyYAML 6.0.3 dates from 2025-09-25. FastAPI 0.142.2 (2026-09-30) and Pydantic 2.13.5 (2026-08-28) rather than 0.143.0 and 2.14.0 (2026-10-08), which fix no security issue; Starlette 1.7.0 (2026-09-23); httpx2 2.13.1 (2026-09-23); iniconfig 2.3.0 because 2.3.1 is from 2026-10-06. OSV reported no known vulnerabilities for any pinned package.
- Notes
- The exploit payloads call a harmless marker function (attack_markers.trigger) that writes a file, standing in for a real gadget chain. The vulnerable and wrong implementations are the Flask card's code; the fixed one validates with Pydantic instead of copying fields by hand.
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)
- Pydantic: JSON parsing (model_validate_json)
- FastAPI: using the request directly
- CWE-502: Deserialization of Untrusted Data
- CAPEC-586: Object Injection
- Bandit B506: yaml_load
Attack patterns: