# Python security: 4 common vulnerabilities we reproduced and fixed

URL: https://systemdesignsimple.com/python-security-common-vulnerabilities/
Published: 2026-11-06
Category: Security
Publisher: System Design Simple (https://systemdesignsimple.com)

## Short answer

Four Python failures: client input changes SQL, supplies pickle instructions, enters a shell command, or escapes a folder. Fixes: ? placeholders, server-side or signed sessions with JSON, argument lists without a shell, and resolve() plus is_relative_to(). In our Python 3.14.7 lab all 4 attacks worked on the unsafe code, the fix blocked 4/4, and 2/2 normal inputs still worked.

## Key facts

- **SQL injection:** db.execute('... WHERE email = ?', (email,))
- **Unsafe deserialization:** Opaque session ID with server-side state, or a verified signature, and JSON instead of pickle
- **Command injection:** subprocess.run(['ls', path]) with a list, no shell=True; add -- only where the program supports it
- **Path traversal:** (base / name).resolve().is_relative_to(base), base already resolved
- **Our lab:** Python 3.14.7: reproduced 4/4, blocked 4/4, normal inputs unchanged 2/2
- **Tooling:** Bandit and code search for your own code; pip-audit for dependencies; regression tests per fix

Four Python security failures occur when client input changes SQL, supplies pickle instructions, enters a shell command, or escapes an allowed directory. Use bound SQL values, authenticated sessions with safe serialization, argument lists, and path containment checks backed by filesystem permissions. We reproduced all four in our lab and spend the most time on the pickle case, unsafe deserialization.

This guide is for protecting applications you own or maintain.

## Background

The [pickle documentation](https://docs.python.org/3.14/library/pickle.html) warns that the module is not secure and that crafted data can run arbitrary code during unpickling; it points to `hmac` for tamper checks and to JSON for untrusted data. The [subprocess security section](https://docs.python.org/3.14/library/subprocess.html#security-considerations) covers `shell=True`.

## Prerequisites

`sqlite3`, `/bin/sh` and the pickle loader are all interpreters: each reads text or bytes and acts on them. A bug appears when client data lands where the interpreter expects instructions.

## Definitions

> Definition: **Injection**: client text is joined into a string that an interpreter (SQL, a shell) parses, so part of the text is read as code.

> Definition: **Unsafe deserialization**: loading bytes in a format that can name and call functions, so the bytes decide what runs while the object is rebuilt.

> Definition: **Path traversal**: a file name with `..` or an absolute path resolves outside the folder you meant to serve.

## Why is pickle unsafe? What pickle.loads really does

A pickle is an instruction stream for a stack machine. For this payload, `__reduce__` supplies the callable and its argument tuple. Here is the lab's payload, trimmed:

```python
# payload_lab.py (lab only: writes an empty marker file in a temp folder)
import pickle, tempfile
from pathlib import Path

marker = Path(tempfile.mkdtemp()) / "pickle-ran.txt"

class Payload:
    def __reduce__(self):
        return (Path.touch, (marker,))

blob = pickle.dumps(Payload())
pickle.loads(blob)
print(marker.exists())   # True
```

Running the stream through [pickletools](https://docs.python.org/3.14/library/pickletools.html) shows the order. `STACK_GLOBAL` resolves `Path.touch` through [`Unpickler.find_class`](https://docs.python.org/3.14/library/pickle.html#pickle.Unpickler.find_class). The stream then reconstructs the marker path, and `REDUCE` invokes the callable with that path. `Payload.__reduce__` runs while creating the pickle; the receiving process needs the referenced objects, but it does not need `Payload`. Real pickling also uses `__reduce_ex__` and registered reducers ([reduction protocol](https://docs.python.org/3.14/library/pickle.html#object.__reduce__)).

## Reproduce: the four mistakes on the unsafe version

Lab python-security-lab is one offline script, [`run.py`](/labs/security/python-security-lab/python-security-lab.py), standard library only, driven by `labs/security/python-security-lab/python-security-lab.mjs`. It builds an in-memory SQLite table of three fake users, a temp folder with `public/hello.txt` beside a fake `secret.env`, and a forged "saved session" pickle. We ran it on this Mac on 2026-11-06 at 10:36 UTC with Python 3.14.7.

| Mistake | Input | Unsafe result |
|---|---|---|
| SQL in an f-string | `x' OR '1'='1` | 3 rows for one email lookup |
| `pickle.loads` on client bytes | pickle bytes from the client | code ran on load |
| `subprocess.run(..., shell=True)` | `hello.txt; touch <tmp>/shell-ran.txt` | the extra command ran |
| `open(base / name)` | `../secret.env` | the secret file was reachable |

## Experiment: attack and normal inputs on both versions

| Test | Input | Unsafe | Fixed |
|---|---|---|---|
| SQL lookup, rows returned (normal) | `a@example.test` | 1 | 1 |
| SQL lookup, rows returned (attack) | `x' OR '1'='1` | 3 | 0 |
| Load a saved session, code ran on load | pickle bytes from the client | true | false |
| ls on a user file name, extra command ran | `hello.txt; touch <tmp>/shell-ran.txt` | true | false |
| File download, file reachable (normal) | `hello.txt` | true | true |
| File download, file reachable (attack) | `../secret.env` | true | false |

The side effect of the pickle happened inside `pickle.loads` itself, before our code could inspect the result. A `try/except` around the call did not stop it.

## Fix: SQL injection with sqlite3 placeholders

An f-string pastes the email into the SQL text, so the quote in `x' OR '1'='1` closes the string literal and the rest becomes a condition that is always true. With `?`, [sqlite3](https://docs.python.org/3.14/library/sqlite3.html) compiles the statement first and binds the value into the slot afterwards, so the quotes stay part of an email address that no user has.

```python
# users.py
import sqlite3

def find_user_unsafe(db: sqlite3.Connection, email: str):
    return db.execute(f"SELECT id FROM users WHERE email = '{email}'").fetchall()

def find_user(db: sqlite3.Connection, email: str):
    return db.execute("SELECT id FROM users WHERE email = ?", (email,)).fetchall()
```

`(email,)` is a tuple; `(email)` is a plain string. More in [what SQL injection is and how to prevent it](/what-is-sql-injection-and-how-to-prevent-it/).

## What to use instead of pickle for sessions

The JSON parser only builds dicts, lists, strings, numbers, booleans and `None`; it never calls a function. It does not authenticate anything, though: a client can edit `{"role": "user"}` into `{"role": "admin"}`. Use an opaque session ID backed by server-side state, or verify a signed session before trusting its fields. Check whether your framework stores session state server-side or authenticates client-side session data. Minimal storage example (expiry, logout invalidation and cookie settings omitted):

```python
# session.py (minimal storage example)
import json, pickle, secrets

SESSIONS: dict[str, dict] = {}            # use Redis or a database table in production

def load_session_unsafe(cookie: bytes):
    return pickle.loads(cookie)             # the cookie decides what runs

def start_session(user_id: int) -> str:
    sid = secrets.token_urlsafe(32)         # opaque, carries no data
    SESSIONS[sid] = {"user_id": user_id, "role": "user"}
    return sid                              # send this as the cookie

def load_session(sid: str) -> dict | None:
    return SESSIONS.get(sid)                # fields come from the server

def export_settings(data: dict) -> str:
    return json.dumps(data)                 # JSON for any data that crosses the boundary
```

The pickle docs also show a restricted `find_class` allowlist and warn that a loose one can still be abused. For untrusted YAML, use `yaml.safe_load`; avoid loaders that permit Python object construction ([PyYAML documentation](https://pyyaml.org/wiki/PyYAMLDocumentation)). [`shelve`](https://docs.python.org/3.14/library/shelve.html) is built on pickle and carries its own warning. We did not test YAML or shelve.

## Is shell=True dangerous? Command injection in subprocess

With `shell=True` and a string, Python runs `/bin/sh -c "<string>"` on macOS and Linux, and `;` separates commands.

```python
# listing.py
import subprocess
from pathlib import Path

def list_file_unsafe(pub: Path, name: str):
    return subprocess.run(f"ls {pub / name}", shell=True, capture_output=True)

def list_file(pub: Path, name: str):
    return subprocess.run(["ls", str(pub / name)], capture_output=True)
```

With a list and `shell=False`, Python starts `ls` directly and passes each item as one argument, so shell metacharacters stay literal. In our run `ls` got the whole name, semicolon included, reported a missing file, and `touch` never ran. The program still interprets its own arguments: a name starting with `-` can look like an option, and `--` ends option parsing only in programs that support it. On Windows, batch files can involve shell parsing even with `shell=False`, per the [subprocess security notes](https://docs.python.org/3.14/library/subprocess.html#security-considerations). Without `check=True`, a nonzero exit does not raise, so read `returncode`.

## Path traversal in Python: why base / name is not enough

`pathlib` joins without normalizing, so `pub / "../secret.env"` keeps the `..` and the OS follows it. An absolute name replaces the base: `pub / "/etc/hosts"` is `/etc/hosts`, per the [pathlib docs](https://docs.python.org/3.14/library/pathlib.html).

```python
# download.py
from pathlib import Path

PUBLIC_DIR = Path("public").resolve()            # resolve the base once

def read_public_unsafe(name: str) -> bytes:
    return (PUBLIC_DIR / name).read_bytes()

def safe_path(base: Path, name: str) -> Path:
    target = (base / name).resolve()
    if not target.is_relative_to(base):
        raise FileNotFoundError(name)
    return target

def read_public(name: str) -> bytes:
    return safe_path(PUBLIC_DIR, name).read_bytes()
```

`resolve()` removes `..` and follows symbolic links; `is_relative_to` (Python 3.9+) compares path components, so a sibling folder `public-old` fails where a string `startswith` would pass.

## Report: what our lab showed

[Image: Terminal output of our python-security-lab lab run: node run.mjs | A Chrome rendering of the text output of `node labs/security/python-security-lab/python-security-lab.mjs` (2026-11-06, 10:36 UTC, Python 3.14.7), cropped before the totals. Full output: [last-run.txt](https://systemdesignsimple.com/labs/security/python-security-lab/last-run.txt); script: [run.py](https://systemdesignsimple.com/labs/security/python-security-lab/python-security-lab.py).]

| Count | Result |
|---|---|
| Reproduced on the unsafe version | 4/4 |
| Blocked by the fix | 4/4 |
| Normal inputs unchanged | 2/2 |

The lab is narrow: one process on one Mac, in-memory SQLite, no web framework, macOS `/bin/sh` and one attack input per mistake. The pickle and shell tests had no normal-input row, so "normal inputs unchanged" covers only SQL and file download. The lab's pickle fix was `json.loads`; the server-side session code above was not run. We did not test symbolic links, a tree that changes mid-request, the restricted unpickler or `--`.

## Failure modes and edge cases

- A path check passes, then the tree changes before the read. Pair the check with file permissions that keep the app away from secrets.
- A symlink inside the public folder points outside it. `resolve()` follows it and the check rejects it, which may break content you meant to serve; decide that case explicitly.
- A column or table name cannot be a placeholder. Pick it from a fixed dict.

## Trade-offs

| Option | Good for | Costs you | Use when |
|---|---|---|---|
| `?` placeholders | every SQL value | dynamic columns need an allowlist | always |
| Server-side session | identity and roles | a session store | users log in |
| Signed client session | stateless servers | key management; data visible to the client | small, non-secret state |
| Argument list, no shell | calling a program | no pipes or globs; check `returncode` | you must call a program |
| `resolve()` + `is_relative_to` | serving files by name | symlinked content needs a decision | user picks a file |

## Common mistakes

- Swapping pickle for JSON in a cookie and trusting its `role` field. Keep identity on the server or verify a signature first.

- Running `shlex.quote` on part of a `shell=True` string. Pass a list.

- Checking `str(target).startswith(str(base))`. Use `target.is_relative_to(base)` after `resolve()`.

- Expecting `pip-audit` to catch these. It checks dependencies against advisories, not your code.

## Code: test your own app

Search for the four shapes, then run [Bandit](https://bandit.readthedocs.io/en/latest/), which flags `pickle`, `shell=True` and string-built SQL. Run [pip-audit](https://github.com/pypa/pip-audit) separately for dependencies.

```sh
# from your project root
grep -rnE "execute\(f[\"']|execute\([^)]*%|execute\([^)]*\.format\(" src/   # SQL built from strings
grep -rnE "pickle\.loads?\(|yaml\.load\(|shelve\.open\(" src/               # loaders that can run code
grep -rnE "shell=True|os\.system\(|os\.popen\(" src/                        # shell calls to review
grep -rnE "open\([^)]*request|send_file\(" src/                             # paths from request data
bandit -r src/
```

These three tests cover SQL binding, path containment and rejection of malformed JSON. Add normal JSON and subprocess tests for your application. They import the functions above from `users.py` and `download.py`; `pytest test_security.py` should report `3 passed`.

```python
# test_security.py
import json, sqlite3
import pytest
from users import find_user
from download import safe_path

def test_sql_placeholder():
    db = sqlite3.connect(":memory:")
    db.execute("CREATE TABLE users (id INTEGER, email TEXT)")
    db.execute("INSERT INTO users VALUES (1, 'a@example.test')")
    assert len(find_user(db, "a@example.test")) == 1
    assert len(find_user(db, "x' OR '1'='1")) == 0

def test_paths_stay_inside(tmp_path):
    base = tmp_path.resolve()
    (base / "hello.txt").write_text("hi")
    assert safe_path(base, "hello.txt").exists()
    for name in ["../secret.env", "/etc/hosts"]:
        with pytest.raises(FileNotFoundError):
            safe_path(base, name)

def test_json_rejects_pickle_bytes():
    with pytest.raises(ValueError):
        json.loads(b"\x80\x05not json")
```

The same reproduce, fix and test method covers Node.js in our [Node.js security write-up](/nodejs-security-common-vulnerabilities/), PHP in the [PHP security checklist](/php-security-common-vulnerabilities/), and uploads in [checking image uploads in Rails](/rails-imagemagick-upload-flaw-check-and-fix/).

## In an interview

Clarify which inputs cross a trust boundary, then go interpreter by interpreter.

> **A 60-second answer**I treat SQL, the shell, the pickle loader and the filesystem as four interpreters and keep client bytes out of their instruction channel: bound values, an argument list, JSON with server-side or signed sessions, and a resolved path checked against a resolved base, with file permissions behind it.

## Practice

**Q (Beginner): Why does `x' OR '1'='1` return every row?**
The quote closes the SQL literal and `OR '1'='1'` is true for each row.

**Q (Intermediate): Which opcode calls the function in a malicious pickle?**
`REDUCE`, after `STACK_GLOBAL` has resolved the callable through `find_class`.

**Q (Advanced): Why is `startswith` wrong for path checks?**
`/srv/public-old` starts with `/srv/public`; `is_relative_to` compares whole components.

## Cheat sheet

| Risk | Fix | Lab result (unsafe → fixed) |
|---|---|---|
| SQL injection | `?` for every value; allowlist for names | 3 rows → 0 |
| Unsafe deserialization | server-side or signed session; JSON at the boundary; `yaml.safe_load` | code ran → did not run |
| Command injection | `subprocess.run([...])`, no `shell=True` | extra command ran → did not run |
| Path traversal | resolved base, `resolve()`, `is_relative_to`, tight permissions | secret reachable → blocked |

## Further reading

- [pickle](https://docs.python.org/3.14/library/pickle.html): the security warning, the reduction protocol and restricting globals.
- [pickletools](https://docs.python.org/3.14/library/pickletools.html): disassemble a pickle to see its opcodes.
- [sqlite3](https://docs.python.org/3.14/library/sqlite3.html): placeholder styles.
- [subprocess](https://docs.python.org/3.14/library/subprocess.html#security-considerations): shell use and the Windows batch file note.
- [pathlib](https://docs.python.org/3.14/library/pathlib.html): joining, `resolve()` and `is_relative_to`.
- [Bandit](https://bandit.readthedocs.io/en/latest/): static checks for these patterns.
- [pip-audit](https://github.com/pypa/pip-audit): known advisories in your installed packages.

These are general patterns tested on throwaway data in our own lab, for applications you own or maintain. They describe no specific company's system.

## Frequently asked questions

**Is pickle safe if I sign it with HMAC?**
A valid HMAC proves the bytes were made by someone holding your key and not changed since. It stops client tampering, but anyone with the key can still produce a pickle that calls functions, so keep the key on the server and never load pickles from outside.

**Is subprocess safe without shell=True?**
For direct POSIX executable calls, shell=False keeps shell metacharacters literal. The executable still interprets its arguments; use -- only where supported. Windows batch files can involve shell parsing even with shell=False.

**Does pip-audit find these bugs?**
No. pip-audit reports installed packages with known advisories. Bugs in your own code need Bandit, code search and unit tests.

## Sources

- [Python 3.14 docs: pickle (security warning, reduction protocol, Restricting Globals)](https://docs.python.org/3.14/library/pickle.html)
- [Python 3.14 docs: pickletools](https://docs.python.org/3.14/library/pickletools.html)
- [Python 3.14 docs: sqlite3, placeholders](https://docs.python.org/3.14/library/sqlite3.html)
- [Python 3.14 docs: subprocess, security considerations](https://docs.python.org/3.14/library/subprocess.html)
- [Python 3.14 docs: pathlib](https://docs.python.org/3.14/library/pathlib.html)
- [Bandit documentation](https://bandit.readthedocs.io/en/latest/)
- [pip-audit (PyPA repository)](https://github.com/pypa/pip-audit)
