Prototype Pollution in JavaScript Applications

Prototype Pollution in JavaScript Applications - article cover image Web Exploitation
Time it takes to read this article 6 minutes.

Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.

Introduction

Prototype pollution is a JavaScript-specific vulnerability class where an attacker manages to add or overwrite properties on Object.prototype (or another built-in prototype) instead of on the intended plain object. Because almost every object in JavaScript inherits from Object.prototype through the prototype chain, a single polluted property becomes visible on *every object in the process* that does not already define that property itself — including objects created long before or after the pollution occurred, in completely unrelated parts of the codebase.

On its own, pollution is often just a denial-of-service bug (crashing code that does not expect an unexpected property). Its real danger is as a primitive: when a polluted property happens to be read by security-sensitive logic — an isAdmin check, a template-engine option, a child_process flag — the consequence escalates to authentication bypass or, via known gadget chains, remote code execution. It affects both Node.js backends and browser front ends, and has been found in widely used libraries such as lodash and in production applications including Kibana.

Attack Prerequisites

Prototype pollution requires a *sink* that writes attacker-controlled keys into an object without filtering dangerous ones, plus a *gadget* that later reads a polluted property in a meaningful way:

  • A recursive merge, clone, or “set nested property by path” functionmerge(), extend(), _.set(), config-loading utilities — that copies keys from attacker-controlled JSON (a request body, query string, uploaded config file) into a target object without excluding __proto__, constructor, or prototype.
  • A code path that parses that JSON into a plain object (JSON.parse) before it reaches the merge function — the pollution itself happens in the merge, not in JSON.parse.
  • A downstream gadget that reads an unexpected property from a fresh object and treats it as trusted — an authorization flag, a template-engine setting, or an option object passed into child_process, a templating engine, or an ORM.

How It Works

Every plain JavaScript object created with {} or new Object() has an internal [[Prototype]] link to Object.prototype, historically exposed as the accessor property __proto__. Setting obj.__proto__.x = 1 does not add x to obj — it adds x to Object.prototype itself, so ({}).x now returns 1 for every plain object in the runtime that doesn’t shadow x with its own property. The same effect can be reached through constructor.prototype (obj.constructor.prototype.x = 1) when __proto__ itself is filtered but the path to the prototype via constructor is not.

A recursive merge function becomes the sink when it iterates for...in (or Object.keys) over an attacker-supplied object and assigns target[key] = source[key] for every key, including the literal string keys "__proto__", "constructor", and "prototype". Because JSON is textual, an attacker doesn’t need special syntax — a request body of {"__proto__":{"isAdmin":true}} parses into an ordinary object whose only own key is the string "__proto__"; the danger appears the moment that key is used to *index* into the target object during the merge, which is interpreted as the special accessor rather than a plain string key.

Exploiting this for more than a crash requires finding a gadget: some later code that does if (obj.isAdmin), templateEngine.render(tpl, {..., options}), or constructs a shell command using an option object whose defaults now include attacker-controlled values because they were never explicitly set on the local object and so fall through to the polluted prototype. Client-side prototype pollution follows the identical mechanism but is typically reached through location.hash/location.search parsing (query-string libraries deep-merging parsed parameters) and chained into DOM XSS.

Vulnerable Code / Configuration

The canonical vulnerable pattern is a hand-rolled deep-merge with no key filtering:

function merge(target, source) {
  for (const key in source) {
    if (source[key] && typeof source[key] === 'object') {
      if (!target[key] || typeof target[key] !== 'object') target[key] = {};
      merge(target[key], source[key]);   // no check for __proto__/constructor
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

// Express route that merges user-supplied settings into app defaults
app.post('/profile/settings', (req, res) => {
  merge(userSettings, req.body);   // req.body is attacker-controlled JSON
  res.send('saved');
});

// POST body:  {"__proto__":{"isAdmin":true}}
// After this request, EVERY plain object in the process — including ones
// created fresh in unrelated request handlers — now has isAdmin === true.
JavaScript

The exact same bug class hides behind popular-looking utility names. Older versions of lodash’s merge/mergeWith/defaultsDeep (fixed for the well-documented CVE-2019-10744) and jQuery’s $.extend(true, ...) deep-copy mode had this exact weakness before they added explicit __proto__/constructor denylists.

Walkthrough / Exploitation

Detection starts with a harmless polluting probe against any endpoint that accepts nested JSON and performs a merge/clone/set-by-path operation:

POST /profile/settings HTTP/1.1
Host: target.example
Content-Type: application/json

{"__proto__":{"pollutedMarker":"yes"}}
HTTP

Then hit any *other*, unrelated endpoint and check whether a fresh object now carries the marker (e.g. via a debug/echo endpoint, or an observable behavior change). Burp’s DOM Invader has a built-in client-side prototype pollution scanner that automates this discovery step in the browser, flagging sources and sinks as the page loads. For server-side hunting, PortSwigger’s Server-Side Prototype Pollution Scanner Burp extension sends a battery of such probes automatically across every JSON parameter.

Once a sink is confirmed, escalate by targeting a real gadget instead of a marker property. A common authorization-bypass payload targets a property the app checks with a plain truthy test:

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

// Later, in a completely different handler:
// if (req.user.isAdmin) { grantAdminPanel(); }
// req.user was created as {} and never set isAdmin itself, so it now
// inherits true from the polluted Object.prototype.
JSON

For remote code execution, the target is a library that passes an internal options object into child_process or a template engine without explicitly setting every option (so defaults are read off the prototype chain). Known public research (including the Kibana prototype-pollution RCE, CVE-2019-7609) chained exactly this: pollution of a template-engine option reached by the Timelion visualization feature led to code execution on the Kibana server.

Note: Object.assign(target, source) is comparatively safe because it only copies own enumerable properties one level deep and does not recurse — but it still triggers pollution if the source’s own key is the literal string "__proto__", because property assignment invokes the accessor. structuredClone() and JSON.parse(JSON.stringify(x)) do not pollute, since they never touch the prototype chain.

Opsec / Testing tip: Prototype pollution is frequently blind — the polluted property never shows up in the response of the request that set it. Use a marker key (__pollution_test__) and probe a second, unrelated endpoint or wait for a process-wide side effect (e.g. a delayed error in logs) rather than relying on the response of the injecting request itself.

Detection and Defense

The fix belongs at the merge/clone boundary, not in downstream gadgets, since you cannot enumerate every future gadget:

  • Deny-list dangerous keys explicitly in any custom merge/clone/set-by-path function — reject or skip __proto__, constructor, and prototype as keys.
  • Use Object.create(null) for objects meant to hold untrusted keys (no prototype to pollute) or use Map instead of a plain object for user-controlled key/value data.
  • Freeze prototypes you don’t need to extend at runtime: Object.freeze(Object.prototype) (and Array.prototype, Function.prototype) makes assignment to inherited properties silently fail in non-strict mode or throw in strict mode.
  • Run Node with --disable-proto=throw (or delete) where feasible, which disables the __proto__ accessor at the engine level.
  • Patch and pin dependencies — lodash, jQuery, and many JSON-schema/config libraries have shipped fixes for exactly this bug class; keep them current.
  • Validate JSON schemas strictly (reject unexpected top-level keys including __proto__) before the payload ever reaches a merge function.

Real-World Impact

Prototype pollution moved from an academic curiosity to a mainstream bug class after researchers demonstrated RCE chains in production software — most notably CVE-2019-7609 in Kibana’s Timelion visualizer — and after CVE-2019-10744 showed the flaw sitting inside lodash, a dependency of a large fraction of the npm ecosystem. It remains a common finding in modern JavaScript/Node code review and bug-bounty programs precisely because merge/clone/set-by-path utilities are everywhere and easy to overlook.

Conclusion

Prototype pollution is a language-level trust problem: JavaScript’s shared prototype chain means one careless merge can leak into every object in the process. Treat any function that recursively copies attacker-controlled keys as a security boundary, deny-list __proto__/constructor/prototype explicitly, prefer Map/Object.create(null) for untrusted data, and keep merge/clone dependencies patched — the sink is cheap to fix; the gadget chain it enables is not.

You Might Also Like

If you found this useful, these related deep-dives cover adjacent techniques and their defenses:

Comments

Copied title and URL