Skip to main content

Prototype pollution in Node.js: how it happens, how it becomes RCE, and how to fix it

Most JavaScript bugs break one object. Prototype pollution breaks all of them. If an attacker can write a property onto Object.prototype, every plain object in the process inherits it: the config object you read next, the options you pass to child_process, the user object your auth check inspects. The bug is in one line of a merge helper. The impact shows up somewhere else entirely.

This post covers where prototype pollution comes from in Node.js, the gadgets that turn it from a curiosity into remote code execution or an auth bypass, and the fixes that actually close it. MITRE tracks the class as CWE-1321.

How does prototype pollution happen?

JavaScript objects look up missing properties on their prototype. For a plain object that is Object.prototype. The special key __proto__ is an accessor that returns that prototype, so writing through it writes to the shared object:

const a = {};
a['__proto__']['isAdmin'] = true;
const b = {};
console.log(b.isAdmin); // true: every object now "has" isAdmin

Nobody writes that on purpose. It arrives through code that copies keys it does not control.

Deep merge and set-by-path helpers

The classic source is a recursive merge:

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;
}

app.post('/settings', (req, res) => {
  merge(userSettings, req.body); // body: {"__proto__": {"isAdmin": true}}
  res.sendStatus(204);
});

JSON.parse creates __proto__ as an ordinary own property, so the loop sees it, target['__proto__'] resolves to Object.prototype, and the recursion writes into it. The same shape appears in "set a value at a dotted path" helpers (set(obj, 'a.b.c', value)) when the path comes from the request: __proto__.isAdmin or constructor.prototype.isAdmin both reach the prototype.

Popular libraries have shipped this bug. CVE-2018-3721 covered lodash's merge, mergeWith and defaultsDeep via __proto__, and CVE-2019-10744 covered defaultsDeep again through a constructor payload (fixed in 4.17.12). If you depend on an old version of a merge utility, your code may be vulnerable without containing a merge loop of its own.

Query string parsers

Parsers that build nested objects from a[b][c]=1 syntax have the same problem. CVE-2022-24999 describes how qs before 6.10.3, as used by Express before 4.17.3, let an unauthenticated attacker hang a Node process with a __proto__ key in the URL. Express 5 changed the default query parser from "extended" to "simple", per the Express 5 migration guide, which removes nested-object parsing unless you opt back in. On Express 4, keep qs patched and consider app.set('query parser', 'simple') if you do not need nested query objects.

Gadgets: from a polluted prototype to RCE or auth bypass

Pollution on its own only adds a property. The damage comes from a gadget: code that reads a property it never set and does something important with the inherited value.

Auth and feature flags. Any check of the form if (user.isAdmin) or if (options.skipValidation) where the property is usually absent will now read true from the prototype.

Options objects. Functions that accept an options bag and read optional keys are gadgets when those keys control behaviour. child_process.spawn(cmd, args, {}) reads options such as shell and env; a polluted shell or environment variable can change what actually runs. Template engines that read compile options from a plain object have produced similar gadgets.

This is not theoretical. The USENIX Security 2023 paper Silent Spring reports finding 11 universal gadgets in core Node.js APIs that lead to code execution, and manually exploiting eight RCE vulnerabilities in three high-profile applications, including the npm CLI.

Denial of service. The simplest outcome. Polluting toString, length or a commonly read flag with the wrong type throws on every request, which is exactly the hang CVE-2022-24999 describes.

How do you fix prototype pollution in Node.js?

The OWASP Prototype Pollution Prevention Cheat Sheet lists the main controls. In order of how much they remove:

1. Use a Map for key-value data from users

A Map has no prototype chain for its entries. map.set('__proto__', x) stores a key named __proto__ and nothing else.

const prefs = new Map(Object.entries(req.body.prefs ?? {}));
const theme = prefs.get('theme');

2. Create lookup objects with Object.create(null)

An object with a null prototype cannot reach Object.prototype through __proto__:

const index = Object.create(null);
for (const [k, v] of Object.entries(req.body)) index[k] = v;

Check membership with Object.hasOwn(obj, key) rather than key in obj or obj[key] !== undefined, both of which consult the prototype.

3. Allowlist keys, and refuse the dangerous ones in generic helpers

Most merge calls do not need arbitrary keys. Copy the fields you expect:

const ALLOWED = ['theme', 'locale', 'timezone'];
const update = Object.fromEntries(
  ALLOWED.filter((k) => Object.hasOwn(req.body, k)).map((k) => [k, req.body[k]])
);
Object.assign(userSettings, update);

If you must keep a generic deep merge, make it skip __proto__, constructor and prototype at every level, and recurse only into own properties. Better still, validate the body against a schema that rejects unknown keys before it reaches any merge.

4. Freeze the prototype and disable __proto__ (defense in depth)

Calling Object.freeze(Object.prototype) at startup makes writes to it fail silently (or throw in strict mode). Test this first: some libraries patch built-in prototypes and will break. Node's --disable-proto=delete flag removes the __proto__ accessor, documented in the Node.js CLI reference. OWASP notes it does not stop pollution through constructor.prototype, so treat it as a backstop, not the fix.

5. Patch the dependency

Upgrade merge, set-by-path and query parsing libraries to versions that block the dangerous keys, and keep them current.

What Vulkro checks

Vulkro follows request data (req.body, req.query, req.params and the framework equivalents) in JavaScript and TypeScript, and treats a computed write that can reach __proto__ as a sink. The rule is js-taint-protopollution-001, and each finding carries the hops from the route to the write. Vulnerable versions of merge and parser packages surface separately through dependency matching against the local CVE bundle. If you run the app locally, vulkro probe --allow-mutations against a non-production copy includes a prototype pollution probe, documented on the probe page. See the JavaScript and TypeScript coverage for the full source and sink list.

FAQ

What is a prototype pollution example in JavaScript?

A request body of {"__proto__": {"isAdmin": true}} passed into a recursive merge. The merge writes isAdmin onto Object.prototype, and every object without its own isAdmin now reads true.

Does Object.freeze prevent prototype pollution?

Freezing Object.prototype blocks writes to it, which stops the most common form. It is shallow and can break libraries that patch built-ins, so use it alongside key allowlists, Map and null-prototype objects rather than instead of them.

Is JSON.parse vulnerable to prototype pollution?

JSON.parse itself does not pollute: it creates __proto__ as an ordinary own property. The danger is the next step, when code copies that object's keys into another object with a merge, for...in loop or set-by-path helper.

Start a 14-day trial at /start/ to scan your own Node.js services. Related reading: Taint analysis: sources, sinks and sanitizers and SSRF in Node.js.