Prototype pollution gets dismissed as a theoretical issue right up until someone chains it to RCE. JavaScript’s prototype chain means that if an attacker can set __proto__ properties on an object, those properties appear on every ordinary object in the process. Depending on how your application uses those objects, that escalates to remote code execution.
How JavaScript Prototypes Work
Every JavaScript object has a prototype, accessible via __proto__ (or Object.getPrototypeOf()). When you access a property that doesn’t exist directly on an object, the engine walks up the prototype chain. Object.prototype sits at the top — properties set there are visible on every ordinary object.
const a = {};
console.log(a.foo); // undefined
Object.prototype.foo = "injected";
console.log(a.foo); // "injected" -- even though we never set it on 'a'
This is prototype pollution: setting a property on Object.prototype via a user-controlled input path, making it appear on objects throughout the application.
The Vulnerable Pattern
The most common source is recursive merge or deep clone functions — the kind used to merge configuration objects or process nested JSON:
// VULNERABLE: naive recursive merge
function merge(target, source) {
for (const key of Object.keys(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 -- danger here
}
}
return target;
}
// Attacker-controlled input:
const maliciousPayload = JSON.parse('{"__proto__": {"admin": true}}');
merge({}, maliciousPayload);
// Now:
const user = {};
console.log(user.admin); // true -- injected via Object.prototype
The __proto__ key is valid JSON and valid JavaScript. When the merge function recurses into it, it’s operating on Object.prototype itself.
Escalating to RCE
Property injection becomes dangerous when the polluted property is used in sensitive contexts. Two common escalation paths:
Path 1: Child process spawn options
const { execFile } = require('child_process');
// Attacker pollutes:
Object.prototype.env = { NODE_OPTIONS: '--require /tmp/malicious.js' };
// Later in legitimate code:
execFile('node', ['script.js'], {}, callback);
// The options object picks up .env from prototype -- NODE_OPTIONS injected
Path 2: Template engine code injection
Several popular template engines (lodash’s _.template, Handlebars in older versions) have been shown to execute prototype-polluted values as code. The lodash CVE-2019-10744 is the canonical example — _.set on a deep object path of __proto__.constructor.prototype allowed arbitrary code execution.
// lodash < 4.17.16 -- vulnerable
const _ = require('lodash');
_.set({}, '__proto__.constructor.prototype.polluted', '1');
// Results in Object.prototype.constructor.prototype.polluted being set
// Combined with lodash template:
_.template('<%= polluted %>')({});
// Executes the polluted value as a template expression
Detection: Finding Vulnerable Code
Use these grep patterns or Semgrep rules to find risky merge patterns in your codebase:
// Semgrep pattern to find unsafe merge implementations
// rules/prototype-pollution.yaml
rules:
- id: unsafe-object-merge
patterns:
- pattern: |
for (... of Object.keys($SRC)) {
...
$TARGET[$KEY] = $SRC[$KEY];
...
}
message: "Potential prototype pollution in object merge -- check key filtering"
languages: [javascript, typescript]
severity: WARNING
For dependency scanning, npm audit and Snyk flag known prototype pollution CVEs in third-party packages. Run npm audit --json | jq '.vulnerabilities | to_entries[] | select(.value.title | test("prototype pollution"; "i"))' to filter specifically for these.
Prevention Patterns
1. Key filtering in merge functions
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; // 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;
}
2. Null-prototype objects
For objects used as hash maps or configuration stores, create them with Object.create(null) — they have no prototype, so pollution can’t reach them:
// Safe map -- no prototype chain
const config = Object.create(null);
config['admin'] = false;
// Prototype pollution has no effect:
Object.prototype.admin = true;
console.log(config.admin); // false -- no prototype lookups
3. Object.freeze on Object.prototype
For server-side applications, freeze Object.prototype during startup to make it immutable:
// In your application entry point:
Object.freeze(Object.prototype);
// Now attempting pollution throws in strict mode / silently fails otherwise:
const payload = JSON.parse('{"__proto__": {"admin": true}}');
const obj = {};
Object.assign(obj, payload); // __proto__ assignment is a no-op on frozen prototype
Be careful with this approach — some libraries legitimately extend Object.prototype (polyfills, older test frameworks). Test thoroughly before deploying.
4. Use structuredClone or JSON round-trip for deep cloning
Instead of hand-rolled merge functions, use structuredClone() (available in Node.js 17+) which handles the clone safely:
// Safe deep clone -- structuredClone ignores __proto__
const cloned = structuredClone(userInput);
Or for merging objects, _.mergeWith from lodash 4.17.21+ includes prototype pollution mitigations, though explicit key filtering is still preferred.
5. Schema validation at the boundary
Validate and strip unknown keys from user-supplied objects before they enter any merge or deep-copy operation. With Zod:
import { z } from 'zod';
const UserSchema = z.object({
name: z.string(),
role: z.enum(['user', 'editor'])
}).strict(); // .strict() rejects unknown keys
const result = UserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: result.error });
}
// result.data is clean -- no __proto__ or unknown keys
Libraries to Audit
If you’re using any of these, verify you’re on patched versions or have mitigations in place:
- lodash — multiple prototype pollution CVEs; use ≥ 4.17.21
- merge npm package — CVE-2018-3728; use
deepmergeinstead - jquery —
$.extend()was vulnerable; patched in 3.4.0 - minimist — CVE-2020-7598; use
yargsor newer arg parsers - qs — CVE-2014-7191 (ancient but still in some dependency trees)
Prototype pollution is a class of vulnerability that’s easy to overlook in code review because the dangerous assignment (target[key] = source[key]) looks completely normal. Key filtering at merge boundaries and null-prototype objects for maps are the two controls that provide the most reliable protection.