CoursesAdvanced scripting for DevSecOpsSecure Python: deserialization, validation & secrets

Secure Python: deserialization, validation & secrets

pickle/yaml.load dangers, input validation, and the secrets module.

Expert35 min · lesson 12 of 15

Some files are a note you read. Others are a note that reads itself and does whatever it says. Python ships tools for both, and in code the two look almost identical. That gap matters more for security work than for almost anything else, because a security script runs with access most programs never get. It reads secrets, it calls internal services, and sometimes it runs as root (the all-powerful administrator account on Linux) on the very machine you are trying to protect. Hand an attacker a foothold there and you have handed them the keys to the building. Three habits close the three holes that keep coming back: never let untrusted data choose which code runs, check every input at the door, and use the right source of randomness when something has to be unguessable.

Loading Data Can Execute Code

Serializing means flattening a live object from memory into a string of bytes you can save to disk or send across the network. Deserializing is the reverse: rebuilding the object from those bytes. Python's built-in tool for this is pickle, and here is its sharp edge. Pickle does not only copy data. It can call functions to rebuild an object, and the bytes themselves say which functions to call. Think of shipping a flat-pack wardrobe with a small robot sealed inside the box. Open the box and the robot follows the printed instructions and assembles the furniture. Nothing stops those instructions from saying 'walk to the kitchen and turn on the stove' instead.

An object controls its own rebuild through a method named __reduce__, which returns the function to call and the arguments to hand it. An attacker writes a class whose __reduce__ returns os.system and a shell command. Unpickle their bytes and that command runs during the load, before you have read a single field of the result. This is remote code execution (RCE, an attacker running commands of their choosing on your machine), and it is one of the most common critical bugs filed against Python code.

~/secopslog — bash
$ python3 - <<'PY' import os, pickle class Config: def __reduce__(self): return (os.system, ("id",)) with open("config.pkl", "wb") as f: pickle.dump(Config(), f) print("payload written") PY
payload written

That file looks like a harmless cached config object, a few dozen bytes on disk. Now a service loads it the way a hundred tutorials show you to load a cache.

~/secopslog — bash
$ python3 -c 'import pickle; pickle.load(open("config.pkl", "rb"))'
uid=1000(deploy) gid=1000(deploy) groups=1000(deploy),27(sudo),999(docker)

No error. No warning. The load reported success, and partway through it the attacker's id command ran as the deploy user, who happens to sit in the docker group (which on most hosts is root in all but name, since you can mount the whole host filesystem from a container). YAML has the same trap. Its full loader honours tags like !!python/object/apply, which are the flat-pack robot wearing a different hat.

~/secopslog — bash
$ python3 -c 'import yaml; yaml.load("!!python/object/apply:os.system [\"id\"]", Loader=yaml.Loader)'
uid=1000(deploy) gid=1000(deploy) groups=1000(deploy),27(sudo),999(docker)

The fix is a reader that understands data and nothing else. yaml.safe_load builds only plain scalars, lists, and dictionaries, and it refuses the dangerous tags outright.

~/secopslog — bash
$ python3 -c 'import yaml; yaml.safe_load("!!python/object/apply:os.system [\"id\"]")'
Traceback (most recent call last): File "<string>", line 1, in <module> ... yaml.constructor.ConstructorError: could not determine a constructor for the tag 'tag:yaml.org,2002:python/object/apply:os.system' in "<unicode string>", line 1, column 1: !!python/object/apply:os.system ... ^

Silencing that machinery is not the same as fixing it. Since PyYAML 5.1 (released in 2019), calling yaml.load(text) with no loader was deprecated behind a printed warning. Modern PyYAML (6.0 and later) goes further and makes the Loader argument mandatory, so the call raises a TypeError until you pass one. The tempting way to quiet either of those is Loader=yaml.FullLoader or Loader=yaml.Loader. Do not. FullLoader has shipped real remote-code-execution bypasses (CVE-2020-1747 and CVE-2020-14343, entries in the public Common Vulnerabilities and Exposures catalogue, the shared registry of known security flaws), and Loader never pretended to be safe in the first place. yaml.safe_load is the only safe answer. Grep the codebase for 'yaml.load(' and treat every hit as a finding to fix.

So the rules stay short. For anything you did not create and protect yourself, never call pickle.load or pickle.loads, and always reach for yaml.safe_load. When you control both ends and want a plain data format, use JSON (JavaScript Object Notation, a text format that can only produce strings, numbers, lists, dictionaries, booleans, and null), because it has no way to name a function, let alone call one. If you genuinely must pass pickled objects between your own services, sign them: compute an HMAC (hash-based message authentication code, a fingerprint keyed by a secret both sides share) over the bytes and verify it before you ever call load, so a tampered payload is rejected without being deserialized.

Check Every Input at the Door

A nightclub does not check IDs at the bar. It checks them once, at the door, and everyone inside is already known to be old enough. Do the same with your data. The trust boundary is the line where information crosses from the outside world into your program: command-line arguments, environment variables, file contents, HTTP responses (replies coming back from a web server), queue messages. Everything crossing that line is a stranger until you have checked its type, its range, its length, and its format. Validate once at the edge, reject early with a clear error, and the core of your program only ever touches data that is already known-good.

pydantic makes that check declarative. You describe the shape you expect as a class, and parsing the input against it either hands you a clean typed object or raises an error. Here a job description arrives as JSON from an API (application programming interface, the agreed contract two programs use to talk to each other), and its count field must land between 1 and 1000.

~/secopslog — bash
$ python3 - <<'PY' from pydantic import BaseModel, field_validator class Job(BaseModel): target: str count: int @field_validator("count") @classmethod def in_range(cls, v): if not 1 <= v <= 1000: raise ValueError("count out of range") return v Job.model_validate_json('{"target": "10.0.0.5", "count": 999999}') PY
Traceback (most recent call last): ... pydantic_core._pydantic_core.ValidationError: 1 validation error for Job count Value error, count out of range [type=value_error, input_value=999999, input_type=int] For further information visit https://errors.pydantic.dev/2.13/v/value_error

Bad shape, bad type, or bad value, and you hear about it right at the boundary with a precise message, not three functions deep once the number is already driving a loop. One kind of input needs its own guard: file paths. A path from outside is a hotel key that should open exactly one room. Path traversal is a guest who scratches ../../manager-office onto the key and walks in where they do not belong. The value ../../../etc/passwd climbs out of your intended folder and into the system password file. Defend against it by resolving the path to its real absolute location and confirming it still sits under your base directory.

~/secopslog — bash
$ python3 - <<'PY' from pathlib import Path base = Path("/srv/uploads").resolve() for name in ["report.pdf", "../../../etc/passwd"]: p = (base / name).resolve() verdict = "allow" if p.is_relative_to(base) else "BLOCK" print(f"{name:22} -> {p} [{verdict}]") PY
report.pdf -> /srv/uploads/report.pdf [allow] ../../../etc/passwd -> /etc/passwd [BLOCK]

resolve() collapses every .. before you make a decision, and is_relative_to (available since Python 3.9) is the gate that actually matters. Do not build the check by hand with string prefixes. That is how a path like /srv/uploads-evil slips straight past a naive base.startswith test.

Use the Right Dice for Secrets

The random module is a card trick. The deck looks shuffled, but its order was fixed the instant it was seeded, and if you watch enough cards you can name the rest. Under the hood it is a Mersenne Twister, a PRNG (pseudo-random number generator, an algorithm that spits out a stream of values that only look random). Feed a standard cracking tool 624 consecutive outputs and it can rebuild the generator's entire internal state, then predict every value it will ever produce. Wonderful for shuffling test data. A disaster for a password-reset link.

Anything a person must never be able to guess has to come from the secrets module or from os.urandom. That covers tokens, API keys, session identifiers, password-reset links, and the nonces and salts (small one-off random values that stop repeated operations from looking identical) that cryptography leans on. Both draw from the operating system's CSPRNG (cryptographically secure pseudo-random number generator), the same unpredictable pool that TLS (Transport Layer Security, the encryption that protects web traffic) pulls its keys from. With a CSPRNG, past output tells an attacker nothing about the next byte.

~/secopslog — bash
$ python3 -c 'import secrets; print(secrets.token_urlsafe(32)); print(secrets.token_hex(16))'
3tDpTgfSgKpi3YK5lcLOn3l2RqQslmiqAAZk6iVdVjc c6e8d1052a316ce037ff2e32254039c1

token_urlsafe(32) gives you 32 bytes of real entropy encoded as a 43-character URL-safe string (a URL is a web address, and URL-safe means it is safe to paste straight into one), ready to drop into a link. token_hex(16) gives 16 bytes as 32 hex characters, handy for a key or an identifier. One more habit rides along with them. When you compare a secret a caller sent against the real one, do not use ==. A normal comparison stops at the first byte that differs, so the time it takes leaks how much of the secret the attacker has already guessed, one byte at a time. secrets.compare_digest takes the same amount of time no matter which bytes differ.

~/secopslog — bash
$ python3 -c 'import secrets; print(secrets.compare_digest("s3cr3t", "s3cr3t")); print(secrets.compare_digest("s3cr3t", "s3cr3x"))'
True False

Use compare_digest for tokens, HMACs, and API keys. It is not a password hasher. Stored user passwords belong in a slow KDF (key derivation function, a one-way hash made deliberately expensive to run) such as Argon2 or bcrypt, which the argon2-cffi and bcrypt libraries give you so you never hand-roll the primitive yourself.

Three footguns, three one-line fixes
Turn bytes into objects
pickle.load / yaml.load
runs attacker code during load
json.loads / yaml.safe_load
data only, no code path
Accept outside input
trust the caller
bad data reaches core logic
pydantic at the boundary
reject early, parse to types
Make a secret
random.choices
predictable seeded PRNG
secrets.token_urlsafe
OS CSPRNG, unguessable
Each dangerous call on the left has a safe twin on the right, one line away. Bandit spots the pickle load and the weak-random call on its own; the missing input check it cannot see, so that habit stays your job.

Let a Scanner Catch What You Miss

You will not catch every one of these in review, least of all in a pull request that touches forty files. bandit is a static analyzer (a tool that reads your code without running it) built for exactly this job. It knows the dangerous patterns by name and flags each one with a severity, a confidence, and a CWE (Common Weakness Enumeration, the industry's shared catalogue of weakness types). Point it at a file that has all three mistakes in it.

app.py
import pickle
import subprocess
def load_config(path):
with open(path, "rb") as f:
return pickle.load(f)
def run(cmd):
return subprocess.call(cmd, shell=True)
~/secopslog — bash
$ bandit -r app.py
[main] INFO running on Python 3.11.2 Run started:2026-07-17 09:14:22.114329 Test results: >> Issue: [B403:blacklist] Consider possible security implications associated with pickle module. Severity: Low Confidence: High CWE: CWE-502 (https://cwe.mitre.org/data/definitions/502.html) More Info: https://bandit.readthedocs.io/en/1.8.0/blacklists/blacklist_imports.html#b403-import-pickle Location: ./app.py:1:0 1 import pickle 2 import subprocess -------------------------------------------------- >> Issue: [B301:blacklist] Pickle and modules that wrap it can be unsafe when used to deserialize untrusted data, possible security issue. Severity: Medium Confidence: High CWE: CWE-502 (https://cwe.mitre.org/data/definitions/502.html) More Info: https://bandit.readthedocs.io/en/1.8.0/blacklists/blacklist_calls.html#b301-pickle Location: ./app.py:7:15 6 with open(path, "rb") as f: 7 return pickle.load(f) -------------------------------------------------- >> Issue: [B602:subprocess_popen_with_shell_equals_true] subprocess call with shell=True identified, security issue. Severity: High Confidence: High CWE: CWE-78 (https://cwe.mitre.org/data/definitions/78.html) More Info: https://bandit.readthedocs.io/en/1.8.0/plugins/b602_subprocess_popen_with_shell_equals_true.html Location: ./app.py:11:11 10 def run(cmd): 11 return subprocess.call(cmd, shell=True) -------------------------------------------------- Code scanned: Total lines of code: 7 Total lines skipped (#nosec): 0 Run metrics: Total issues (by severity): Undefined: 0 Low: 1 Medium: 1 High: 1 Total issues (by confidence): Undefined: 0 Low: 0 Medium: 0 High: 3 Files skipped (0):

Three findings, three severities. The Low one is the mere import of pickle. The Medium is the actual pickle.load. The High is shell=True, which bandit rates hottest because it is the shortest path from user input to a running shell. When a finding is new to you, follow its CWE link. It explains the whole class of bug rather than this single line, and that is usually where the real understanding clicks into place.

shell=True turns an argument into a second command
subprocess.call(cmd, shell=True) hands your string to /bin/sh (the system shell), so a filename like report.pdf; rm -rf /var/backups arrives as two commands and the second one runs. Pass a list of arguments and keep the default shell=False, for example subprocess.run(["cat", user_path]), and the operating system launches exactly one program with exactly those arguments, with no shell left to reinterpret them. This is bandit's B602, and it is the Python finding attackers reach for first.
Quick check
01A teammate keeps pickle.load in place, arguing it is safe here because the code checks the loaded object's type right afterward and rejects anything unexpected. Why does that check not protect them?
Incorrect — No concurrency is involved; the flaw is when code runs, not two threads racing.
Correct — __reduce__ fires while the bytes are being unpickled, so the payload has already run by the time you inspect the result.
Incorrect — pickle represents types fine; the loaded object's type is not the problem.
Incorrect — isinstance works on any Python object regardless of source; the issue is that execution already happened.
02Modern PyYAML makes the Loader argument mandatory, and a teammate silences the resulting TypeError with Loader=yaml.FullLoader. Why is that the wrong fix?
Incorrect — speed is irrelevant here; the problem is that FullLoader can be driven into executing code.
Incorrect — parsing capability is not the concern; safety against code execution is.
Incorrect — it works on both, and that has nothing to do with the security risk.
Correct — FullLoader has had documented RCE bypasses, so the fix is switching to safe_load, not quieting the error with another unsafe loader.
03To block path traversal a developer writes: if str((base / name).resolve()).startswith(str(base)): allow, with base = /srv/uploads. Why can an attacker still escape the intended folder?
Correct — a string prefix test treats /srv/uploads-evil as inside /srv/uploads, which is exactly why the lesson says use is_relative_to instead.
Incorrect — resolve() does collapse .. before the check; the flaw is the naive string comparison, not normalization.
Incorrect — case sensitivity is not the escape route here; the sibling-directory prefix match is.
Incorrect — resolving first is correct and necessary; the real hole is comparing with a string prefix rather than a path-boundary test.

Wire the scan into CI (continuous integration, the automated pipeline that builds and checks every change as it lands) as a required gate. Run bandit -r . --severity-level medium --confidence-level medium, and treat a fresh B301 (pickle load), B506 (yaml.load), or B602 (shell=True) as a merge blocker. Fix the line, run the scan again, and watch the High count fall to zero. That last step is the whole point. You do not trust the diff, you trust the green scan.

Try this

Work through “Let a Scanner Catch What You Miss” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: shell=True turns an argument into a second command. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related