Prototype Pollution
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.
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.
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.
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.bodyis user-controlled JSON - Query string parsing: Libraries like
qsparse 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
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.
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
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
});
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.
Detecting Prototype Pollution in Your Codebase
Look for these red flags:
Dangerous patterns:
- Recursive
for...inloops that assigntarget[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.assignis safe for shallow copies but watch for recursive wrappers around it
Automated detection:
- ESLint plugin
eslint-plugin-no-unsanitizedcatches some patterns npm auditflags 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 do | What 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 |
- 1Prototype pollution modifies Object.prototype through __proto__ or constructor.prototype, affecting every object in the runtime.
- 2Unsafe deep merge is the #1 attack vector — any recursive property copy that doesn't validate keys is vulnerable.
- 3Use Map or Object.create(null) for user-controlled key-value data — they don't participate in the prototype chain.
- 4Always use Object.hasOwn() instead of the in operator or truthy checks for security-sensitive property access.
- 5Validate input at the boundary with schema validation (Zod, Joi) — strip unknown keys before they reach your application logic.
- 6Object.freeze(Object.prototype) prevents pollution but can break polyfills — use in controlled environments only.