# 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

Source: https://socket.dev/glossary/prototype-pollution

Last updated: 2026-11-02

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](https://socket.dev/glossary/common-weakness-enumeration-cwe).

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](https://socket.dev/glossary/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](https://tc39.es/ecma262/#sec-object.prototype.__proto__). 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`.

> Three objects share Object.prototype. Before the merge, a lookup of isAdmin on any of them returns undefined. After a merge writes isAdmin to Object.prototype, every object returns true.

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](https://developer.mozilla.org/en-US/docs/Web/Security/Attacks/Prototype_pollution) 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](https://github.com/advisories/GHSA-hrpp-h998-j3pp) is a [denial of service](https://socket.dev/glossary/denial-of-service-dos) 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](https://socket.dev/glossary/cross-site-scripting-xss).

**Code execution on the server.** [Remote code execution](https://socket.dev/glossary/remote-code-execution-rce) needs a gadget that passes an inherited property to a process, module loader, or code generator. The [Silent Spring paper](https://www.usenix.org/conference/usenixsecurity23/presentation/shcherbakov) (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](https://arxiv.org/abs/2407.10812) (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.

> Timeline of lodash prototype pollution fixes from 2018 to 2026, showing each fix and the bypass that followed it.

| Released | Version | Advisory | What changed |
| --- | --- | --- | --- |
| 2018-02-04 | 4.17.5 | CVE-2018-3721, moderate | merge, mergeWith, and defaultsDeep stop following \_\_proto\_\_. |
| 2019-07-09 | 4.17.12 | CVE-2019-10744, critical, CVSS 9.1 | Bypass: defaultsDeep still reached Object.prototype through constructor.prototype. |
| 2020-07-08 | 4.17.19 | CVE-2020-8203, high | Path setters: set, setWith, update, updateWith, zipObjectDeep, pick. |
| 2021-02 to 2026-01 | none | none | 4.17.21 is the last release for almost five years. |
| 2026-01-21 | 4.17.23 | CVE-2025-13465, moderate | unset and omit could delete properties from global prototypes. |
| 2026-03-31 | 4.18.0 | CVE-2026-2950, moderate | Bypass: 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](https://github.com/advisories/GHSA-fvqr-27wr-82fm), [CVE-2019-10744](https://github.com/advisories/GHSA-jf85-cpcp-j695), [CVE-2020-8203](https://github.com/advisories/GHSA-p6mc-m468-83gw), [CVE-2025-13465](https://github.com/advisories/GHSA-xxjr-mmjv-4gpg), and [CVE-2026-2950](https://github.com/advisories/GHSA-f23m-r3pf-42rh). 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](https://socket.dev/blog/inside-lodash-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:

```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](https://nodejs.org/api/cli.html#--disable-protomode), available since v12.17.0, removes the `__proto__` accessor. It does nothing about `constructor.prototype`, which still polluted the prototype in our test.

| Defense | What it blocks | What it misses or costs |
| --- | --- | --- |
| Object.create(null) or { \_\_proto\_\_: null } targets | Both prototype paths on that object, which has no accessor and no constructor | Only 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 data | Prototype paths entirely, because keys never become properties | Not 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 runs | Only inputs you validate. Every nested schema must also be closed. |
| Own-key iteration plus a denylist of \_\_proto\_\_, constructor, prototype | Known spellings in code you control | The next spelling. Lodash's record shows this list is never finished. |
| Object.hasOwn() before reading options | The gadget side: inherited values are ignored | Every read site must change. Destructuring defaults stay exposed. |
| Object.freeze(Object.prototype) at startup | All writes to Object.prototype | Breaks 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 =throw | The \_\_proto\_\_ accessor | constructor.prototype. Treat it as defense in depth. |
| node --frozen-intrinsics | Writes to every built-in prototype | Experimental as of Node.js 22. Expect compatibility breaks. |
| Upgrading the vulnerable library | The specific advisory | The 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](https://socket.dev/glossary/transitive-dependency) pulled in by a framework or CLI. We pinned three common packages to old versions and ran [npm audit](https://socket.dev/glossary/npm-audit):

```sh
$ 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](https://socket.dev/blog/inside-lodash-security-reset): "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](https://socket.dev/glossary/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](https://socket.dev/blog/introducing-tier-1-reachability) 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](https://socket.dev/blog/certified-patches) take the third route. The [`socket-patch` CLI](https://github.com/SocketDev/socket-patch) 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](https://socket.dev/blog/pnpm-11-1-hardens-registry-authentication), 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.
