Prototype Pollution: JavaScript's Hidden Security Risk
How prototype pollution attacks work through __proto__ and constructor.prototype, real-world exploit paths, and defenses including Object.freeze and safe merge libraries.
Prototype Pollution: JavaScript's Hidden Security Risk
JavaScript's prototype chain is one of the language's most powerful features. It is also one of its most dangerous when user-controlled data can reach object property assignment. Prototype pollution is a class of vulnerability where an attacker can modify Object.prototype — the ancestor of every plain object in JavaScript — causing application-wide behavioral changes that can lead to privilege escalation, remote code execution, or denial of service.
How JavaScript Prototypes Work
Every JavaScript object has an internal prototype chain. When you access a property on an object, JavaScript first checks the object itself, then walks up the prototype chain:
const obj = { name: "Alice" };
// obj.__proto__ === Object.prototype
// obj.toString === Object.prototype.toString (inherited)
If you set a property on Object.prototype, every plain object in the application inherits it:
Object.prototype.isAdmin = true;
const user = {};
console.log(user.isAdmin); // true — pollution!
The Attack Vector: Unsafe Property Assignment
Prototype pollution occurs when an application performs recursive property assignment using user-controlled data without sanitizing property names. The classic scenario is a deep merge or clone function:
// Vulnerable deep merge
function merge(target, source) {
for (const key in source) {
if (typeof source[key] === 'object' && source[key] !== null) {
if (!target[key]) target[key] = {};
merge(target[key], source[key]);
} else {
target[key] = source[key]; // Dangerous!
}
}
return target;
}
// Attacker-controlled JSON payload
const malicious = JSON.parse('{"__proto__": {"isAdmin": true}}');
merge({}, malicious);
// Now every object has isAdmin: true
const user = {};
console.log(user.isAdmin); // true
The attack works because JSON.parse creates plain objects where "__proto__" is a normal string key. When the merge function assigns target["__proto__"]["isAdmin"] = true, it walks up to Object.prototype and sets isAdmin there.
The constructor.prototype Vector
The __proto__ assignment can also be achieved via constructor.prototype:
const payload = JSON.parse('{"constructor": {"prototype": {"isAdmin": true}}}');
merge({}, payload);
// Equivalent pollution
const user = {};
console.log(user.isAdmin); // true
Any merge function that recursively assigns object keys without checking for __proto__ and constructor is vulnerable.
Real-World Exploit: Lodash merge
CVE-2019-10744 affected Lodash's _.merge() function. Lodash versions prior to 4.17.12 were vulnerable to prototype pollution via the merge function. Given that Lodash was (and still is) one of the most downloaded npm packages, this affected an enormous number of applications:
// Vulnerable Lodash (< 4.17.12)
const _ = require('lodash');
_.merge({}, JSON.parse('{"__proto__": {"polluted": "yes"}}'));
console.log({}.polluted); // "yes"
Lodash fixed this by explicitly checking for __proto__ and constructor in the merge path. Update to 4.17.21+ which contains all fixes.
Escalating Prototype Pollution to RCE
On Node.js, prototype pollution can escalate to remote code execution when it interacts with child process spawning, template engines, or other code paths that use polluted properties:
// If an attacker can set: Object.prototype.env = { NODE_OPTIONS: '--require /tmp/evil.js' }
// Then child_process.spawn() with options.env will inherit the polluted env
Object.prototype.env = { NODE_OPTIONS: '--require /tmp/evil.js' };
const { execSync } = require('child_process');
execSync('ls'); // Loads /tmp/evil.js before executing ls
This pattern has been used to achieve RCE from prototype pollution vulnerabilities in several real-world applications. It relies on Node.js merging process.env with options.env during subprocess spawning.
Defense 1: Block Dangerous Keys
The simplest fix is to reject __proto__, constructor, and prototype as keys when processing user-supplied objects:
function safeMerge(target, source) {
for (const key in source) {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
continue; // Skip dangerous keys
}
if (typeof source[key] === 'object' && source[key] !== null) {
if (!target[key]) target[key] = {};
safeMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
Defense 2: Use Object.create(null) for Data Objects
Objects created with Object.create(null) have no prototype — they do not inherit from Object.prototype. Using these as data containers means pollution of Object.prototype does not affect them, and __proto__ is just a regular property:
// No prototype — pollution-resistant
const safeContainer = Object.create(null);
safeContainer.__proto__ = 'test'; // This is just a string property, not chain traversal
console.log(safeContainer.__proto__); // "test" — not a prototype reference
Use this pattern for objects that hold user-controlled keys (query params, HTTP headers, parsed JSON data).
Defense 3: Object.freeze(Object.prototype)
Freezing Object.prototype prevents any properties from being added to it:
Object.freeze(Object.prototype);
// Now pollution attempts throw in strict mode or silently fail
const malicious = JSON.parse('{"__proto__": {"isAdmin": true}}');
merge({}, malicious); // isAdmin is not added to Object.prototype
This is a strong defense but has trade-offs: some third-party libraries dynamically add methods to Object.prototype (not a good practice, but it exists). Test thoroughly before applying this in production.
Defense 4: Use structuredClone for Deep Cloning
Node.js 17+ and modern browsers include structuredClone, which safely clones objects without prototype pollution risk:
// Safe deep clone — does not traverse prototype chain unsafely
const cloned = structuredClone(userInput);
structuredClone handles circular references, transfers typed arrays, and does not copy prototype chains — making it safe for cloning user-supplied data.
Detecting Prototype Pollution
Use these patterns in your security testing:
// Quick prototype pollution test
const test = JSON.parse('{"__proto__": {"polluted": true}}');
Object.assign({}, test);
if ({}.polluted) {
console.log('VULNERABLE to prototype pollution via Object.assign');
}
Tools like snyk test and npm audit flag known vulnerable versions of merge/clone libraries. For custom code, use eslint-plugin-security which flags patterns like target[key] = source[key] in merge contexts.
Key Takeaways
- Prototype pollution happens when recursive property assignment does not filter
__proto__,constructor, andprototypekeys. - It can escalate from application logic bypasses to RCE via Node.js child process options.
- Fix by blocking dangerous keys in merge functions, using
Object.create(null)for data containers, and freezingObject.prototypewhere feasible. - Keep Lodash, deep-extend, and other merge libraries at their latest patched versions.
- Prefer
structuredCloneover hand-rolled deep clone implementations.