18 min read

White-Box Testing
Intermediate
A05 A08

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

Python 3.15 is current, and the deserialization advice here does not move with the minor version. Two library changes do. PyYAML 6.0 (October 2021) made the 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.

importer.py
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',))

The gadget is not the vulnerability

Detecting a gadget chain in a payload tells you the exploit works; it does not tell you where the bug is. The bug is the 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.

config.py
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"]

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.

diagnostics.py
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()

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.

greet.py
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>')

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.

calc.py
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)}

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

files.py
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))

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.

PyGoat — Python Web Application Custom Lab medium
Pickle deserializationJinja2 SSTICommand injection
PortSwigger — Server-Side Template Injection PortSwigger hard
Template injection probesContext escapeInterpreter access