Part VI — Security-focused Python
Python is memory-safe in the sense that matters to C programmers: no
buffer overflows or use-after-free in Python code. Its vulnerabilities come
from somewhere else: features that turn data into code (pickle, eval,
yaml.load, shell=True, templates), parsers that trust their input,
crypto used wrongly, and a huge, easily poisoned package ecosystem. This
Part covers each, ties it to the
Security from Zero and
Security in Depth guides, and
ends with two projects.
47. Python's own attack surface
47.1 pickle executes code: the model-file problem
pickle serializes arbitrary Python objects. To rebuild them, the format
can tell the loader to call any importable function with any
arguments. Loading a pickle is running a program:
import os
import pickle
class Exploit:
def __reduce__(self): # tells pickle how to "rebuild" this object
return (os.system, ("echo pwned: code ran during pickle.loads",))
payload = pickle.dumps(Exploit())
print(payload[:40], "...")
pickle.loads(payload) # runs the shell command
python -m pickletools payload.pkl # disassemble: look for GLOBAL/STACK_GLOBAL 'os system' / 'builtins eval'
Where pickles hide in AI work:
- PyTorch checkpoints (
.pt,.pth,.bin) are zip files containing a pickle. Since PyTorch 2.6,torch.loaddefaults toweights_only=True, which uses a restricted unpickler that only allows tensors and basic types. Never passweights_only=Falsefor a file you did not create. - scikit-learn / joblib models (
.pkl,.joblib) are pickles, with no safe mode. pandas.read_pickle,numpy.load(allow_pickle=True), cached results in/tmp, Celery/RQ task queues configured with the pickle serializer, and session data in some old web frameworks.
The defenses, in order:
- Use formats that cannot carry code.
safetensorsfor weights (Hugging Face's format: a JSON header plus raw tensor bytes), JSON or Parquet for data, ONNX/GGUF for exported models. - If you must load a pickle, load only ones you produced, from storage only you can write, ideally verified with an HMAC or signature (Ch 48).
- Scan third-party model files (
picklescan,modelscan) before loading, knowing scanners can be bypassed.
This is the deserialization class from Security in Depth Ch 37, and it is why AI security treats model files as executables.
47.2 eval, exec, and "safe" evaluation
user_input = "__import__('os').getcwd()" # attacker-controlled
# eval(user_input) # WRONG: runs arbitrary code; there is no safe way to sandbox eval
import ast
print(ast.literal_eval("[1, 2, {'a': 3}]")) # parses literals only: numbers, strings, lists, dicts...
try:
ast.literal_eval(user_input)
except ValueError as e:
print("rejected:", e)
Stripping __builtins__ from eval does not make it safe; there are
well-known escapes through object attributes (().__class__.__base__.__subclasses__()).
If users need expressions (a formula field, a filter), write or use a small
parser with an explicit grammar, and evaluate the AST yourself with an
allow-list of node types.
Agents: an LLM agent with a "run Python" tool is exec on text an
attacker can influence through prompt injection. Run such tools in a real
sandbox (a container or microVM with no network and no secrets), never in
the agent's own process (AI Ch 61).
47.3 YAML, XML, and archives
import yaml # uv add pyyaml
doc = "!!python/object/apply:os.system ['echo pwned']"
# yaml.load(doc, Loader=yaml.Loader) # WRONG: constructs arbitrary objects
print(yaml.safe_load("a: 1\nb: [x, y]")) # always safe_load
- XML: the standard library parsers are not safe against malicious XML
(entity expansion "billion laughs", external entities in some
configurations). Use
defusedxmlfor untrusted XML (Security in Depth Ch 32). - Archives: a tar or zip entry named
../../.ssh/authorized_keys, or a symlink entry pointing outside the target, writes anywhere ("zip slip"). Since 3.12,tarfilehas extraction filters; in 3.14 the default isfilter="data", which blocks absolute paths,.., links outside the destination, and device files. Pass it explicitly so the code is safe on every version:
import tarfile, zipfile
from pathlib import Path
def safe_untar(archive: str, dest: str) -> None:
with tarfile.open(archive) as tf:
tf.extractall(dest, filter="data") # rejects traversal, links out of dest, devices
def safe_unzip(archive: str, dest: str, max_total: int = 1 << 30) -> None:
root = Path(dest).resolve()
with zipfile.ZipFile(archive) as zf:
total = 0
for info in zf.infolist():
target = (root / info.filename).resolve()
if not target.is_relative_to(root):
raise ValueError(f"path traversal: {info.filename}")
total += info.file_size
if total > max_total:
raise ValueError("archive expands too much (zip bomb?)")
zf.extractall(root)
Datasets and model repositories arrive as archives. This matters in ML pipelines more than almost anywhere else (Security in Depth Ch 38).
47.4 Paths, regexes, and resource exhaustion
from pathlib import Path
UPLOADS = Path("/srv/uploads").resolve()
def open_user_file(name: str) -> Path:
p = (UPLOADS / name).resolve() # resolve() collapses .. and follows symlinks
if not p.is_relative_to(UPLOADS):
raise PermissionError("path traversal")
return p
for n in ["report.pdf", "../../etc/passwd", "/etc/passwd"]:
try:
print("ok ", open_user_file(n))
except PermissionError as e:
print("blocked", n, e)
Note that UPLOADS / "/etc/passwd" is /etc/passwd: joining an absolute
path discards the left side. The resolve() + is_relative_to() check
catches it.
ReDoS: Python's re engine backtracks. A pattern like ^(a+)+$
against "a" * 30 + "!" takes exponential time and pins a CPU core.
Avoid nested quantifiers on untrusted input, cap input length before
matching, and consider the re2 bindings (linear-time) for user-supplied
patterns. 3.11+ supports atomic groups and possessive quantifiers
((?>...), a++) that prevent backtracking.
Other resource limits: cap request body sizes, JSON nesting depth,
decompressed sizes (a 1 MB gzip can expand to 1 GB), and int() on huge
digit strings (Ch 3.1).
47.5 Secrets in the wrong places
- Do not put secrets in code or in
os.environdumps in logs and error pages. Load them from a secrets manager or files mounted at run time (Security Ch 49). repr()of a config object that holds a password will print it in a traceback. Wrap secrets in a type whose__repr__hides the value (pydantic'sSecretStrdoes this).- Jupyter notebooks save outputs, including printed tokens, into the
.ipynbfile that gets committed. Clear outputs before committing (nbstripoutas a git filter). - Use
secrets, notrandom, for anything security-related (next chapter).
47.6 Static analysis
uv run ruff check --select S . # flake8-bandit rules: pickle, eval, shell=True, hardcoded passwords, ...
uv tool run bandit -r src/ # the original Python security linter
uv tool run semgrep --config p/python # pattern rules, including taint-style checks
Run them in CI and review every suppression.