Skip to content

Prototype Pollution

intermediate18 min read

The Attack You've Probably Never Heard Of

Here's a scary thought: what if an attacker could add a property to every single object in your application — without touching your source code?

That's prototype pollution. The attacker modifies Object.prototype, and since every object inherits from it, every object in your app suddenly has the attacker's property. This can bypass authentication, trigger XSS, crash your server, or grant admin access to random users.

It's not theoretical. It has affected lodash, jQuery, Express, Mongoose, and dozens of other libraries you probably depend on. Let's understand exactly how it works, why it's so dangerous, and how to make your code immune.

Mental Model

Think of Object.prototype as a company-wide template that every document inherits from. If someone sneaks a line into that template — say, "approved: true" — then every document generated from it automatically says "approved: true." Nobody changed the individual documents, but they all changed. That's prototype pollution: poisoning the template that every object in JavaScript inherits from.

How It Works — The Core Mechanism

Every object in JavaScript inherits from Object.prototype. When you look up a property on an object and it's not there, JavaScript walks up the prototype chain. If Object.prototype has that property, your object "has" it too.

const user = { name: "Alice" };
user.isAdmin; // undefined

Object.prototype.isAdmin = true;

user.isAdmin; // true — Alice is now admin
({}).isAdmin; // true — EVERY object is now admin

That's the entire concept. The question is: how does an attacker get to Object.prototype in the first place?

Two main entry points: __proto__ and constructor.prototype.

const obj = {};

obj.__proto__ === Object.prototype; // true
obj.constructor === Object; // true
obj.constructor.prototype === Object.prototype; // true

Any code that lets a user control property keys on an object is potentially vulnerable. If the user can set __proto__.isAdmin to true, they've just polluted every object.

Quiz
What will this code log?

The Unsafe Deep Merge — Attack Vector #1

The most common source of prototype pollution is the unsafe deep merge. You've probably written one yourself, or used a library that had one:

function deepMerge(target, source) {
  for (const key in source) {
    if (
      typeof source[key] === "object" &&
      source[key] !== null
    ) {
      if (!target[key]) target[key] = {};
      deepMerge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

This looks perfectly fine, right? Now watch what happens with malicious input:

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

const config = {};
deepMerge(config, malicious);

// The merge walked into config.__proto__ (which is Object.prototype)
// and set isAdmin = true on it

const user = {};
user.isAdmin; // true — every object is now "admin"

The key insight: config["__proto__"] doesn't create a new property called "__proto__". It accesses the actual prototype of config, which is Object.prototype. So the merge writes directly to Object.prototype.

Execution Trace
Parse JSON
{"__proto__": {"isAdmin": true}}
JSON.parse creates a plain object with a key literally named '__proto__'
Start merge
deepMerge(config, malicious)
Iterate over keys of malicious
Key: __proto__
typeof source['__proto__'] === 'object'
It's an object, so we recurse
Access target.__proto__
target['__proto__'] = Object.prototype
This resolves to Object.prototype, not a new property
Recurse
deepMerge(Object.prototype, {isAdmin: true})
Now we're merging INTO Object.prototype
Set isAdmin
Object.prototype.isAdmin = true
Every object in the app now has isAdmin: true

User-Controlled JSON Keys — Attack Vector #2

Any endpoint that accepts JSON with arbitrary keys is a potential target. This includes:

  • REST API body parsing: Express req.body is user-controlled JSON
  • Query string parsing: Libraries like qs parse nested keys (a[__proto__][isAdmin]=true)
  • Configuration merging: Merging user preferences with defaults
  • Template engines: Some template engines use deep merge to combine context objects
// Express route that merges user preferences
app.post("/preferences", (req, res) => {
  const defaults = { theme: "light", lang: "en" };
  const prefs = deepMerge(defaults, req.body);
  // If req.body is {"__proto__": {"isAdmin": true}}
  // ...every object in the server is now polluted
});

The constructor.prototype path is a subtler variant that bypasses naive __proto__ filters:

const payload = JSON.parse(
  '{"constructor": {"prototype": {"isAdmin": true}}}'
);

const obj = {};
deepMerge(obj, payload);
// obj.constructor is Object
// obj.constructor.prototype is Object.prototype
// Same result: Object.prototype.isAdmin = true
Quiz
A developer adds this check to their deep merge: if (key === '__proto__') continue; — Is their code safe from prototype pollution?

Real CVEs — This Has Hit Major Libraries

Prototype pollution isn't a theoretical exercise. It has affected some of the most widely-used packages in the JavaScript ecosystem.

lodash.merge (CVE-2018-3721)

Lodash's merge and defaultsDeep functions were vulnerable until v4.17.11. Since lodash was downloaded 40+ million times per week, this affected a massive percentage of Node.js applications:

const lodash = require("lodash");

// Before the fix, this polluted Object.prototype
lodash.merge({}, JSON.parse('{"__proto__":{"polluted":"yes"}}'));

({}).polluted; // "yes"

jQuery.extend (CVE-2019-11358)

jQuery's $.extend(true, ...) (deep extend) was vulnerable in versions before 3.4.0. jQuery was on roughly 74% of all websites at the time:

// Before the fix
$.extend(true, {}, JSON.parse('{"__proto__":{"xss":"<img src=x onerror=alert(1)>"}}'));

// Any code that reads obj.xss and renders it → XSS

qs (CVE-2017-1000048)

The qs query string parser (used by Express) would parse a[__proto__][isAdmin]=true into an object that polluted Object.prototype. This affected every Express app that didn't pin to a safe version.

The scale of the problem

A 2022 study by researchers at CISPA found over 11 million client-side websites vulnerable to prototype pollution through third-party scripts. These vulnerabilities were exploitable for XSS in the browser without any server involvement. The attack surface is enormous because prototype pollution can happen anywhere JavaScript runs.

Exploitation — What Attackers Can Actually Do

Polluting Object.prototype is just the first step. Here's what an attacker does next.

Property Injection for Auth Bypass

function isAdmin(user) {
  if (user.isAdmin) {
    return true;
  }
  return false;
}

// After pollution: Object.prototype.isAdmin = true
const regularUser = { name: "Bob", role: "viewer" };
isAdmin(regularUser); // true — Bob is now admin

The check user.isAdmin walks the prototype chain. Since isAdmin isn't an own property of regularUser, JavaScript finds it on Object.prototype and returns true.

XSS Through Template Engines

Many template engines merge context objects before rendering. If the merge is vulnerable, an attacker can inject HTML:

// Attacker pollutes Object.prototype:
Object.prototype.template = "<img src=x onerror=alert(document.cookie)>";

// Template engine checks for obj.template or obj.layout
// and finds the attacker's value on the prototype chain

Denial of Service

// Pollute with a value that causes crashes
Object.prototype.toString = null;

// Now any code that calls .toString() on any object throws
String({}); // TypeError: toString is not a function

Remote Code Execution (Node.js)

In Node.js, certain internal modules read properties from objects that can be polluted. One documented pattern exploits child_process.spawn:

// Attacker pollutes Object.prototype
Object.prototype.shell = true;
Object.prototype.NODE_OPTIONS = "--require=./malicious.js";

// When the app later spawns a child process, these
// options are picked up from the prototype chain
Quiz
Why does the auth check if (user.isAdmin) fail under prototype pollution, even though user was created as {name: 'Bob', role: 'viewer'}?

Prevention — Making Your Code Immune

1. Use Object.create(null) for Dictionaries

Objects created with Object.create(null) have no prototype — no __proto__, no constructor, no inherited properties:

const safeDict = Object.create(null);
safeDict["__proto__"] = { isAdmin: true };

// This creates a LITERAL property called "__proto__"
// It does NOT modify Object.prototype
({}).isAdmin; // undefined — safe

2. Use Map Instead of Plain Objects

Map doesn't use the prototype chain for key storage. Any string (including "__proto__" and "constructor") is just a key:

const config = new Map();
config.set("__proto__", { isAdmin: true });

// Map stores this as a regular entry
// Object.prototype is untouched
({}).isAdmin; // undefined — safe

3. Validate and Sanitize Input Keys

Block dangerous keys before they reach your merge logic:

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

function safeMerge(target, source) {
  for (const key of Object.keys(source)) {
    if (DANGEROUS_KEYS.has(key)) continue;

    if (
      typeof source[key] === "object" &&
      source[key] !== null &&
      !Array.isArray(source[key])
    ) {
      if (!target[key]) target[key] = {};
      safeMerge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

Note the use of Object.keys() instead of for...in. Object.keys returns only own enumerable properties, which already helps avoid some prototype chain surprises.

4. Freeze Object.prototype

The nuclear option. Prevents any new properties from being added to Object.prototype:

Object.freeze(Object.prototype);

Object.prototype.isAdmin = true;
// Silently fails (or throws in strict mode)
({}).isAdmin; // undefined — safe

This is aggressive and can break libraries that legitimately modify prototypes (polyfills, for example). Use it in controlled environments where you own every dependency.

5. Use Object.hasOwn() for Property Checks

Never rely on in or truthy checks for security-sensitive properties:

// Vulnerable to pollution
if (user.isAdmin) { ... }
if ("isAdmin" in user) { ... }

// Safe — only checks own properties
if (Object.hasOwn(user, "isAdmin") && user.isAdmin) { ... }

6. Schema Validation at the Boundary

Validate incoming data against an explicit schema before it enters your application. Libraries like Zod strip unknown keys by default:

import { z } from "zod";

const PreferencesSchema = z.object({
  theme: z.enum(["light", "dark"]),
  lang: z.string().max(5),
});

app.post("/preferences", (req, res) => {
  const prefs = PreferencesSchema.parse(req.body);
  // Only theme and lang get through — __proto__ is stripped
});
Common Trap

Using JSON.parse does NOT protect you. When you JSON.parse('{"__proto__": {"isAdmin": true}}'), the result is a plain object with a regular property named "__proto__". It's harmless at this point. The danger comes when you pass this object to a deep merge or recursive assignment function — that's when the engine resolves target["__proto__"] as Object.prototype instead of a literal key.

Quiz
Which approach provides the strongest protection against prototype pollution when storing user-provided key-value data?
Quiz
What will this code log after the merge?

Detecting Prototype Pollution in Your Codebase

Look for these red flags:

Dangerous patterns:

  • Recursive for...in loops that assign target[key] = source[key] without key validation
  • Any function named merge, deepMerge, extend, defaults, deepAssign
  • Path-based assignment like set(obj, "a.b.c", value) where the path comes from user input
  • Object.assign is safe for shallow copies but watch for recursive wrappers around it

Automated detection:

  • ESLint plugin eslint-plugin-no-unsanitized catches some patterns
  • npm audit flags known vulnerable versions of libraries
  • CodeQL has prototype pollution detection queries
  • Snyk and Socket.dev scan for vulnerable deep merge patterns in dependencies
// Quick test: is your app currently polluted?
function checkPollution() {
  const clean = {};
  const keys = Object.keys(clean);
  if (keys.length > 0) {
    console.error(
      "Prototype pollution detected! Unexpected keys:",
      keys
    );
    return true;
  }
  return false;
}
What developers doWhat they should do
Only blocking __proto__ in deep merge functions
The constructor.prototype path is equally dangerous and bypasses naive __proto__ filters
Block both __proto__ and constructor.prototype, or use Object.create(null) / Map
Assuming JSON.parse sanitizes dangerous keys
JSON.parse faithfully creates properties with any key. The pollution happens when a deep merge resolves __proto__ on the target object
JSON.parse creates a normal object — the danger comes from how you USE that object afterward
Using if (user.role) to check permissions
Property access walks the prototype chain. If Object.prototype.role was polluted, every object would pass the check
Use Object.hasOwn(user, 'role') for security-critical checks
Thinking prototype pollution is a server-only attack
Research found 11+ million client-side websites vulnerable. Any deep merge in browser code is a potential entry point
Client-side JavaScript is equally vulnerable — XSS via polluted template values, DOM clobbering, gadget chains
Key Rules
  1. 1Prototype pollution modifies Object.prototype through __proto__ or constructor.prototype, affecting every object in the runtime.
  2. 2Unsafe deep merge is the #1 attack vector — any recursive property copy that doesn't validate keys is vulnerable.
  3. 3Use Map or Object.create(null) for user-controlled key-value data — they don't participate in the prototype chain.
  4. 4Always use Object.hasOwn() instead of the in operator or truthy checks for security-sensitive property access.
  5. 5Validate input at the boundary with schema validation (Zod, Joi) — strip unknown keys before they reach your application logic.
  6. 6Object.freeze(Object.prototype) prevents pollution but can break polyfills — use in controlled environments only.