Blog/Web Security
Web SecurityAugust 9, 2026 · 7 min read

Insecure Deserialization: How Loading Data Becomes Running Code

An app stores a user's session or preferences by serializing an object and handing it back to the client, then trusts it on the next request:

import pickle, base64
# On response: cookie = base64(pickle.dumps(session_obj))
# On request:
session = pickle.loads(base64.b64decode(request.cookies["session"]))

The problem: pickle.loads doesn't just read data — it reconstructs objects, and a pickle stream can specify which objects to build and how. An attacker replaces the cookie with a crafted payload whose reconstruction calls os.system, and your server runs their command the instant it loads the session — before any of your code inspects a single field. This is insecure deserialization, and its defining trait is that the damage happens during loading, so validating the result afterward is already too late.

Why "just loading data" runs code

Native serialization formats are designed to round-trip rich objects, so they encode type and construction, not only values. Reconstructing them invokes real code paths:

  • Python pickle executes reduce methods during loads — arbitrary callables with arbitrary arguments.
  • Java ObjectInputStream calls readObject on the deserialized classes; chains through libraries like Commons-Collections reach Runtime.exec.
  • PHP unserialize triggers magic methods (__wakeup, __destruct) on reconstructed objects — the basis of "POP chains."
  • Ruby Marshal.load and .NET BinaryFormatter have the same shape.

The attacker rarely injects new code. They assemble a gadget chain out of methods already present in your dependencies, so deserializing their object walks those methods to a dangerous sink. That's why the fix isn't "sanitize the object" — by the time you could, the code has already run.

Where it hides

Look for any place your app deserializes data that a user can influence:

  • Session tokens, cookies, or "state" blobs stored client-side in a native format
  • Message-queue payloads, cache entries (a serialized object in Redis/Memcached)
  • File uploads processed by a library that deserializes (some document/model formats)
  • APIs accepting application/x-java-serialized-object, or JSON with polymorphic type hints (@class, $type)
  • pickle/Marshal/unserialize anywhere near request-controlled bytes

The fix: use a data-only format, verify integrity, and don't resolve types

Three ideas, in priority order.

import json, hmac, hashlib
from pydantic import BaseModel

# 1) NEVER pickle untrusted data. Use JSON, which only carries data.
class Session(BaseModel):
  user_id: int
  role: str
  exp: int

def load_session(raw: str) -> Session:
  data = json.loads(raw)          # produces a plain dict, no code runs
  return Session(**data)          # schema-validate the fields

# 2) If the client holds the token, sign it so it can't be swapped.
SECRET = b"server-only-key"
def sign(payload: str) -> str:
  mac = hmac.new(SECRET, payload.encode(), hashlib.sha256).hexdigest()
  return f"{payload}.{mac}"

def verify(token: str) -> str:
  payload, mac = token.rsplit(".", 1)
  expected = hmac.new(SECRET, payload.encode(), hashlib.sha256).hexdigest()
  if not hmac.compare_digest(mac, expected):
      raise ValueError("Tampered token")
  return payload  # only now is it safe to json.loads + validate

The principles

  • Serialize data, not objects. Use JSON/Protobuf/MessagePack for anything crossing a trust boundary. They carry values, so loading them can't run code.
  • Turn off type resolution. Never enable polymorphic/default typing on untrusted input (Jackson default typing, $type/@class hints). If you truly need it, use a strict allowlist of permitted types.
  • Sign what the client holds. If a token or blob round-trips through the browser, HMAC-sign it and verify before parsing — better yet, keep session state on the server and hand out an opaque ID.
  • Never pickle/Marshal/unserialize untrusted bytes. There is no safe way to deserialize an arbitrary native object; the reconstruction is the exploit.

One line to keep: deserialization runs before validation, so the only safe input is a format that can't express code. Choose the format, and the gadget chains have nothing to grab.

Walk the attack in the insecure deserialization simulation: swap a serialized session for a gadget payload, trigger execution on load, then move to signed JSON and watch it become inert. Pairs naturally with the API token storage guide — both are about not trusting state the client hands back.

Share this post

Frequently Asked Questions

Related posts

Race Conditions in Web Apps: When Two Requests Beat Your Check

Check-then-act logic that looks correct on paper breaks when two requests run at once. Here's how TOCTOU race conditions drain balances and reuse one-time codes, and how atomic operations and locks fix them.

Aug 11, 2026 · 7 min read

Mass Assignment: How role=admin Ends Up in Your Update

Mass assignment lets an attacker set fields you never meant to expose — like isAdmin or accountBalance — by adding them to a request body. Here's why binding whole objects is dangerous and how to allowlist fields in Node, Rails, and Django.

Aug 10, 2026 · 6 min read

WebSocket Security: Why the Same-Origin Rules Don't Apply

WebSockets skip the protections you rely on for normal requests — CORS doesn't apply and cookies are sent automatically, enabling cross-site hijacking. Here's how to validate origins, authenticate connections, and secure real-time apps.

Aug 2, 2026 · 6 min read