Software Securityhome
Fix a findingSearch
Fix card · A08:2025 Software or Data Integrity Failures

Fix insecure deserialization in Python and FastAPI

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

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.

Stack
  • Language Python
  • Runtime CPython 3.14.8
  • Framework FastAPI 0.142.2 (Starlette 1.7.0, Pydantic 2.13.5)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks6 cards
Download the project ZIP, 19 KB
On this page

Steps

  1. 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.
  2. 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.
  3. Replace yaml.load(...) with yaml.safe_load(...), then validate the result with a Pydantic model (model_validate) before using it.
  4. 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.
  5. 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.
  6. 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.

Vulnerable, fixed and wrong

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

Vulnerableimpl/vulnerable.pyPython
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

Fixedimpl/fixed.pyPython
# 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 exc

With IMPL=fixed, 7 of 7 tests pass.

The fix: safe YAML

Fixedimpl/fixed.pyPython
# 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 exc

With IMPL=fixed, 7 of 7 tests pass.

A fix that looks safe but is not

Wrong fiximpl/wrong.pyPython
# 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 regression test

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.

Testtests/test_session.pyPython
# 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

Pitfalls

  • 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.

Verify the fix

  1. 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).
  2. 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.
  3. 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.

Scanner rules

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.

Scanner rules for Fix insecure deserialization in Python and FastAPI
ToolRuleWhat it found here
Semgreppython.lang.security.deserialization.pickle.avoid-pickleFlagged 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
Semgreppython.lang.security.deserialization.avoid-pyyaml-load.avoid-pyyaml-loadFlagged 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)
CodeQLpy/unsafe-deserializationFrom 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
BanditB301pickleFlagged 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
BanditB506yaml_loadFlagged impl/vulnerable.py line 29 and impl/wrong.py line 50 (MEDIUM/HIGH); nothing in impl/fixed.py.Checked: Bandit 1.9.4

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePython
RuntimeCPython 3.14.8
FrameworkFastAPI 0.142.2 (Starlette 1.7.0, Pydantic 2.13.5)
fastapi0.142.2
pydantic2.13.5 (pydantic-core 2.46.5)
pyyaml6.0.3
pickle, base64 (standard library)Python 3.14.8
httpx2 (TestClient transport, test only)2.13.1
pytest9.1.1

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the deserialization/python-fastapifolder.

Shell
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
Shell
# 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 pytest

Each 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.

Download the project ZIP, 19 KB

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

  1. OWASP Deserialization Cheat Sheet
  2. Python pickle: restricting globals (security)
  3. PyYAML documentation: loading YAML safely (safe_load)
  4. Pydantic: JSON parsing (model_validate_json)
  5. FastAPI: using the request directly
  6. CWE-502: Deserialization of Untrusted Data
  7. CAPEC-586: Object Injection
  8. Bandit B506: yaml_load

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.