Frontend Labs
⋯

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.load defaults to weights_only=True, which uses a restricted unpickler that only allows tensors and basic types. Never pass weights_only=False for 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:

  1. Use formats that cannot carry code. safetensors for weights (Hugging Face's format: a JSON header plus raw tensor bytes), JSON or Parquet for data, ONNX/GGUF for exported models.
  2. 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).
  3. 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 defusedxml for 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, tarfile has extraction filters; in 3.14 the default is filter="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.environ dumps 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's SecretStr does this).
  • Jupyter notebooks save outputs, including printed tokens, into the .ipynb file that gets committed. Clear outputs before committing (nbstripout as a git filter).
  • Use secrets, not random, 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.


Generated from real-life-python-guide.md · 8 Oct 2026 16:36