Python Security
Python makes the dangerous path the short one. pickle, eval,
shell=True and a bare yaml.load all read as one-liners, and each is an
interpreter that runs whatever the caller sends.
Version context that changes the finding
Loader argument mandatory,
so yaml.load(x) now raises TypeError on the current release (6.0.2) — a
call written against 5.x is a real finding in an old codebase and a broken code path in a new one.
Django removed SECURE_BROWSER_XSS_FILTER in 4.0, so a settings file that still sets it
has not been re-read since 3.x, which is worth reporting on its own.
The review question is never "is this call dangerous" — the call is the answer. It is whether the value can be reached from a request, a queue message, an upload, or a file another system wrote. The table below is the whole page compressed; each section that follows explains one row.
| Sink | CWE | Detection | Fix |
|---|---|---|---|
pickle.load / loads |
CWE-502 | grep -rnE "pickle\.loads?\(" . |
JSON or MessagePack plus a MAC |
yaml.unsafe_load, Loader=yaml.Loader |
CWE-502 | grep -rnE "yaml\.(load|unsafe_load|full_load)\(" . |
safe_load or a SafeLoader subclass |
shell=True, os.system, os.popen |
CWE-78 | grep -rnE "shell=True|os\.system|os\.popen" . |
argv list, validated, timeout |
eval, exec, compile |
CWE-95 | grep -rnE "eval\(|exec\(|compile\(" . |
ast.literal_eval, dispatch dict |
render_template_string, |safe |
CWE-1336 | grep -rnE "render_template_string|\|safe" . |
Templates from disk, autoescape on |
os.path.join with request data |
CWE-22 | grep -rnE "os\.path\.join\(|send_file\(" . |
resolve() plus is_relative_to() |
Pickle deserialization
pickle is a stack language, not a data format. Unpickling builds arbitrary objects and
calls whatever __reduce__ names, so a payload is a function call with arguments:
(os.system, ('id',)) is the entire exploit. There is no safe subset to configure — the
find_class restriction is documented as unsuitable for a security boundary, and signing
the blob only proves who sent it, not that the sender is trusted. Any pickle.load whose
bytes come from a request, a cookie, a queue, an upload or a cache shared with another service is a
finding. The fix is a format with no code in it, plus a MAC when the value must be tamper-evident.
import os
import pickle
from flask import Flask, request
app = Flask(__name__)
@app.post('/restore')
def restore():
# Bytes arrive from the browser; unpickling calls __reduce__ during load
state = pickle.loads(request.cookies['state'])
return {'theme': state['theme']}
# The payload is a function call with arguments, not a data structure
class Exploit:
def __reduce__(self):
return (os.system, ('id',))
import os
import pickle
from flask import Flask, request
app = Flask(__name__)
@app.post('/restore')
def restore():
# Bytes arrive from the browser; unpickling calls __reduce__ during load
state = pickle.loads(request.cookies['state'])
return {'theme': state['theme']}
# The payload is a function call with arguments, not a data structure
class Exploit:
def __reduce__(self):
return (os.system, ('id',))
import base64
import hashlib
import hmac
import json
from flask import Flask, abort
app = Flask(__name__)
STATE_KEY = app.config['STATE_KEY'] # from the environment, never a literal
def decode_state(raw: str) -> dict:
body_b64, signature = raw.split('.', 1)
expected = hmac.new(
STATE_KEY.encode(), body_b64.encode(), hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, signature):
abort(400)
return json.loads(base64.urlsafe_b64decode(body_b64))
import base64
import hashlib
import hmac
import json
from flask import Flask, abort
app = Flask(__name__)
STATE_KEY = app.config['STATE_KEY'] # from the environment, never a literal
def decode_state(raw: str) -> dict:
body_b64, signature = raw.split('.', 1)
expected = hmac.new(
STATE_KEY.encode(), body_b64.encode(), hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, signature):
abort(400)
return json.loads(base64.urlsafe_b64decode(body_b64))
The gadget is not the vulnerability
loads call, and it stays a bug even when the class it builds
looks harmless today — a new dependency with a useful __reduce__ turns the same call
into execution without anyone editing the file.
YAML loading
PyYAML's history is the reason the old advice was wrong. Version 5.1 only added a warning to
yaml.load without a Loader; 6.0 (October 2021) made the argument
mandatory, so on 6.0.2 the bare call raises TypeError instead of executing the
document. That does not make old code safe: Loader=yaml.Loader and
yaml.unsafe_load still construct Python objects, and full_load has had its
own execution bug (CVE-2020-14343, fixed in 5.4), so it is not a boundary either. Use
yaml.safe_load, or subclass SafeLoader and register only the tags the
configuration genuinely needs.
import yaml
# PyYAML 6.0 made Loader mandatory: the bare call is now a TypeError.
# unsafe_load and Loader=yaml.Loader still construct Python objects.
def load_config(stream):
return yaml.unsafe_load(stream)
# Request body that becomes os.system("id"):
# !!python/object/apply:os.system ["id"]
import yaml
# PyYAML 6.0 made Loader mandatory: the bare call is now a TypeError.
# unsafe_load and Loader=yaml.Loader still construct Python objects.
def load_config(stream):
return yaml.unsafe_load(stream)
# Request body that becomes os.system("id"):
# !!python/object/apply:os.system ["id"]
import yaml
class ConfigLoader(yaml.SafeLoader):
"""SafeLoader plus one explicit tag. Every other tag is rejected."""
def _duration(loader, node):
return int(loader.construct_scalar(node))
ConfigLoader.add_constructor('!duration', _duration)
with open('config.yaml') as fh:
config = yaml.load(fh, Loader=ConfigLoader) # Loader is mandatory since 6.0
import yaml
class ConfigLoader(yaml.SafeLoader):
"""SafeLoader plus one explicit tag. Every other tag is rejected."""
def _duration(loader, node):
return int(loader.construct_scalar(node))
ConfigLoader.add_constructor('!duration', _duration)
with open('config.yaml') as fh:
config = yaml.load(fh, Loader=ConfigLoader) # Loader is mandatory since 6.0
Command execution
shell=True is the whole vulnerability: the string goes to /bin/sh, so
;, &&, $() and backticks are syntax. Passing a list with
shell=False removes the shell but does not make the command safe — the program's own
options are still attacker-reachable, so a leading -o can redirect a write, and a
hostname that passes a pattern can still resolve to an internal address. Validate the argument,
pass -- before positionals where the tool supports it, set timeout, and run
the worker unprivileged. os.system and os.popen are the same sink with
older names; shlex.quote is a mitigation for the shell case, never a substitute for argv.
import os
import subprocess
from flask import Flask, request
app = Flask(__name__)
@app.get('/ping')
def ping():
host = request.args['host']
# /bin/sh parses the string, so "127.0.0.1; id" is two commands
return subprocess.run(f'ping -c 1 {host}', shell=True, capture_output=True).stdout
@app.get('/disk')
def disk():
mount = request.args['mount']
return os.popen(f'df -h {mount}').read()
import os
import subprocess
from flask import Flask, request
app = Flask(__name__)
@app.get('/ping')
def ping():
host = request.args['host']
# /bin/sh parses the string, so "127.0.0.1; id" is two commands
return subprocess.run(f'ping -c 1 {host}', shell=True, capture_output=True).stdout
@app.get('/disk')
def disk():
mount = request.args['mount']
return os.popen(f'df -h {mount}').read()
import re
import subprocess
HOST = re.compile(r'^[a-z0-9.-]{1,253}$')
def ping(host: str) -> str:
if not HOST.fullmatch(host):
raise ValueError('invalid host')
result = subprocess.run(
['ping', '-c', '1', host], # argv, no shell
shell=False,
capture_output=True,
text=True,
timeout=5,
check=False,
)
return result.stdout
import re
import subprocess
HOST = re.compile(r'^[a-z0-9.-]{1,253}$')
def ping(host: str) -> str:
if not HOST.fullmatch(host):
raise ValueError('invalid host')
result = subprocess.run(
['ping', '-c', '1', host], # argv, no shell
shell=False,
capture_output=True,
text=True,
timeout=5,
check=False,
)
return result.stdout
Templating and SSTI
Jinja2's Environment has autoescape off unless you ask for it, so a
variable renders as raw markup; select_autoescape(['html', 'xml']) is the switch, and
Flask's render_template already applies it to .html files. Two sinks follow.
The first is the developer's own escape hatch — |safe, Markup(),
autoescape false — which is a stored-XSS finding whenever the value is user data. The
second is compiling user input as a template: render_template_string with an f-string,
or jinja2.Template(request.args['t']), turns a greeting into server-side template
injection (CWE-1336), where a probe proves evaluation and the object graph walk
reaches the interpreter. Templates come from disk or a constant; values go in as variables.
from flask import Flask, request, render_template_string
app = Flask(__name__)
@app.get('/greet')
def greet():
name = request.args['name']
# The user's string is compiled as a Jinja2 template: a probe returns 49
return render_template_string(f'<p>Hello {name}</p>')
from flask import Flask, request, render_template_string
app = Flask(__name__)
@app.get('/greet')
def greet():
name = request.args['name']
# The user's string is compiled as a Jinja2 template: a probe returns 49
return render_template_string(f'<p>Hello {name}</p>')
from flask import Flask, request
from jinja2 import Environment, FileSystemLoader, select_autoescape
app = Flask(__name__)
env = Environment(
loader=FileSystemLoader('templates'),
autoescape=select_autoescape(['html', 'xml']), # off by default
)
@app.get('/greet')
def greet():
template = env.get_template('greet.html') # from disk, never from input
return template.render(name=request.args.get('name', ''))
from flask import Flask, request
from jinja2 import Environment, FileSystemLoader, select_autoescape
app = Flask(__name__)
env = Environment(
loader=FileSystemLoader('templates'),
autoescape=select_autoescape(['html', 'xml']), # off by default
)
@app.get('/greet')
def greet():
template = env.get_template('greet.html') # from disk, never from input
return template.render(name=request.args.get('name', ''))
eval, exec and compile
eval, exec and compile execute Python, so a reachable
eval is remote code execution with extra steps — __import__('os').system('id')
needs no gadget at all. ast.literal_eval parses literals only (strings, numbers, tuples,
lists, dicts, sets, booleans and None) and raises on anything else, which is the right
replacement when the input is data. When the input selects behaviour, use a dispatch dict keyed by
an allowlisted string rather than evaluating the operator name. Grep is unusually effective here
because the sinks are short and rarely wrapped, and a # noqa on the line is not a control.
from flask import Flask, request
app = Flask(__name__)
@app.get('/calc')
def calc():
expr = request.args['expr']
# __import__('os').system('id') is a valid "expression"
return {'result': eval(expr)}
from flask import Flask, request
app = Flask(__name__)
@app.get('/calc')
def calc():
expr = request.args['expr']
# __import__('os').system('id') is a valid "expression"
return {'result': eval(expr)}
import ast
OPERATIONS = {
'double': lambda n: n * 2,
'half': lambda n: n / 2,
}
def apply(raw_op: str, raw_value: str) -> float:
if raw_op not in OPERATIONS: # allowlist the behaviour
raise ValueError('unsupported operation')
value = ast.literal_eval(raw_value) # literals only: no calls, no names
if not isinstance(value, (int, float)):
raise ValueError('not a number')
return OPERATIONS[raw_op](value)
import ast
OPERATIONS = {
'double': lambda n: n * 2,
'half': lambda n: n / 2,
}
def apply(raw_op: str, raw_value: str) -> float:
if raw_op not in OPERATIONS: # allowlist the behaviour
raise ValueError('unsupported operation')
value = ast.literal_eval(raw_value) # literals only: no calls, no names
if not isinstance(value, (int, float)):
raise ValueError('not a number')
return OPERATIONS[raw_op](value)
Framework defaults
Framework reviews are won in the settings module, and that module is also where stale advice
accumulates. Django removed SECURE_BROWSER_XSS_FILTER in 4.0 (deprecated
in 3.0): setting it today does nothing, so its presence dates the file and justifies reading every
other line sceptically. What still matters is DEBUG off, an explicit
ALLOWED_HOSTS, HTTPS redirect and HSTS, secure cookies, a narrowed
CSRF_TRUSTED_ORIGINS, and mark_safe/format_html used only on
developer-built strings. On Flask, keep SECRET_KEY in the environment — a hardcoded key
signs forgeable sessions — and set SESSION_COOKIE_SECURE,
SESSION_COOKIE_HTTPONLY, TRUSTED_HOSTS and MAX_CONTENT_LENGTH.
# settings.py
# SECURE_BROWSER_XSS_FILTER was removed in Django 4.0 — it is not a setting
# any more, and adding it back changes nothing.
DEBUG = False
ALLOWED_HOSTS = ['app.example.com']
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 31_536_000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
CSRF_COOKIE_SECURE = True
CSRF_TRUSTED_ORIGINS = ['https://app.example.com']
X_FRAME_OPTIONS = 'DENY'
| Setting | Status | Why it matters |
|---|---|---|
SECURE_BROWSER_XSS_FILTER |
Removed in 4.0 | No effect; its presence marks an unreviewed settings file |
SECURE_CONTENT_TYPE_NOSNIFF |
Current | Stops MIME sniffing turning an upload into script |
SECURE_HSTS_SECONDS |
Current | Closes the downgrade window after the first request |
SESSION_COOKIE_SECURE, CSRF_COOKIE_SECURE |
Current | Keeps the session and CSRF token off plaintext hops |
DEBUG, ALLOWED_HOSTS |
Current | Debug pages leak settings, SQL and source; host filtering stops header abuse |
Path handling
os.path.join has a trap that reads like a bug report and is documented behaviour: if a
later component is absolute, the earlier ones are discarded. os.path.join('/srv/uploads',
'/etc/passwd') is /etc/passwd, so a parameter that begins with a slash escapes
the base with no .. anywhere, and the traversal form works as well. Build the path with
pathlib, resolve it — which also collapses symlinks — then assert containment with
Path.is_relative_to. Resolve the base once at import time. The check still leaves a
time-of-check window, so where that matters open with os.O_NOFOLLOW and keep the file
descriptor.
import os
from flask import Flask, request, send_file
app = Flask(__name__)
@app.get('/download')
def download():
name = request.args['name']
# An absolute name discards the base: join('/srv/uploads', '/etc/passwd')
return send_file(os.path.join('/srv/uploads', name))
import os
from flask import Flask, request, send_file
app = Flask(__name__)
@app.get('/download')
def download():
name = request.args['name']
# An absolute name discards the base: join('/srv/uploads', '/etc/passwd')
return send_file(os.path.join('/srv/uploads', name))
from pathlib import Path
from flask import abort, request, send_file
BASE = Path('/srv/uploads').resolve()
@app.get('/download')
def download():
candidate = (BASE / request.args['name']).resolve()
if not candidate.is_relative_to(BASE): # Python 3.9+
abort(404)
return send_file(candidate)
from pathlib import Path
from flask import abort, request, send_file
BASE = Path('/srv/uploads').resolve()
@app.get('/download')
def download():
candidate = (BASE / request.args['name']).resolve()
if not candidate.is_relative_to(BASE): # Python 3.9+
abort(404)
return send_file(candidate)
Two related Python-only details are worth a second look. tarfile.extractall honours
member names, so an archive entry of ../../etc/cron.d/x writes outside the target
directory unless you filter members — filter='data' is the supported form on current
releases. And open() on a path assembled from two user-influenced fragments can be
redirected by a NUL byte or a Windows device name, so validate the components, not the joined string.
| Check | Expected | Where to look |
|---|---|---|
| Unpickling of anything request-shaped | No pickle.load/loads outside test fixtures and trusted cache readers | grep -rnE "pickle\.loads?\(" . |
| YAML entry points | Only safe_load, or load(..., Loader=) with a SafeLoader subclass | grep -rnE "yaml\.(load|unsafe_load|full_load)\(" . |
| Shell execution | No shell=True, os.system or os.popen on an interpolated string | grep -rnE "shell=True|os\.system|os\.popen" . |
| Template compilation | render_template_string and jinja2.Template receive constants, not request data | grep -rnE "render_template_string|Template\(" . |
| Filesystem containment | Every user-influenced path goes through resolve() plus is_relative_to() | grep -rnE "os\.path\.join\(|send_file\(" . |
| Removed Django settings | SECURE_BROWSER_XSS_FILTER absent — it was removed in Django 4.0 | grep -rn "SECURE_BROWSER_XSS_FILTER" . |
Evidence Collection
For a Python deserialization or injection finding, record:
The sink, quoted with file and line: Line 42 of importer.py calls pickle.loads on the uploaded blob is a sentence a reviewer can act on; a screenshot of a traceback is not.
The data path, not just the sink: The route, the parameter, and every hop the value takes before it reaches the interpreter — the fix belongs at the boundary that still has context, so the report has to name that boundary.
Proof of execution inside the interpreter: The output of id, a file the worker wrote, or a callback to your own listener — plus the OS user the process ran as, which decides whether the finding is medium or critical.
False Positive Identification
- Deserialization of your own output: A cache reader that only ever unpickles bytes it wrote itself is not remotely reachable — until a second writer, a shared Redis or a user-writable key appears. State which of those exist before you downgrade it.
- eval in a test helper or a migration: Reachable only from the test runner or a one-off script? Then it is not a production finding, but it is still a pattern the next developer will copy. Report it as a cleanup, not a vulnerability.
- The flagged call that is not the sink: yaml.safe_load matches a naive grep for yaml.load, and subprocess.run with a list and no shell is the fix, not the bug. Read the arguments before you read the finding.
Python Review Checklist
Deserialization
- ☐ List every pickle, yaml, marshal, shelve and dill call, then trace each argument back to a request, queue, file upload or shared cache.
- ☐ Check YAML entry points for an explicit Loader and a SafeLoader base, and confirm no unsafe_load or Loader=yaml.Loader survives.
Execution and templating
- ☐ Grep for eval, exec, compile, __import__, os.system, os.popen and shell=True; for each hit, decide whether any input segment is attacker-controlled.
- ☐ Check that Jinja2 environments pass autoescape=select_autoescape, and that no template is built from a request value or marked |safe.
Filesystem and framework
- ☐ Follow every os.path.join, open(), send_file and Path() built from input; an absolute component silently discards the base.
- ☐ Diff the settings module against the current framework docs: DEBUG, ALLOWED_HOSTS, cookie flags, HSTS, and settings that were removed.
Remediation & Defense
Each class has one structural fix: keep data out of the interpreter and keep the interpreter out of the data path.
Replace the format, not the payload check
- • Move session state, job payloads and cache values to JSON or MessagePack with a keyed MAC, verified before parsing. A denylist of "bad" pickle opcodes is not a fix.
Explicit loaders and explicit tags
- • Standardise on yaml.safe_load, and register custom tags on a SafeLoader subclass. Since PyYAML 6.0 the Loader argument is mandatory, so the unsafe call fails loudly rather than silently.
argv, allowlist, timeout
- • Call programs through a list with shell=False, validate the argument against a strict pattern, pass -- before positionals, set a timeout, and run the worker unprivileged.
Templates from disk
- • Compile templates from files or module constants, render values as variables, leave autoescape on, and treat |safe as a code-review trigger rather than a formatting choice.
Resolve, then assert containment
- • Build paths with pathlib, resolve the base once, and reject any candidate that is not is_relative_to() the base. Open with O_NOFOLLOW where a symlink race would matter.
Python Deserialization and SSTI Practice
Work the Python-specific sinks against applications built to be broken, then re-read your own code with the same questions.
Related Source Code Review Guides
Deserialization Attacks
Gadget chains, native serialization formats and where object graphs get accepted
Server-Side Template Injection
Jinja2 payloads from a probe to interpreter access
Command Injection
Separators, blind confirmation and filter bypasses
Remediation Guidance
The fix catalogue this page defers to for library-specific recipes
SAST and Scanning Tools
Semgrep and CodeQL rules that catch these sinks automatically