What is prototype pollution?

What is prototype pollution?

Prototype pollution is a JavaScript bug where an attacker writes to the shared prototype that almost every object inherits from, and that one write quietly changes objects all over your app. It usually starts with untrusted JSON and a helper that copies fields into an object without checking the key names. The result is a property that appears on data you never touched, which is how prototype pollution turns a harmless merge into privilege escalation, a crash, or a step toward worse.

What an object prototype actually is

In JavaScript every object has a hidden link to another object called its prototype. When you read a property that the object does not have, the engine walks up that chain and checks the prototype next. Most plain objects you create with {} link to one shared object: Object.prototype.

Here is the part that matters. There is one Object.prototype for the whole runtime. Every {} you make, every parsed JSON object, every options bag passed around your code, all of them inherit from that same object. So if an attacker can add a property to Object.prototype, that property shows up as a default on millions of objects at once.

Two doors lead to that shared object. The first is __proto__, an accessor that points at an object’s prototype. Reading obj.__proto__ gives you the prototype. Writing obj.__proto__.isAdmin = true sets a property on the prototype itself, not on obj. The second door is constructor.prototype. From any object you can reach obj.constructor, which for a plain object is Object, and Object.prototype from there. Both paths land on the same shared object.

How prototype pollution happens in real code

The classic source is a recursive merge that copies user supplied JSON into an existing object. Imagine a small invented app, Acme Notes, that lets users save preferences. The server merges the posted JSON onto a defaults object:

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]);   // recurse with attacker controlled key
    } else {
      target[key] = source[key];
    }
  }
  return merge;
}

// defaults the server trusts
const prefs = { theme: 'light' };

// body posted by the user
const body = JSON.parse(req.body);
merge(prefs, body);

Now the attacker posts this body:

{ "__proto__": { "isAdmin": true } }

The loop hits the key __proto__, sees an object value, and recurses into target["__proto__"], which is Object.prototype. It then sets isAdmin = true on the prototype. The user’s own prefs object looks untouched. But Object.prototype.isAdmin is now true for the entire process.

The attacker never edits the object you are looking at. They edit the default that every other object falls back to, and you read that default by accident later.

Why the polluted property leaks everywhere

Later, in code that has nothing to do with preferences, someone checks a fresh object:

const session = {};            // a brand new, empty object
if (session.isAdmin) {
  grantAdminAccess();          // runs, because the property is inherited
}

session has no own isAdmin key, so the engine walks the prototype chain, finds isAdmin = true on Object.prototype, and returns it. The check passes. That is privilege escalation from a single preferences write, and the two pieces of code may live in different files written by different people.

The other shapes of damage

  • Denial of service. Pollute a property that core libraries read, such as a numeric or function valued field, and unrelated objects start failing type checks or throwing. A few bytes of JSON can crash a worker on every request.
  • Gadget toward code execution. On its own a polluted property is just a default value. The danger is when that default flows into a sink that later treats it as code or as a config that controls a child process or a template. If a templating engine reads an inherited option, or a command runner reads inherited arguments, the polluted value becomes the input to that sink. We will keep this at a high level: the lesson is that a write you think is contained can reach a dangerous place because so much code reads from the shared prototype.

Fixing the vulnerable merge

The same merge becomes safe once you refuse the dangerous keys and stop trusting inherited properties. Several defenses stack together:

const BANNED = new Set(['__proto__', 'constructor', 'prototype']);

function safeMerge(target, source) {
  for (const key of Object.keys(source)) {     // own keys only
    if (BANNED.has(key)) continue;             // reject the doors
    const value = source[key];
    if (value && typeof value === 'object' && !Array.isArray(value)) {
      if (typeof target[key] !== 'object' || target[key] === null) {
        target[key] = Object.create(null);     // no prototype to pollute
      }
      safeMerge(target[key], value);
    } else {
      target[key] = value;
    }
  }
  return target;
}

What each piece buys you:

  • Block __proto__, constructor, and prototype keys. This shuts both doors to the shared prototype. Reject the whole request rather than silently dropping the key, so abuse is visible.
  • Use Object.create(null) for bags of user data. An object with a null prototype has no inherited isAdmin to leak and no __proto__ accessor to abuse. Lookups return only own keys.
  • Prefer a Map over a plain object for key value data from users. A Map stores keys as real entries, so __proto__ is just a string key with no special meaning and no prototype chain to walk.
  • Freeze the prototype. Object.freeze(Object.prototype) at startup makes the shared object read only, so even a missed sink cannot write to it. Test this, since some libraries expect to extend prototypes.
  • Validate against a schema. Define the exact fields you accept and their types, then drop everything else. A schema that allows only theme and fontSize never lets __proto__ through in the first place.

How to detect and prevent it

Detection starts with knowing where untrusted data meets object writes. Look for these patterns:

  • Recursive merge, deep clone, deep assign, or set(obj, path, value) helpers that accept user controlled keys or dotted paths like a.b.c.
  • Any spot where JSON.parse output flows straight into a merge or into bracket assignment obj[key] = value.
  • Query string parsers that build nested objects, since ?__proto__[isAdmin]=true is the URL version of the same attack.

To prevent it, treat all three steps as one job: reject dangerous keys at the boundary, validate input against a strict schema, and use prototype free structures (Object.create(null) or Map) for user data. Freeze Object.prototype as a backstop. Keep dependencies patched, because popular merge and path setting libraries have shipped and fixed this exact bug more than once. For a wider view of input driven bugs, see our injection and input writeups, since prototype pollution sits in that family.

Why this bug rewards understanding over pattern matching

Prototype pollution is rarely visible in one file. The write happens in a preferences endpoint and the payoff happens in an auth check two modules away, so a tool that only matches known payloads can miss the link entirely. Finding it means understanding what the app assumes, that a new empty object is truly empty, and then testing whether that assumption holds. This is the kind of assumption gap an autonomous researcher that experiments and verifies is built to find. If you want to see how UnboundCompute approaches that, read more about how it works.

Frequently asked questions

Can prototype pollution lead to remote code execution?

Not on its own, but it can. A polluted property is just a default value until it flows into a sink that treats it as code or config, such as a template engine or a command runner that reads an inherited option. When that happens, the value you thought was contained becomes the input to a dangerous operation, which is why the bug is rated higher than a simple data tampering issue.

What is the difference between __proto__ and constructor.prototype in this attack?

Both are paths that reach the same shared Object.prototype, so polluting through either one affects every plain object in the runtime. __proto__ is a direct accessor for an object’s prototype, while constructor.prototype reaches it by going through the object’s constructor first. A good defense blocks the keys __proto__, constructor, and prototype together rather than just one.

Does using Object.create(null) actually stop prototype pollution?

It removes the prototype chain for that one object, so there is no inherited isAdmin to leak and no __proto__ accessor to abuse on it. It is a strong control for bags of user data, but it does not protect objects elsewhere in your code, so pair it with key filtering and schema validation. See the PortSwigger Web Security Academy guide on prototype pollution for the wider attack surface.

Why do automated scanners often miss prototype pollution?

The write happens in one place, like a preferences endpoint, and the payoff happens in a separate auth or config check that may live in another file. A tool that only matches known payloads against a single response cannot see that the two are connected, so finding the bug means understanding what the app assumes about fresh objects being empty.