Skip to content

Prototype pollution

CWE-1321OWASP A03:2021Updated August 31, 20267 min read

Prototype pollution is a vulnerability in which an attacker uses keys such as __proto__ to add properties to Object.prototype, which nearly every object in the process then inherits. The usual route is a recursive merge of untrusted input, and the payoff is an authorisation bypass or code execution through a gadget. The fix is to refuse the dangerous keys and validate input against a strict schema.

Most JavaScript applications fold user input into an object they already hold: a settings update, a configuration, a patched profile. When a recursive merge does that and copies whatever keys it is handed, an attacker can supply a key that never lands in the object at all, but in the shared blueprint behind it. That is prototype pollution. Here is how it works and how to close it.

What is prototype pollution?

Prototype pollution is a vulnerability in which an attacker adds or changes properties on the prototype that JavaScript objects inherit from, so that nearly every other object in the running process picks those properties up. Every ordinary object inherits from Object.prototype. When code reads a property an object does not define itself, the engine walks up that prototype chain and returns whatever it finds there. Writing to the prototype therefore means answering on behalf of every object at once.

An everyday comparison: a print shop keeps one master form, and every blank form handed over the counter is a copy of it. Add a line of small print to the master and every form printed from then on carries it, though nobody touched the copies already in circulation. Object.prototype is that master form.

Two doors lead to it: on an ordinary object the key __proto__ resolves to its prototype, and the longer route through constructor and then prototype arrives at the same place. A filter watching only the first name leaves the second wide open.

The vulnerability comes in two flavours. In the browser the input reaches the prototype chain through the query string, the URL fragment or a message between windows, and the attack usually ends in DOM-based cross-site scripting. On the server, typically in Node.js, it arrives in a JSON body or a nested query string, and the outcome can be an authorisation bypass or remote code execution (RCE), meaning arbitrary code on your server.

How does a prototype pollution attack work?

Take an endpoint where a user updates their own settings: the application loads the stored settings, merges the submitted fields into them and writes the result back. The merge is hand-rolled and recurses into nested objects.

Vulnerable:

// Hand-rolled deep merge: it copies every key it is handed
function merge(target, source) {
  for (const key of Object.keys(source)) {
    const value = source[key];

    if (value !== null && typeof value === "object") {
      // Reading target[key] resolves __proto__ to the prototype itself
      if (typeof target[key] !== "object" || target[key] === null) {
        target[key] = {};
      }
      merge(target[key], value);
    } else {
      target[key] = value;
    }
  }
  return target;
}

app.post("/api/settings", express.json(), (req, res) => {
  const settings = merge(loadSettings(req.user.id), req.body);
  saveSettings(req.user.id, settings);
  res.json(settings);
});

An ordinary request carries a theme and returns the updated settings. An attacker adds one extra key to the body:

POST /api/settings HTTP/1.1
Host: app.example.com
Content-Type: application/json

{"theme":"dark","__proto__":{"isAdmin":true}}

Two properties of the language make this work. First, JSON.parse keeps __proto__ as an ordinary own property of the object it builds, so the key survives parsing and reaches the loop unharmed. Second, reading target["__proto__"] behaves quite differently: it hands back not a property of the target object but Object.prototype itself. The recursion writes isAdmin straight into the prototype and reports success.

From that point the entire process is polluted, for every user it serves:

const account = {};

account.isAdmin;                    // true, though nothing ever assigned it
Object.hasOwn(account, "isAdmin");  // false

Any check that merely asks whether the value is truthy now waves everyone through: a middleware that opens the administration area on that flag sees it set on every request. The same move works against options a library reads and quietly expects to be absent. Such an option is called a gadget. Pollute the option that decides whether a child process is spawned through a shell, or a field that a template engine evaluates as code, and the pollution becomes code execution.

The repair has two halves. Refuse the dangerous keys, and never recurse into an object whose prototype you did not choose yourself.

Secure:

const FORBIDDEN = new Set(["__proto__", "constructor", "prototype"]);

function safeMerge(target, source) {
  for (const key of Object.keys(source)) {
    if (FORBIDDEN.has(key)) continue;

    const value = source[key];

    if (value !== null && typeof value === "object" && !Array.isArray(value)) {
      const existing = Object.hasOwn(target, key) ? target[key] : null;

      if (existing === null || typeof existing !== "object") {
        // No prototype at all: "__proto__" is a plain key in here
        target[key] = Object.create(null);
      }
      safeMerge(target[key], value);
    } else {
      target[key] = value;
    }
  }
  return target;
}

Better still, do not merge free-form input at all. Describe the expected shape in a schema and discard unknown fields, and __proto__ never reaches the merge:

import { z } from "zod";

const Settings = z.object({
  theme: z.enum(["light", "dark"]),
  notifications: z.object({ email: z.boolean() }).strict()
}).strict();

app.post("/api/settings", (req, res) => {
  const parsed = Settings.safeParse(req.body);
  if (!parsed.success) {
    return res.status(400).send("Invalid settings");
  }

  const settings = safeMerge(loadSettings(req.user.id), parsed.data);
  saveSettings(req.user.id, settings);
  res.json(settings);
});

Where the keys themselves come from the user, keep them out of ordinary objects. A Map and a null-prototype object have no prototype chain to walk:

const attempts = new Map();
attempts.set(req.ip, count);          // keys stay data, never properties

const labels = Object.create(null);
labels[req.query.name] = value;       // "__proto__" is an ordinary key here
A denylist holding only __proto__ is not enough, because constructor followed by prototype reaches the same object. A filter that strips the text from a key once is bypassable as well, since a valid key can reappear after the strip. Refuse all three keys outright rather than sanitising them.

What is the impact of prototype pollution?

Prototype pollution is rarely game over by itself and rarely harmless either. Severity hangs on the gadget: the polluted property does nothing until other code reads it and bases a decision on it. Without one you are left with strange behaviour or a crashed worker, a denial of service in its own right, because the pollution covers the whole process and clears only on restart.

Where a gadget does exist, the escalation is quick. A polluted property inside an authorisation check produces an authorisation bypass, letting an attacker reach administrative functionality without ever guessing a password. A gadget in a template engine or in the options of a call to an external program produces code execution on the server, with everything that follows: reading secrets, exfiltrating data, pivoting into internal systems. In the browser the same flaw ends in cross-site scripting and therefore in hijacked sessions.

One trait weighs heavily in business terms: the damage is not confined to the attacker’s own session. Because every object in the process shares one blueprint, a single request can change how concurrent users are handled, which is a particularly unwelcome property in an environment shared by several customers. The rating therefore runs from medium to high.

How do you detect prototype pollution?

Start where user input enters an object with no fixed shape: functions named merge, extend, clone or set, query string parsers with nested notation, configuration loaders, and endpoints that lay a JSON body straight over a stored object. This is a classic in third-party code, so a software composition analysis of your dependencies belongs in the same sweep.

Testing means sending a payload with __proto__ in the JSON body, then through the query string as ?__proto__[polluted]=yes, then once more using constructor and prototype, and checking whether a freshly created object has inherited the property. When nothing comes back, the case is blind and you need an observable side effect: pollute a setting the framework reads, for example the option Express uses for the indentation of JSON responses, and watch the formatting of every later response change. On the client you set a property through the URL and check in the console whether an empty object inherits it.

Scanners flag known vulnerable dependencies but almost never spot a hand-written merge, let alone the gadget that turns pollution into an exploit. That takes reading the code and reasoning from pollution to effect by hand. AssistSec covers this in a penetration test and demonstrates, per finding, which key reached the prototype and which gadget then acted on it.

How do you prevent prototype pollution?

  • Refuse the keys __proto__, constructor and prototype at the boundary. Do it while parsing anything that arrives from outside, not deep in the pipeline where one code path can skip the check.
  • Validate against a strict schema. Describe the expected fields with a validator such as zod, Joi or Ajv and reject unknown fields explicitly. The same measure closes mass assignment.
  • Use Object.create(null) or a Map for user-controlled keys. Neither has a prototype chain, so a key named after the prototype is stored as plain data.
  • Do not write your own deep merge for untrusted input. Use a maintained library, test own properties with Object.hasOwn, and never recurse into an object you did not create yourself.
  • Harden the runtime where you can. Node.js offers --disable-proto with the values throw and delete. Freezing Object.prototype also works, but test it first: some libraries write to prototypes themselves.
  • Keep dependencies current and have the code reviewed. Merge, query string and configuration libraries are the best-known source; a review of every merge routine plus a targeted penetration test catches what a scanner skips.

Sources

Frequently asked questions

What is the difference between prototype pollution and mass assignment?

In a mass assignment an attacker writes to a field that really exists on the record but should not have been editable, such as a role on their own account. In prototype pollution the write lands outside the record entirely, in the shared blueprint that all objects inherit from. The consequences look alike, but the blast radius is not: a polluted property appears in every object that does not define that property itself, including objects built for other users.

Is prototype pollution only a Node.js problem?

No. The weakness sits in the language, not in the runtime, so browser code is exposed in the same way. On the client the input normally arrives through the query string, the URL fragment or a message between windows, and the attack ends in DOM-based cross-site scripting. Server-side in Node.js the consequences are usually heavier, because more gadgets that lead to code execution are within reach.

Is blocking __proto__ enough?

No. The same prototype is reachable through the key constructor followed by prototype, so a filter aimed at one name leaves the other route open. Filters that strip the offending text from a key once can also be defeated, because a valid key can reappear after the strip. Refuse all three keys outright, or use a strict schema that discards unknown fields.

How serious is prototype pollution in practice?

It depends on the gadget, because a polluted property does nothing until other code reads it and makes a decision on it. Without a usable gadget you are left with odd behaviour or a crashed process, which is a denial of service in its own right. With a gadget in an authorisation check, a template engine or the options of a call to an external program, it reaches full compromise, which is why the rating spans medium to high.

Related articles

Press / to search · Esc