Prototype pollution is a JavaScript-specific vulnerability class that emerges from the language’s prototype-based inheritance model. An attacker who can pollute Object.prototype — the root prototype from which almost every JavaScript object inherits — can inject properties that appear on objects across the entire application. Depending on how those properties are used downstream, the impact ranges from application logic corruption to remote code execution.
The vulnerability is more common than its name suggests. Most JavaScript applications include at least one function that recursively merges user-controlled data into objects without sanitisation — and that’s typically enough.
How Prototype-Based Inheritance Works
Every JavaScript object has an internal [[Prototype]] link pointing to another object. When you access a property on an object, JavaScript walks up the prototype chain until it finds the property or hits null at the top:
const obj = {};
console.log(obj.toString); // found on Object.prototype
console.log(obj.__proto__ === Object.prototype); // true
console.log(obj.__proto__.__proto__); // null (end of chain)
The special keys __proto__, constructor, and prototype can be used to traverse or modify this chain. When user-controlled input reaches a merge or assignment operation that processes these keys, the attacker can write to Object.prototype itself.
The Vulnerability Pattern
Vulnerable Deep Merge
This pattern appears in countless libraries and utility functions:
// VULNERABLE: naive recursive 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]); // recurse
} else {
target[key] = source[key]; // direct assignment
}
}
return target;
}
// Attacker sends: {"__proto__": {"isAdmin": true}}
const userInput = JSON.parse('{"__proto__": {"isAdmin": true}}');
merge({}, userInput);
// Now EVERY object in the application has isAdmin === true
const newObj = {};
console.log(newObj.isAdmin); // true — polluted!
The for...in loop iterates over __proto__ as a key. The recursive call hits merge(target["__proto__"], {isAdmin: true}) — which writes isAdmin directly onto Object.prototype.
Other Vulnerable Patterns
// Object.assign does NOT cause prototype pollution (it skips __proto__)
// But path-based property setters do:
function setPath(obj, path, value) {
const parts = path.split('.');
let current = obj;
for (let i = 0; i < parts.length - 1; i++) {
current = current[parts[i]]; // VULNERABLE if parts[i] is "__proto__"
}
current[parts[parts.length - 1]] = value;
}
// Attacker input: path="__proto__.isAdmin", value=true
setPath({}, '__proto__.isAdmin', true);
Libraries that have historically been vulnerable: lodash (merge/set — patched in 4.17.12+), jquery (deep extend — patched), hoek, deep-extend, mixin-deep, qs. Check your dependency tree.
Exploitation Paths
Logic Bypass
The simplest impact: injecting a truthy property that authentication or authorization logic checks:
// Application code
if (user.isAdmin) {
grantAccess();
}
// After pollution with {"__proto__": {"isAdmin": true}}
// Every user passes this check, including unauthenticated requests
const anonymousUser = {};
if (anonymousUser.isAdmin) { // true — inherited from Object.prototype!
grantAccess();
}
Remote Code Execution via Gadget Chains
Prototype pollution escalates to RCE when combined with code paths that use the polluted property in a dangerous way. In Node.js environments, several framework-level gadget chains have been documented:
Ejs template engine gadget (pre-patch):
// Pollution:
// {"__proto__": {"outputFunctionName": "process.mainModule.require('child_process').execSync('id')//"}}
// When ejs renders any template, it uses outputFunctionName internally.
// The injected value causes it to execute the attacker's shell command.
const ejs = require('ejs');
ejs.render('<%= user %>', { user: 'test' });
// → executes id command on the server
Handlebars gadget (pre-patch):
// {"__proto__": {"__lookupGetter__": ..., "escapeExpression": ...}}
// Enables access to __defineGetter__ which leads to function construction
// with arbitrary code execution
Modern versions of ejs and Handlebars have patched their gadgets, but the pattern shows why prototype pollution is considered a critical severity in server-side JavaScript: the impact depends on what gadgets exist in the dependency tree, and most applications have enough dependencies to produce a usable chain.
Tool: pp-finder
The pp-finder tool can scan a Node.js project for potential prototype pollution gadgets in installed packages:
npx pp-finder --scan ./node_modules
Detection
Static Analysis
Semgrep rules for detecting unsafe merge patterns:
rules:
- id: prototype-pollution-merge
patterns:
- pattern: |
for (var $KEY in $SRC) {
$DST[$KEY] = $SRC[$KEY];
}
- pattern-not: |
if ($KEY === '__proto__' || ...) { continue; }
message: "Potential prototype pollution in object merge — add key sanitisation"
languages: [javascript, typescript]
severity: WARNING
- id: prototype-pollution-set-path
pattern: |
$OBJ[$KEY] = $VALUE
pattern-inside: |
function $F(...) {
...
var $PARTS = $PATH.split('.');
...
}
message: "Path-based property setter may be vulnerable to prototype pollution"
languages: [javascript]
severity: WARNING
Runtime Detection
// Detect prototype pollution at runtime (for monitoring / WAF)
const originalProto = Object.getPrototypeOf({});
const originalKeys = Object.getOwnPropertyNames(originalProto);
function checkPrototypePollution() {
const currentKeys = Object.getOwnPropertyNames(Object.prototype);
const injected = currentKeys.filter(k => !originalKeys.includes(k));
if (injected.length > 0) {
console.error('PROTOTYPE POLLUTION DETECTED:', injected);
// Alert, log, or throw
}
}
// Run periodically or wrap around merge operations
setInterval(checkPrototypePollution, 5000);
Prevention
1. Use Safe Merge Functions
// SAFE: check for dangerous keys before merging
function safeMerge(target, source) {
const DANGEROUS_KEYS = ['__proto__', 'constructor', 'prototype'];
for (const key of Object.keys(source)) { // Object.keys() skips __proto__
if (DANGEROUS_KEYS.includes(key)) {
continue; // skip dangerous keys entirely
}
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;
}
Object.keys() is safer than for...in because it only returns own enumerable properties, not inherited ones — but it still returns '__proto__' if the key was explicitly set. The explicit deny-list is still needed.
2. Use Object.create(null) for Accumulator Objects
// Objects created with null prototype cannot be polluted
// and don't inherit from Object.prototype
const safeConfig = Object.create(null);
safeConfig.debug = false;
safeConfig.admin = false;
// __proto__ injection against Object.create(null) has no effect
// because there's no prototype chain to walk up
Use Object.create(null) for objects that accumulate untrusted properties (parsed query strings, JSON payloads used as maps).
3. JSON Schema Validation Before Merge
Validate the structure of user input before it touches any merge operation:
import Ajv from 'ajv';
const ajv = new Ajv();
const userInputSchema = {
type: 'object',
properties: {
name: { type: 'string' },
preferences: {
type: 'object',
additionalProperties: false, // block unexpected keys
properties: {
theme: { type: 'string', enum: ['light', 'dark'] }
}
}
},
additionalProperties: false // block __proto__, constructor, etc.
};
const validate = ajv.compile(userInputSchema);
if (!validate(userInput)) {
throw new Error('Invalid input');
}
// safe to use userInput here
additionalProperties: false in AJV blocks any key not explicitly listed in properties — including __proto__ and constructor.
4. Freeze Object.prototype
For applications that don’t need to extend the built-in prototype:
// At application startup, before any untrusted input is processed
Object.freeze(Object.prototype);
// Now attempts to modify Object.prototype throw in strict mode
// or silently fail in non-strict mode
This is a defence-in-depth measure. It breaks some poorly-written third-party code that legitimately adds to prototypes (uncommon in modern code, but check your dependencies first).
5. Keep Dependencies Patched
The most common source of exploitable prototype pollution in real applications is a vulnerable version of lodash, jquery, qs, or similar utility libraries. Audit your lockfile:
npm audit
# or more specifically:
npx better-npm-audit audit --level moderate
For automated detection in CI, tools like Snyk and Socket.dev flag prototype pollution vulnerabilities in transitive dependencies before they reach production.