Back to glossary

Prototype Pollution

Prototype pollution is a JavaScript vulnerability where attacker-controlled keys like __proto__ modify Object.prototype, changing every object in the process.

Also known as CWE-1321, Prototype pollution vulnerability, JavaScript prototype pollution

Prototype pollution is a JavaScript vulnerability in which attacker-controlled keys such as __proto__ or constructor.prototype reach code that writes object properties by path, adding properties to Object.prototype. Every object in the process then inherits the injected values, which can flip authorization checks, crash servers, or reach code execution through gadgets. It is tracked as CWE-1321.

Prototype pollution needs two pieces to do damage. The first is a sink: a merge, clone, or set-by-path function that follows attacker-supplied keys. The second is a gadget: code that reads a property its object never set and trusts whatever the prototype chain returns. A library usually ships the sink. The gadget lives somewhere else, in application code, another dependency, or the runtime itself.

Our position is to fix the data structure, not the key string. Lodash's own key checks have been bypassed twice, each time by another spelling of the same path. Null-prototype objects, Map, and closed schemas remove the inheritance the attack depends on. For advisories already in your dependency tree, triage by whether untrusted input reaches the vulnerable function, not by CVSS score.

How does prototype pollution work?

#

Prototype pollution works by abusing how JavaScript resolves properties. Every ordinary object has a hidden [[Prototype]] link. When a lookup misses the object's own keys, it continues up that chain until it reaches null. Every object literal shares the same parent, Object.prototype. A Node.js server handles every request in the same process, so one Object.prototype serves all of them.

JavaScript exposes the link through an accessor. Object.prototype.__proto__ is a getter and setter defined in section 20.1.3.8 of the ECMAScript specification. Writing obj.__proto__.x = 1 does not touch obj. It writes x onto the shared prototype. The same object is also reachable without that accessor, as obj.constructor.prototype, because constructor is itself inherited from Object.prototype.

The vulnerable pattern fits in ten lines:

JavaScript
function merge(target, source) {
  for (const key in source) {
    if (typeof source[key] === 'object' && source[key] !== null) {
      if (typeof target[key] !== 'object') target[key] = {};
      merge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

const body = JSON.parse('{"__proto__": {"isAdmin": true}}');
console.log(Object.keys(body));   // [ '__proto__' ]
console.log(({}).isAdmin);        // undefined
merge({}, body);
console.log(({}).isAdmin);        // true

We ran this on Node.js 22.23 and the output matches the comments. The request body never touched the object that later answers isAdmin. It only had to reach merge.

One write to Object.prototype changes every object

Property lookup checks the object's own keys, then walks [[Prototype]] until it reaches null

Before the merge

session

user: "eve".isAdmin → undefined

config

port: 8080.isAdmin → undefined

{}

(no keys).isAdmin → undefined
[[Prototype]]
Object.prototype
toString, hasOwnProperty, …
↓ null

After merge({}, JSON.parse(body))

session

user: "eve".isAdmin → true

config

port: 8080.isAdmin → true

{}

(no keys).isAdmin → true
[[Prototype]]
Object.prototype
toString, hasOwnProperty, …
isAdmin: true
↓ null

None of the three objects was touched. The request body only had to reach a merge that walked into __proto__.

The figure compares three objects before and after the merge: a session object with an own key user: "eve", a config object with port: 8080, and an empty {}. Before the merge, isAdmin on each one walks to Object.prototype, finds nothing, and returns undefined. After the merge writes isAdmin: true to Object.prototype, the same lookup returns true on all three. None of the three objects was modified.

Why JSON.parse is not the bug

#

JSON.parse treats __proto__ as an ordinary key and creates it as an own property. In the output above, Object.keys(body) lists __proto__, and nothing is polluted yet. An object literal written in source code behaves differently: {"__proto__": {...}} in JavaScript sets the new object's prototype instead of creating a key. That asymmetry is why parsed JSON is such a reliable carrier. The dangerous key survives parsing intact.

The pollution happens on the copy. When merge reaches key = "__proto__", the expression target[key] invokes the inherited getter and returns Object.prototype. The recursive call then writes the attacker's keys onto it. MDN notes that a one-level Object.assign only swaps the target's own prototype. The recursion step is what reaches the shared one. Spread syntax ({...body}) defines properties instead of assigning them, so it copies __proto__ as a plain key and pollutes nothing.

The same sink shows up in four families of code:

  • Recursive merge and defaults helpers, such as lodash merge and defaultsDeep.
  • Path setters that take a string or array path, such as set(obj, "a.b.c", value).
  • Query-string and argument parsers that expand a[b][c]=1 or --a.b=1 into nested objects.
  • Config loaders that layer user-supplied JSON or YAML over defaults.

A check for the string __proto__ alone does not close the sink. The constructor.prototype path uses two ordinary keys and never touches the accessor. We ran the same merge with a guard that skips only __proto__:

JavaScript
const p2 = JSON.parse('{"constructor": {"prototype": {"polluted2": "yes"}}}');
protoOnlyCheck({}, p2);   // merge that skips only "__proto__"
console.log(({}).polluted2);
// yes

What can an attacker do with prototype pollution?

#

What an attacker can do with prototype pollution depends entirely on the gadgets reachable in the same process. A gadget is any code that reads an optional property from a plain object and acts on it. Pollution supplies the value. The gadget decides the impact.

Logic flaws. Checks like if (user.isAdmin) or option bags read with options.debug || false pick up inherited values. Destructuring defaults do not help. We confirmed that const { verbose = false } = {} binds verbose to true once Object.prototype.verbose is set, because a default only applies when the lookup returns undefined.

Denial of service. Overwriting or deleting properties that other code calls crashes or hangs the process. The qs advisory CVE-2022-24999 is a denial of service rated CVSS 7.5. Before 6.10.3, a __proto__ key in a query string could hang the Node.js process, and the payload needs no authentication.

Cross-site scripting in the browser. Client-side pollution typically arrives through URL query or fragment parsers and postMessage handlers. The gadgets are configuration objects for sanitizers, script loaders, and template engines, which turns pollution into cross-site scripting.

Code execution on the server. Remote code execution needs a gadget that passes an inherited property to a process, module loader, or code generator. The Silent Spring paper (USENIX Security 2023) found 11 universal gadgets in core Node.js APIs. Its authors used them to exploit 8 code execution vulnerabilities in three applications: the npm CLI, Parse Server, and Rocket.Chat. A follow-up study, GHunter (2024), reported 56 new gadgets in Node.js and 67 in Deno.

The research changes how a prototype pollution advisory should be read. The sink is in the library. The gadget may be in Node.js itself. A library maintainer cannot see the gadgets, and an application team rarely reads the library's merge code. Neither side has the full picture.

Eight years of lodash fixes show why key denylists fail

#

Lodash is the clearest record of how prototype pollution fixes age. It ships the generic merge and path helpers the attack needs, so new spellings of a prototype path keep landing there.

Eight years of lodash prototype pollution fixes

Release dates from the npm registry. Advisory data from the GitHub Advisory Database.

  1. 2018-02-04 4.17.5 · CVE-2018-3721merge, mergeWith, defaultsDeep stop following __proto__.
  2. 2019-07-09 4.17.12 · CVE-2019-10744 bypassdefaultsDeep still reached the prototype through constructor.prototype. Critical, CVSS 9.1.
  3. 2020-07-08 4.17.19 · CVE-2020-8203Path setters: set, setWith, update, zipObjectDeep, pick.
  4. 2021-02 to 2026-01 no release4.17.21 is the last version for almost five years.
  5. 2026-01-21 4.17.23 · CVE-2025-13465unset and omit could delete methods from global prototypes.
  6. 2026-03-31 4.18.0 · CVE-2026-2950 bypassThe 4.17.23 check only covered string keys. Array-wrapped path segments got through.

Each fix blocked a spelling of the path. Each bypass found another spelling.

ReleasedVersionAdvisoryWhat changed
2018-02-044.17.5CVE-2018-3721, moderatemerge, mergeWith, and defaultsDeep stop following __proto__.
2019-07-094.17.12CVE-2019-10744, critical, CVSS 9.1Bypass: defaultsDeep still reached Object.prototype through constructor.prototype.
2020-07-084.17.19CVE-2020-8203, highPath setters: set, setWith, update, updateWith, zipObjectDeep, pick.
2021-02 to 2026-01nonenone4.17.21 is the last release for almost five years.
2026-01-214.17.23CVE-2025-13465, moderateunset and omit could delete properties from global prototypes.
2026-03-314.18.0CVE-2026-2950, moderateBypass: the 4.17.23 check only covered string keys, so array-wrapped path segments got through.

Release dates come from the npm registry. Advisory details come from the GitHub Advisory Database entries for CVE-2018-3721, CVE-2019-10744, CVE-2020-8203, CVE-2025-13465, and CVE-2026-2950. Each fix in that table guarded against specific keys or key shapes. Each bypass used a different representation of the same path: __proto__, then constructor.prototype, then a path segment wrapped in an array.

The 2019 bypass is easy to reproduce. We installed lodash 4.17.11 and 4.18.1 side by side and passed both the same payload:

JavaScript
// npm i lodash-old@npm:lodash@4.17.11 lodash-new@npm:lodash@4.18.1
const payload = JSON.parse('{"constructor": {"prototype": {"polluted": true}}}');
for (const name of ['lodash-old', 'lodash-new']) {
  const _ = require(name);
  _.defaultsDeep({}, payload);
  console.log(`${name}@${_.VERSION} ->`, ({}).polluted);
  delete Object.prototype.polluted;
}
// lodash-old@4.17.11 -> true
// lodash-new@4.18.1 -> undefined

The 2026 pair is a different kind of bug. CVE-2025-13465 deletes rather than adds. The advisory says "an attacker can pass crafted paths which cause Lodash to delete methods from global prototypes." Deleting a method that the rest of the process calls is a crash. The fix shipped as part of Lodash's 2026 security reset, when a newly funded Technical Steering Committee rebuilt the project's CI and security process. Ten weeks later, CVE-2026-2950 showed the new check was incomplete.

Lodash also publishes each method as its own package, and those have a longer tail. The CVE-2020-8203 advisory lists lodash.set, lodash.setwith, lodash.update, lodash.updatewith, and lodash.pick with no patched version at all. If one of those is in your tree, upgrading is not an option. Replacing it is.

How do you prevent prototype pollution in JavaScript?

#

You prevent prototype pollution in JavaScript by removing the inherited lookup the attack needs, not by filtering keys. We tested each defense against the same naive merge on Node.js 22.23:

Plain text
$ node defenses.js
Object.create(null) target -> ({}).polluted = undefined
denylist merge        -> ({}).polluted = undefined
__proto__-only check  -> ({}).polluted2 = yes
frozen Object.prototype -> ({}).polluted = undefined

$ node --disable-proto=delete merge-constructor.js
disable-proto + constructor.prototype -> ({}).y = 1

The last line is the important one. Node's --disable-proto flag, available since v12.17.0, removes the __proto__ accessor. It does nothing about constructor.prototype, which still polluted the prototype in our test.

DefenseWhat it blocksWhat it misses or costs
Object.create(null) or { __proto__: null } targetsBoth prototype paths on that object, which has no accessor and no constructorOnly the objects you create. A merge that creates nested {} objects is exposed again one level down. Code that calls obj.hasOwnProperty() breaks.
Map and Set for keyed dataPrototype paths entirely, because keys never become propertiesNot JSON-serializable without conversion. Requires an API change.
Closed schema validation at the boundary (additionalProperties: false)Unknown keys, including all prototype paths, before any merge runsOnly inputs you validate. Every nested schema must also be closed.
Own-key iteration plus a denylist of __proto__, constructor, prototypeKnown spellings in code you controlThe next spelling. Lodash's record shows this list is never finished.
Object.hasOwn() before reading optionsThe gadget side: inherited values are ignoredEvery read site must change. Destructuring defaults stay exposed.
Object.freeze(Object.prototype) at startupAll writes to Object.prototypeBreaks libraries that extend built-ins. In strict mode the blocked write throws a TypeError, which turns pollution into a crash. Array.prototype and Function.prototype need freezing too.
node --disable-proto=delete or =throwThe __proto__ accessorconstructor.prototype. Treat it as defense in depth.
node --frozen-intrinsicsWrites to every built-in prototypeExperimental as of Node.js 22. Expect compatibility breaks.
Upgrading the vulnerable libraryThe specific advisoryThe next bypass. An upgrade also brings in new code you have not reviewed.

Our verdict: use null-prototype objects or Map for anything keyed by input, and validate input against closed schemas before it reaches a merge. Read security-relevant options with Object.hasOwn() so a polluted prototype has nothing to feed. Use --disable-proto and key denylists as backup layers, never as the fix. Freeze intrinsics only in services you control end to end and can test after every dependency change.

Triaging prototype pollution advisories in your dependencies

#

Prototype pollution typically reaches a security backlog as an advisory against a dependency you did not choose, often one of the transitive dependencies pulled in by a framework or CLI. We pinned three common packages to old versions and ran npm audit:

Bash
$ cat package.json
{ "dependencies": { "lodash": "4.17.15", "minimist": "1.2.0", "qs": "6.5.2" } }
$ npm audit --json | jq -r '.vulnerabilities[].via[]
    | select(type=="object") | "\(.cwe | join(",")) \(.severity) \(.title)"'
CWE-77,CWE-94 high Command Injection in lodash
CWE-770,CWE-1321 high Prototype Pollution in lodash
CWE-400,CWE-1333 moderate Regular Expression Denial of Service (ReDoS) in lodash
CWE-94 high lodash vulnerable to Code Injection via `_.template` imports key names
CWE-1321 moderate lodash vulnerable to Prototype Pollution via array path bypass in `_.unset` and `_.omit`
CWE-1321 moderate Lodash has Prototype Pollution Vulnerability in `_.unset` and `_.omit` functions
CWE-1321 moderate Prototype Pollution in minimist
CWE-1321 critical Prototype Pollution in minimist
CWE-1321 high qs vulnerable to Prototype Pollution
CWE-20 moderate qs's arrayLimit bypass in its bracket notation allows DoS via memory exhaustion
CWE-248,CWE-703 moderate qs: Denial of Service via Attacker Controlled isBuffer

Six of the 11 advisories against these three packages are tagged CWE-1321, as of November 2026. npm audit matches on name and version range. It cannot tell whether your code ever calls _.unset with a path an attacker controls.

That is the right question, and the Lodash maintainers asked it of their own backlog. Ulises Gascón of the Lodash TSC described the test: "Is this actually exploitable in Lodash's real usage model?" For a consumer, the equivalent is whether untrusted input reaches the specific function named in the advisory. Each lodash prototype pollution advisory in the table above names between one and six functions. If your application never calls them, the advisory is noise.

Function-level reachability analysis answers the first half of that question mechanically. It builds a call graph from your code into the dependency and checks whether the vulnerable functions can run. Socket reports that its Full Application Reachability typically filters out "about 80% of vulnerabilities as irrelevant." Reachability has a limit worth stating: a reachable _.merge on a hard-coded config object is still not exploitable. Call-graph analysis proves the function can run, not that an attacker controls its arguments. That last step still needs a human.

For the advisories that survive triage, there are three exits. Upgrade the direct dependency. Force a fixed version of a transitive one with overrides in npm or pnpm. Or patch the vulnerable file in place when no fixed version exists, as with lodash.set. Socket Certified Patches take the third route. The socket-patch CLI keys each patch to one exact package version, checks the file's hash before editing, and verifies the patched hash after writing. The announcement shows a one-line fix for a qs prototype pollution flaw that blocks assignment to __proto__. The trade-off is real. In its default mode, patches are reapplied after each install through a postinstall hook. A vendored mode commits the patched package instead and needs no install-time script.

Package managers are not immune either. A dependency name is an attacker-chosen key that a package manager reads into objects during install. pnpm 11.10, released in July 2026, fixed a prototype pollution hazard tied to a dependency named __proto__. Any tool that turns registry metadata into nested objects is a merge function over untrusted JSON.

JavaScript gives every object a shared, writable parent by default, and no library can fix that from the inside. Until a runtime ships frozen intrinsics on by default, every deep-merge helper on npm is one missed key spelling away from its next CVE, and published research has already catalogued the gadgets waiting on the other side.