websec · level 2108

Prototype Pollution

JS-specific Object.prototype mutation. lodash/jQuery/express. Object.create(null) defense.

250 XP

Prototype Pollution

A bug class that only exists in JavaScript, where it has been the source of dozens of CVEs across lodash, jQuery, Express, set-value, mongoose, hoek, and many more. The TL;DR: every plain JS object shares one mutable global thing — Object.prototype — and if attacker input can write to it, they get to inject properties into every object the application uses.

The fundamental shape

const o = {};
o.foo;                  // undefined — no own property, no inherited property either
Object.prototype.foo = "polluted";
o.foo;                  // "polluted" — every object that doesn't shadow `foo` now has it

That's the bug. The polluter doesn't need to touch the specific object; modifying the prototype is enough to leak the property to every consumer.

The polluter key is usually __proto__:

const x = {};
x.__proto__.isAdmin = true;          // same as Object.prototype.isAdmin = true
({}).isAdmin                         // → true

Or constructor.prototype (slightly more roundabout):

const x = {};
x.constructor.prototype.isAdmin = true;  // same outcome

How attacker JSON triggers it

Most apps deep-merge user input into config or session objects. Something like:

function deepMerge(target, source) {
  for (const key of Object.keys(source)) {
    if (typeof source[key] === "object") {
      target[key] = target[key] ?? {};
      deepMerge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
}

The attacker sends:

{ "__proto__": { "isAdmin": true } }

Walk through it. Object.keys({ "__proto__": { … } }) returns ["__proto__"]. The merge writes into target["__proto__"] — which is, well, target.__proto__ — which is Object.prototype. The deeper merge writes Object.prototype.isAdmin = true. Every object now has isAdmin = true. The auth middleware that checks req.user.isAdmin returns true for every request.

Real CVEs of exactly this shape:

  • lodash CVE-2019-10744 (defaultsDeep): full prototype pollution via __proto__, fixed in 4.17.12.
  • lodash CVE-2018-3721 (merge, mergeWith): similar.
  • lodash CVE-2020-8203 (zipObjectDeep): another deep-merge variant.
  • jQuery $.extend(true, , …) (CVE-2019-11358): same bug class.
  • set-value, set-deep-prop, dot-prop, hoek, mongoose: dozens of similar.

What it enables

Once Object.prototype is polluted, the impact depends on what the app does next:

  • Auth bypass: any code that checks if (req.user.isAdmin) reads the polluted truthy value.
  • RCE in some frameworks: if the app calls child_process.spawn with options derived from user objects, the attacker can inject shell or other dangerous flags. Documented in pug, ejs, jade, Handlebars, and other template-engine SSR setups.
  • Denial of service: pollute methods like toString to throw on every operation.
  • Data corruption: pollute serialization defaults, configuration constants.

The blast radius is "anywhere in the app that touches an object."

Defenses

Stack them. None alone is bulletproof.

1. Validate / allowlist input keys

Refuse __proto__, constructor, prototype in any user input that gets merged into objects:

const FORBIDDEN = new Set(["__proto__", "constructor", "prototype"]);

function safeMerge(target, source) {
  for (const key of Object.keys(source)) {
    if (FORBIDDEN.has(key)) continue;
    // ... rest of merge logic
  }
}

This catches the obvious case. Sophisticated attackers find paths like a.constructor.prototype.x — the validation needs to be on every key in every nested level.

2. Use Object.create(null)

Plain {} objects descend from Object.prototype. Object.create(null) creates an object with no prototype chain at all:

const o = Object.create(null);
o.__proto__;        // undefined (no chain)
o.toString;         // undefined (the inherited method is gone)

Setting __proto__ on a prototype-less object just adds an own property called __proto__; it does not modify any prototype chain. This is the right shape for any map keyed by user input.

const sessions = Object.create(null) as Record<string, Session>;
sessions[req.userId] = ...;   // safe even if req.userId is "__proto__"

3. Use Map

The ES6 Map is prototype-pollution-immune by construction:

const m = new Map();
m.set("__proto__", "totally fine");
m.set(42, "even non-string keys work");
m.get("__proto__");   // "totally fine"

Maps don't share a global mutable space. They're slightly slower for small workloads and a bit more verbose, but they're the right default for any user-keyed collection.

4. Object.freeze(Object.prototype) at boot

// At the top of your entry point, before anything else loads:
Object.freeze(Object.prototype);
Object.freeze(Object.create(null));

After this, Object.prototype.x = "..." silently fails (or throws in strict mode). The attack at the prototype level is foreclosed entirely. Many serious projects ship this.

There's a small list of libraries that monkey-patch Object.prototype at runtime; they will break. Most of those are very old jQuery plugins, MooTools, Prototype.js (the library, ironically). Modern code is fine.

5. JSON.parse strict mode

JSON.parse does NOT process __proto__ as the actual prototype — it creates an own property "__proto__" on the resulting object. So parsing JSON is safe; the dangerous step is merging the parsed object into other state without sanitization.

If your code does:

const cfg = JSON.parse(req.body);     // safe by itself
deepMerge(globalConfig, cfg);         // ← this is where the bug lands

Replace the merge.

What's safe and what isn't

Roughly:

  • JSON.parse(userInput) — safe. No prototype mutation.
  • Object.assign(target, userInput) — safe (shallow; __proto__ becomes an own property of target).
  • target[key] = value where key is user-controlled — dangerous. If key === "__proto__", you're mutating the prototype.
  • target.path.to.deep[key] = valuedangerous, transitively.
  • Deep-merge / deep-set / deep-extend libraries with user input — dangerous unless they explicitly skip prototype keys.

Audit any function in your code or your dependencies that takes a "path" or "key chain" derived from user input. That's where this bug lives.

Detection in production

  • ESLint with eslint-plugin-security flags Object.assign(target, userInput) and the related patterns.
  • npm audit / Snyk catch known CVEs in dependencies, including lodash variants.
  • Snyk Code (SAST) finds prototype-pollution sinks across your codebase.
  • Object.freeze(Object.prototype) at boot is a single line that costs nothing.

What goes wrong (real)

In 2022, a popular admin panel for a SaaS shipped a config-import feature: paste a JSON blob, the panel would deep-merge it with the live config. The deep-merge was an unaudited utility from a tiny package on npm. A user tried {"__proto__":{"isAdmin":true}}. The next request from any user came back as admin. CVE assigned, panel patched, but the patch was a one-line key-blocklist; the architectural fix (use a Map for the config) shipped 6 months later.

The general lesson: deep-merge with user input is a footgun in JavaScript. There are only two right answers:

  1. Don't deep-merge user input. Validate-and-set-explicitly each field you accept.
  2. If you must, use a deep-merge that hardens against prototype keys AND a destination object made with Object.create(null).

Anything in between is a future CVE.

What to ship

// utils/safe-collections.ts
export function emptyMap<V>() {
  return Object.create(null) as Record<string, V>;
}

export const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);

export function safeAssign<T extends object>(target: T, source: Record<string, unknown>): T {
  for (const key of Object.keys(source)) {
    if (FORBIDDEN_KEYS.has(key)) continue;
    (target as Record<string, unknown>)[key] = source[key];
  }
  return target;
}

// In your app entry point:
Object.freeze(Object.prototype);

Use these everywhere user input flows into object state. The bug class disappears.

Tools in the wild

3 tools
  • Static analysis flags `Object.assign(target, userInput)` and prototype-mutating patterns.

    library
  • SAST that detects prototype-pollution sinks across the dependency tree.

    service
  • One-line global hardening: prevents any subsequent mutation of Object.prototype.

    spec