# Prototype pollution: how it works, how to find it and how to fix it in Node.js

Source: https://penetrationtestservices.com/vulnerabilities/prototype-pollution/
Publisher: Excession Labs (penetrationtestservices.com)
Last updated: 2026-11-09

Prototype pollution explained: how one JSON key changes every object, client and server tests, lodash, minimist and protobufjs CVEs with fixed versions, and tested fixes.

> **Short answer:** Prototype pollution is a JavaScript flaw where user input adds or changes properties on Object.prototype, so every object in the process inherits them. It usually comes from unsafe recursive merges. Alone it is often harmless, but with a gadget it leads to DOM XSS in browsers or remote code execution on servers. Fix it with safe merges, null-prototype objects or Map, schema validation and updated dependencies.

Prototype pollution is a JavaScript flaw where untrusted input adds or changes properties on a global object prototype, usually `Object.prototype`, so that every object in the process inherits them. CWE-1321 describes it as a product that receives input specifying attributes to initialize or update in an object, and fails to restrict changes to the object's prototype. It is also called prototype pollution in JavaScript and Node.js reports.

Testing for it needs written authorization from the system owner. The examples below use a fictional app and local code.

## How does prototype pollution work?

Prototype pollution works because JavaScript objects look up missing properties on their prototype. If an attacker can write to the prototype, every object that lacks that property reads the attacker's value. PortSwigger explains that this typically happens when a function recursively merges user-controlled data into an existing object without sanitizing keys, so a key like `__proto__` writes to the prototype and not to the target object.

The flaw needs three parts, as PortSwigger describes them: a source (the input that injects properties), a sink (code that can run script or commands) and a gadget (a property that is read unsafely and can be inherited). Pollution alone is often harmless. The gadget makes it a vulnerability.

> **Illustrative pollution: Harbor Notes:** This is a demonstration. No real customer system was tested. Harbor Notes is a fictional app and example.com is a reserved example domain.
> 
> The Harbor Notes API merges a settings object from the request body into the user's defaults with a hand-written recursive merge:
> 
> ```js
> function merge(target, source) {
>   for (const key in source) {
>     if (typeof source[key] === "object" && source[key] !== null) {
>       if (!target[key]) target[key] = {};
>       merge(target[key], source[key]);
>     } else {
>       target[key] = source[key];
>     }
>   }
>   return target;
> }
> ```
> 
> A tester on a local copy sends the settings body below, parsed with `JSON.parse`, which treats `__proto__` as an ordinary own key:
> 
> ```json
> { "theme": "dark", "__proto__": { "probe": "polluted-by-tester" } }
> ```
> 
> After the merge, a fresh object that never had that field reads it:
> 
> ```js
> const merged = merge({}, JSON.parse(body));
> console.log({}.probe); // "polluted-by-tester": the shared prototype changed
> ```
> 
> The control is the same request without the `__proto__` key, after a process restart, where `{}.probe` is `undefined`. A safe merge leaves `{}.probe` undefined for both requests.
The first merge call writes to the prototype because `target["__proto__"]` resolves to `Object.prototype` and the recursion then writes into it. The change lasts as long as the process runs, and it affects other users' requests in the same Node.js process.

## What are the main types of prototype pollution?

The two main types are client-side and server-side. They have different sources, different gadgets and different tests.

| Type | Typical source | Typical gadget or impact | How testers detect it |
|---|---|---|---|
| Client-side | URL query or fragment, web messages | DOM XSS through a script or HTML property | Add `?__proto__[probe]=value` and check `Object.prototype.probe` in the browser console |
| Server-side | JSON body, query string parsed into nested objects | Changed behavior, denial of service, in some apps remote code execution | Non-destructive probes that change a harmless response property |
| Dependency | A vulnerable library function such as a deep merge or parser | Depends on the app | Software composition analysis, version check, then a targeted test |
| Via constructor | `constructor.prototype` instead of `__proto__` | Same as above, bypasses filters that only block `__proto__` | Test the alternate path on the same source |

PortSwigger says client-side pollution commonly leads to DOM XSS, and server-side pollution can result in remote code execution. For the client-side impact, see [DOM-based XSS](/vulnerabilities/dom-based-xss/). For a related server-side binding flaw, see [mass assignment](/vulnerabilities/mass-assignment/).

## What are the real CVEs?

Real prototype pollution CVEs sit in popular npm packages. This table gives each ID, its NVD publication date and the fixed version that NVD names. Dates are NVD publication dates checked on 9 November 2026.

| CVE | Package and function | Published | Fixed in |
|---|---|---|---|
| CVE-2019-10744 | lodash `defaultsDeep` | 26 Jul 2019 | 4.17.12 |
| CVE-2020-7598 | minimist (`constructor` or `__proto__` payload) | 11 Mar 2020 | 1.2.2 |
| CVE-2020-8203 | lodash `zipObjectDeep` | 15 Jul 2020 | 4.17.20 |
| CVE-2022-25878 | protobufjs `util.setProperty` and related functions | 27 May 2022 | 6.11.3 |
| CVE-2023-36665 | protobufjs, a different flaw with a user-controlled protobuf message | 5 Jul 2023 | 7.2.5 |

NVD lists all five under CWE-1321. For lodash, the NVD text says "before 4.17.12" for the first and "before 4.17.20" for the second, so a project on 4.17.21 or later is past both fixes. Check your lock file for the version that is actually installed, because a nested dependency can pull an older copy.

## From pollution to impact: what are gadgets?

A gadget is a property that code reads without checking where it came from. PortSwigger's example is a library that uses `config.transport_url || defaults.transport_url` and then loads a script from that value. If the attacker pollutes `transport_url`, the library loads a script of the attacker's choice. An object defined with a `null` prototype inherits nothing, so it cannot carry a gadget.

On the server, a gadget is code that reads an option from an object that the developer did not fill in. PortSwigger documents server-side cases that lead to remote code execution through the options of process-spawning functions. Do not hunt for these on systems you do not own. In an authorized test, the evidence of a gadget is a harmless, reversible change, such as a property that alters a response field, and not a command run on the host.

## How do penetration testers detect and prove prototype pollution?

Testers prove it with a probe that changes a harmless property, then read the effect back and run a control. The method follows our [methodology](/methodology/).

**Client-side.**

1. Open the page in Chromium and add `?__proto__[probe]=1` to the URL, or `?constructor[prototype][probe]=1` for the constructor path.
2. In the console, run `({}).probe`. A value of `"1"` shows that the source works.
3. Find a gadget by reading the page's client-side JavaScript bundles, or use DOM Invader in Burp's browser, which PortSwigger documents for this task.
4. Label the result "Browser-observed" and include the URL, the console output and a screenshot.

**Server-side.**

1. Use a test account and a test tenant. Send JSON with a `__proto__` key to each endpoint that merges or stores nested objects.
2. Look for a reflected property. If the response shows the injected property, the source works.
3. If nothing reflects, PortSwigger lists three detection techniques that do not need reflection: status code override, JSON spaces override and charset override. Each changes a harmless response setting that the framework reads from a polluted property, and each can be reversed by restarting a test instance.
4. Run controls: the same body without the `__proto__` key, a body with a harmless unknown key, and the same request after restarting the instance.
5. Read back from a second session to prove the effect reached other requests.

Because pollution lasts for the lifetime of the process, test on a staging copy where you can restart the instance, or agree the risk in writing. Never use probes on production that could break other users' requests. Report the controls that held, such as a merge path that ignores `__proto__`.

For dependency scans, run `npm audit` and your software composition analysis tool, then match each finding to the table above and check whether your code reaches the vulnerable function.

## How do you prevent prototype pollution?

Prevent it by not merging untrusted keys into plain objects, and by keeping the runtime and libraries up to date. OWASP's Prototype Pollution Prevention Cheat Sheet recommends `Set` or `Map` instead of object literals, `Object.create(null)` for objects without a prototype, and freezing built-in prototypes. It also recommends the `--disable-proto=delete` Node.js flag as defense in depth. It says that the flag is not a complete fix, because pollution is still possible through `constructor.prototype`.

Safe merge, with a key block:

```js
const BLOCKED = new Set(["__proto__", "constructor", "prototype"]);

function safeMerge(target, source) {
  for (const key of Object.keys(source)) {   // own keys only
    if (BLOCKED.has(key)) continue;
    const value = source[key];
    if (value !== null && typeof value === "object" && !Array.isArray(value)) {
      if (!Object.hasOwn(target, key)) target[key] = Object.create(null);
      safeMerge(target[key], value);
    } else {
      target[key] = value;
    }
  }
  return target;
}
```

Null-prototype objects and `Map` for lookup tables:

```js
const counts = Object.create(null);   // no inherited keys
counts[userKey] = (counts[userKey] ?? 0) + 1;

const cache = new Map();              // keys are data, not property names
cache.set(userKey, value);
```

Schema validation with an allow-list, using Zod, rejects unknown keys before any merge:

```js
import { z } from "zod";
const Settings = z.object({ theme: z.enum(["light", "dark"]), lang: z.string().max(8) }).strict();
const clean = Settings.parse(JSON.parse(body));   // throws on a __proto__ or unknown key
```

Ajv works the same way with `additionalProperties: false`. A schema helps because it names the keys you accept, and it is the same control that stops [mass assignment](/vulnerabilities/mass-assignment/).

Runtime hardening:

```js
// run once at startup, before any request handling
Object.freeze(Object.prototype);
```

```bash
node --disable-proto=delete app.js
```

The Node.js CLI documentation says `--disable-proto=delete` removes the `Object.prototype.__proto__` property and `throw` makes access throw `ERR_PROTO_ACCESS`. OWASP warns that freezing is shallow and that some libraries change built-in prototypes, so test your app after you freeze.

Other fixes:

- Update lodash to 4.17.21 or later, minimist to 1.2.2 or later and protobufjs to 7.2.5 or later (the highest fixed version in the table).
- Avoid deep-merging request bodies into configuration objects. Copy the named fields you need.
- Check that code that reads options uses `Object.hasOwn` for values it did not set itself.

> **Important:** A key block that checks only `__proto__` is not enough. The `constructor.prototype` path reaches the same prototype. Block both, or use null-prototype objects and a schema.
## Prototype pollution testing checklist

Run this on a test environment with written authorization.

- Every endpoint that merges or stores nested JSON is tested with `__proto__` and `constructor.prototype` keys.
- Query strings parsed into nested objects are tested the same way.
- Client-side URL sources are tested in a real browser with `?__proto__[probe]=1`.
- Server-side probes use harmless properties and a restartable test instance.
- Controls run: no `__proto__` key, an unknown harmless key, and the same request after a restart.
- A read-back from a second session shows whether the change reached other requests.
- Dependencies are checked against the CVE table above, and lock files are checked for nested older copies.
- Merge functions in your own code are reviewed for unchecked keys.
- A schema rejects unknown keys at the API boundary.
- `Object.freeze(Object.prototype)` or `--disable-proto=delete` is tested in staging as a second layer.

## When should you get a professional test?

Get a professional test when your Node.js API merges client data into shared objects, when you ship a single-page app with URL-driven configuration, or when dependency alerts show prototype pollution CVEs and you need to know if they are reachable. A scanner can flag a version. It cannot tell you whether a gadget exists in your code. Our [API penetration testing](/api-penetration-testing/) and [web application penetration testing](/web-application-penetration-testing/) include safe pollution probes with controls and read-back evidence. Excession Labs agrees the scope with you in writing before testing starts. For a CORS review in the same scope, see [CORS misconfiguration](/vulnerabilities/cors-misconfiguration/).

## Questions

### What is prototype pollution in simple terms?

In JavaScript, objects inherit properties from a prototype. Prototype pollution is when untrusted input adds a property to Object.prototype, the shared prototype, so every ordinary object seems to have it. PortSwigger says it typically happens when a function recursively merges user-controlled data into an object without sanitizing the keys.

### Is prototype pollution only a JavaScript problem?

Yes, the class is specific to JavaScript and runtimes such as Node.js, because it depends on prototype inheritance. CWE-1321 describes it in general terms as a failure to restrict changes to an object's prototype attributes. The impact depends on the code that reads the polluted property.

### Does npm audit find prototype pollution?

It finds known vulnerable versions of dependencies, such as lodash before 4.17.12 for CVE-2019-10744. It does not find pollution in your own merge code, or in a library version with no advisory. Use it for dependencies and a code review or test for your own merge functions.

### Does Node's --disable-proto flag fix prototype pollution?

No. OWASP calls it a defense in depth measure and says pollution is still possible through constructor.prototype. The flag removes or blocks the __proto__ accessor. It is a second layer. Safe merge code and validated input are the fix.

### Can prototype pollution lead to remote code execution?

Yes, on the server side, when a polluted property reaches a sink such as the options of a process-spawning call or a template engine. PortSwigger documents server-side cases that lead to remote code execution. The result depends on a gadget in your code or dependencies, so each app needs its own test.

## Sources

- [PortSwigger Web Security Academy: Prototype pollution](https://portswigger.net/web-security/prototype-pollution)
- [PortSwigger Web Security Academy: Server-side prototype pollution](https://portswigger.net/web-security/prototype-pollution/server-side)
- [OWASP Prototype Pollution Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Prototype_Pollution_Prevention_Cheat_Sheet.html)
- [CWE-1321: Improperly Controlled Modification of Object Prototype Attributes](https://cwe.mitre.org/data/definitions/1321.html)
- [Node.js documentation: --disable-proto](https://nodejs.org/docs/latest/api/cli.html)
- [NVD: CVE-2019-10744 (lodash defaultsDeep)](https://nvd.nist.gov/vuln/detail/CVE-2019-10744)
- [NVD: CVE-2020-8203 (lodash zipObjectDeep)](https://nvd.nist.gov/vuln/detail/CVE-2020-8203)
- [NVD: CVE-2020-7598 (minimist)](https://nvd.nist.gov/vuln/detail/CVE-2020-7598)
- [NVD: CVE-2022-25878 (protobufjs)](https://nvd.nist.gov/vuln/detail/CVE-2022-25878)
- [NVD: CVE-2023-36665 (protobufjs)](https://nvd.nist.gov/vuln/detail/CVE-2023-36665)
