In a typical Express API, body-parser deserializes the JSON payload and hands it to the route handler. You validate types and ranges. You never validate key names. That gap is where prototype pollution lives.
The JavaScript prototype chain is the only structure where a successful mutation in one request handler persists across every subsequent request in the same process. Any endpoint that passes unsanitized key paths to a merge function has a potential gadget chain to RCE. The injected value does not matter: the key name does.
Key names are the ignored attack surface
The qs query string parser, Express's default, converts a[__proto__][x]=1 to a JavaScript object with __proto__ before Express processes the request. express.json() passes raw key names through without sanitization. A single POST body with {"__proto__": {"isAdmin": true}} modifies Object.prototype for the entire Node.js process.
The mutation is silent and persists for the lifetime of the process. No HTTP error, no log entry, no exception signals the event. Every object created after the mutation inherits the property. This includes objects in other modules, other route handlers, and other user sessions.
POST /api/users HTTP/1.1
Content-Type: application/json
{"__proto__": {"isAdmin": true}}
After that request, ({}).isAdmin === true anywhere in the Node.js process. The attacker does not need to know which gadget exists in advance. The pollution waits for any library code to read from the prototype chain.
The mechanism is straightforward: JavaScript resolves properties by walking up the prototype chain. When Object.prototype is modified, every object in the process sees the polluted property. Code that does not explicitly call hasOwnProperty before using a property cannot distinguish an injected value from a legitimate one.
CVE-2019-10744: lodash defaultsDeep as a supply-chain event
Lodash was present in over 80% of Node.js projects when CVE-2019-10744 was published in July 2019. The _.defaultsDeep function accepted payloads via constructor.prototype, mutating Object.prototype with a single function call. CVSS 9.1: AV:N/AC:L/PR:N/UI:N.
_.defaultsDeep({}, JSON.parse('{"constructor":{"prototype":{"isAdmin":true}}}'));
// ({}).isAdmin === true across the entire process
Every plain object in the process now has isAdmin === true without explicit assignment. The CVE was patched in lodash 4.17.12. Over 80% of Node.js projects at the time ran below that version.
CVE-2020-8203 added _.zipObjectDeep as a second vector in the same package, with CVSS 7.4. Both were patched in lodash 4.17.21. Lodash's prevalence turned 2 library bugs into a supply-chain event. Any transitive dependency pulling lodash below 4.17.21 was an active vector.
The fix lodash adopted was checking path segments against a block-list of prohibited keys during deep merge operations. Most projects running lodash did not see the patch until months later via a transitive version bump. The exposure window ran from 2019 to 2022 for projects that never audited transitive dependencies.
Gadget chains convert a polluted property into shell execution
Prototype pollution alone is inert. Escalation to RCE requires a gadget: a code path that reads from the prototype chain. That path then passes the result to spawn(), execSync(), or a template engine eval().
The Silent Spring paper (USENIX Security 2023) identified 11 universal gadgets in Node.js source code. Authors Shcherbakov, Balliu, and Staicu (KTH/CISPA) demonstrated 8 RCEs in NPM CLI, Parse Server, and Rocket.Chat. The execArgv gadget works by polluting __proto__.execArgv with ['--eval', 'require("child_process").exec(cmd)']. Every subsequent fork() executes the payload as a process argument.
// Pollution payload via POST body
{"__proto__": {"execArgv": ["--eval", "require('child_process').exec('id > /tmp/pwned')"]}}
// The next fork() in the process executes the command
The lodash.template gadget reads __proto__.sourceURL and evaluates arbitrary JavaScript inside the template engine. The gadget lives in dependencies chosen for logging, HTTP, or i18n. The payload poisons the prototype; the gadget is already in the classpath.
CVE-2023-23917 (Rocket.Chat below 5.2.0, CVSS 8.8, HackerOne #1631258) demonstrated the full chain. A user with a regular account achieved admin-level RCE via prototype pollution plus gadget chain. Published February 2023.
The 11 gadgets identified by Silent Spring are not new vulnerabilities. They are legitimate code patterns in the runtime that, combined with a polluted prototype, produce code execution. Most cannot be patched without breaking Node.js API contracts.
protobufjs: when the serialization layer is the vector
CVE-2022-25878 (CVSS 8.2) proved the attack surface is not limited to merge utilities. protobufjs before 6.11.3 did not validate property path segments in util.setProperty and ReflectionObject.setParsedOption. The attacker supplies a crafted .proto file: no deep merge required.
Schema libraries that construct JavaScript objects from external definitions are an underappreciated attack surface. When the API parses a .proto definition from client input, the parser calls util.setProperty, which writes to Object.prototype. The vulnerability sits in the serialization layer, before any business-logic validation runs.
The fix is upgrading to protobufjs 6.11.3 and auditing any library that builds JavaScript objects from external schema definitions. APIs that accept schema definitions from clients must treat that input with the same rigor as data payloads.
Node.js core was not immune: CVE-2022-21824
HackerOne #1431042 established that even Node.js built-in APIs can be prototype pollution sources. console.table(data, userControlledProperties) polluted Object.prototype when the first argument was a plain object with at least 1 property. CVE-2022-21824, patched in January 2022 across Node.js 12.22.9, 14.18.3, 16.13.2, and 17.3.1.
Any application logging user-supplied data via console.table was silently vulnerable. The vector was in the runtime itself, not in npm packages. Prototype pollution vectors exist in any code that constructs or formats objects from external input, including observability and logging tooling.
// Apparently harmless logging
console.table(req.body, Object.keys(req.body));
// If req.body contains __proto__, Object.prototype is polluted
Rocket.Chat was running an unpatched Node.js version when CVE-2023-23917 was exploited. The chain combined a logging-layer pollution source with a template engine gadget to produce admin-level RCE. The pollution vector and the execution gadget are independent components that produce a critical severity only when they coexist in the same process.
Per-framework hardening that ships in production
Object.freeze(Object.prototype) appears in every piece of documentation but is rarely deployed. It breaks libraries that dynamically add properties to the prototype. The practical defense combines JSON key allowlisting at the API boundary with safe merge primitives.
AJV with additionalProperties: false blocks any key outside the schema at parse time. This includes __proto__ and constructor, before any business logic runs. qs 6.10+ defaults allowPrototypes to false, stripping __proto__ from query-string objects before Express processes them.
// AJV blocking __proto__ at the API boundary
const validate = ajv.compile({
type: 'object',
properties: { name: { type: 'string' } },
additionalProperties: false
});
// qs configured to strip __proto__
const qs = require('qs');
qs.parse('a[__proto__][admin]=true', { allowPrototypes: false });
// Returns {} -- __proto__ removed
structuredClone() does not carry prototype-polluted properties. It serializes only own enumerable properties. The --disable-proto=delete Node.js startup flag removes __proto__ from all objects at the V8 level.
express-mongo-sanitize blocks $-prefixed keys for MongoDB injection prevention. It does not block __proto__. Developers who install it as a security measure against prototype pollution remain vulnerable.
The MAGO team tool (mago.team) sends prototype pollution payloads across all JSON body parameters of an API. It then checks whether subsequent responses indicate prototype contamination. This detects the vector before a gadget is identified.
The prototype chain is the only JavaScript structure where a mutation in 1 request handler persists across every subsequent request in the same process. Treating key names as adversarial input, not just values, is the only control point that blocks the entire attack class.
Top comments (0)