JavaScript’s prototypal inheritance model means every object in the runtime inherits properties from Object.prototype. Prototype pollution exploits this: an attacker who can control property assignment through a recursive merge, deep clone, or path-based setter function can write properties to Object.prototype itself, causing every object in the runtime to inherit those properties. Depending on how the application uses those properties, the consequences range from property injection to denial of service to remote code execution.
Prototype pollution has caused high-severity vulnerabilities in widely used packages including lodash (CVE-2019-10744), jquery (CVE-2019-11358), minimist (CVE-2020-7598), express-fileupload (CVE-2020-7699), and many others. The underlying pattern keeps recurring because recursive object merging is a genuinely useful pattern and the safe implementation is not obvious.
How Prototype Pollution Works
JavaScript’s prototype chain means:
const obj = {};
obj.__proto__.isAdmin = true; // Writes to Object.prototype
const anotherObj = {};
console.log(anotherObj.isAdmin); // true — inherited from Object.prototype
The canonical exploit path uses a malicious key like __proto__ or constructor.prototype in a JSON payload processed by a merge function:
// Vulnerable merge function
function merge(target, source) {
for (let key in source) {
if (typeof source[key] === 'object') {
target[key] = merge(target[key] || {}, source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
// Attacker sends this JSON payload:
const payload = JSON.parse('{"__proto__": {"isAdmin": true}}');
const userConfig = {};
merge(userConfig, payload);
// Now Object.prototype is poisoned
const freshObj = {};
console.log(freshObj.isAdmin); // true
The for...in loop traverses inherited properties including __proto__, and the recursive assignment writes to Object.prototype when the key chain includes it.
Attack Scenarios
Scenario 1: Authorization Bypass
// Application checks for a property on a config object
function isAuthorised(userObj, requiredRole) {
return userObj[requiredRole] === true;
}
// Default user object (no admin role)
const user = { name: "alice", email: "[email protected]" };
// After prototype pollution with {"__proto__": {"admin": true}}:
console.log(isAuthorised(user, "admin")); // true — inherited from polluted prototype
Scenario 2: Denial of Service
// Many template engines and libraries iterate Object.prototype
// Injecting a non-enumerable iterator or a property that causes iteration to hang:
const payload = '{"__proto__": {"toString": "BREAK"}}';
merge({}, JSON.parse(payload));
// Breaks any code that calls .toString() on objects
({}).toString(); // TypeError: toString is not a function
Scenario 3: RCE via Gadget Chains
In Node.js environments with specific modules loaded, prototype pollution can achieve RCE through property injection into control flow objects. A well-documented gadget chain uses child_process and ejs (Embedded JavaScript templating):
// If the runtime uses ejs for template rendering and prototype is polluted:
const payload = JSON.parse(
'{"__proto__": {"outputFunctionName": "x; process.mainModule.require(\'child_process\').execSync(\'id\'); //"}}'
);
merge({}, payload);
// Any subsequent ejs.render() call executes the injected command
ejs.render('<%= name %>', { name: 'test' }); // executes: id
This is not theoretical — CVE-2020-7699 in express-fileupload used exactly this approach: uploaded files were merged into req.body, the __proto__ key polluted Object.prototype, and ejs gadgets achieved RCE.
Finding Prototype Pollution
Manual Code Review Patterns
Search for recursive merge or deep clone implementations using these patterns:
# Find recursive merge patterns in Node.js projects
grep -rn "for.*in.*source\|Object\.keys.*source" src/ --include="*.js" --include="*.ts"
# Find path-based property setters
grep -rn "\.set\(.*\.\|__proto__\|constructor\[" src/ --include="*.js"
# Find JSON parsing fed into merge operations
grep -rn "JSON\.parse" src/ --include="*.js" | grep -i "merge\|extend\|assign"
Automated Detection
npm audit catches known vulnerable package versions:
npm audit --audit-level=moderate
# Flags: lodash <4.17.21, jquery <3.5.0, minimist <1.2.6, etc.
Snyk identifies prototype pollution in both direct and transitive dependencies:
npx snyk test --all-projects
Semgrep with the community ruleset detects vulnerable merge patterns:
semgrep --config "p/javascript" --pattern 'for (let $KEY in $SRC) { $DST[$KEY] = $SRC[$KEY] }' .
Safe Implementations
Option 1: Check for Prototype-Polluting Keys
function safeMerge(target, source) {
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
for (const key of Object.keys(source)) { // Object.keys() vs for...in: no inherited props
if (FORBIDDEN_KEYS.has(key)) {
continue; // Skip dangerous keys
}
if (source[key] !== null && typeof source[key] === 'object') {
if (!(key in target) || typeof target[key] !== 'object') {
target[key] = {};
}
safeMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
Option 2: Use Object.create(null) for Accumulation Objects
Objects created with Object.create(null) have no prototype, so prototype pollution has no effect on them:
// Safe accumulator — no prototype to pollute
const config = Object.create(null);
// Or for intermediate merge targets:
function safeMergeToNull(source) {
const target = Object.create(null);
for (const key of Object.keys(source)) {
target[key] = source[key];
}
return target;
}
Option 3: Validate with JSON Schema Before Merging
If you’re merging user-supplied JSON, validate the schema before processing it. An attacker cannot send __proto__ if your schema rejects unknown keys:
import Ajv from 'ajv';
const ajv = new Ajv({ allowUnionTypes: true });
const schema = {
type: 'object',
properties: {
name: { type: 'string' },
theme: { type: 'string', enum: ['light', 'dark'] }
},
additionalProperties: false // Reject __proto__ and any other unexpected key
};
const validate = ajv.compile(schema);
function processUserConfig(rawInput) {
const input = JSON.parse(rawInput);
if (!validate(input)) {
throw new Error('Invalid input: ' + ajv.errorsText(validate.errors));
}
return input; // Safe to use — schema validated
}
Option 4: Use structuredClone() for Deep Cloning
Node.js 17+ and modern browsers include structuredClone(), which performs a deep clone without being vulnerable to prototype pollution:
// Vulnerable:
const cloned = JSON.parse(JSON.stringify(obj)); // __proto__ in JSON string survives this
// Safe:
const cloned = structuredClone(obj); // Ignores __proto__ and constructor keys
Freeze Object.prototype as a Mitigation
In environments where you cannot audit all merge code, Object.freeze(Object.prototype) prevents prototype pollution at the runtime level:
// At the very start of your application, before any other code runs:
Object.freeze(Object.prototype);
// Now prototype pollution attempts will silently fail (non-strict) or throw (strict mode)
const payload = JSON.parse('{"__proto__": {"isAdmin": true}}');
merge({}, payload);
console.log({}.isAdmin); // undefined — prototype is frozen
The downside: some legitimate library code that modifies Object.prototype (older polyfills, some test frameworks) will break. Test thoroughly before deploying.
The correct long-term fix is eliminating all vulnerable merge patterns and maintaining up-to-date dependencies. Freezing the prototype is a defence-in-depth measure, not a replacement for fixing the root cause.