GraphNode
Back to all posts
AppSec

Prototype Pollution: A JavaScript Bug Class That Reaches Far Beyond the Browser

| 11 min read |GraphNode Research

In July 2019, Snyk's research team published an advisory against lodash that would eventually carry the identifier CVE-2019-10744 and a CVSS score of 9.1. The vulnerable function was defaultsDeep, a utility that merged a partial configuration object into a defaults object by walking both trees recursively. The researchers showed that passing a payload of {"constructor": {"prototype": {"isAdmin": true}}} through the function caused lodash to walk into the constructor's prototype property and write isAdmin onto Object.prototype itself. Every subsequent object literal in the running Node.js process — every request body, every database row, every internal config — silently inherited isAdmin: true. lodash was at the time installed in over four million packages on npm and downloaded roughly fifty million times a week, which meant the affected surface was effectively the JavaScript ecosystem. The patch landed within days, but the bug class it exposed — first formalized a year earlier by Olivier Arteau in his 2018 NorthSec talk "Prototype pollution attacks in NodeJS applications" — turned out to live in dozens of other libraries, in framework internals, and in hand-rolled deep-merge functions across thousands of production codebases.

Prototype pollution sits in an awkward category. It is JavaScript-specific because no other mainstream language has the prototype chain at the heart of its object model. It is also dangerous in ways most language-specific bugs are not, because a single write to Object.prototype reaches every object in the runtime and can flip authorization booleans, inject template strings, or trigger downstream gadget chains that escalate to remote code execution. This article walks through how prototype pollution actually works at the language level, what vulnerable and fixed code looks like, where the bug class reaches the server and the browser, and the small set of structural defenses that close it for good.

What Prototype Pollution Actually Is

Every object in JavaScript has an internal slot pointing at another object called its prototype. When you read a property that the object does not own, the engine walks up the prototype chain and returns the value it finds on the first ancestor that has it. Object literals — the {} you write a hundred times a day — all share the same root prototype, Object.prototype. The chain is mutable at runtime through two related accessors: the legacy __proto__ property, exposed on every object, and the modern Object.getPrototypeOf/Object.setPrototypeOf pair. Writing obj.__proto__.isAdmin = true on any object literal does not modify that object; it modifies Object.prototype, and from that moment on every other object literal in the runtime that does not own an isAdmin property reports isAdmin: true when read.

Prototype pollution is the bug pattern in which user-controlled input reaches a code path that uses a string key to assign into an object, the input includes the key __proto__ or the path constructor.prototype, and the assignment ends up walking up the prototype chain and writing onto Object.prototype. The most common vector is a deep-merge or deep-set helper that recursively descends into nested objects without checking whether each step lands on the prototype. The dangerous primitive is not just "the attacker can set a property on one object" — it is "the attacker can set a property that every other object in the process will then inherit," which is a far more interesting capability than it sounds.

The Express Handler That Pollutes Your Whole Process

The textbook vulnerable shape is a configuration update endpoint that accepts a JSON body and merges it into a server-side config object using lodash's _.merge. Variants of this pattern shipped in countless internal admin tools, feature-flag services, and tenant-config endpoints throughout the late 2010s, and CVE-2018-3721 — the original lodash prototype pollution issue, also reported by HoLyVieR — covered exactly this class of usage:

// VULNERABLE
const express = require('express');
const _ = require('lodash');

const app = express();
app.use(express.json());

const serverConfig = { theme: 'light', features: {} };

app.post('/api/config', (req, res) => {
  _.merge(serverConfig, req.body);
  res.json({ ok: true });
});

A pre-patch version of lodash, fed a body of {"__proto__": {"isAdmin": true}}, walks into serverConfig.__proto__ — which is Object.prototype — and writes isAdmin: true onto it. Every authorization check in the application that relies on a default-falsy isAdmin field on a fresh object literal now returns true. The fix is layered: pin lodash to a patched version, validate the input shape, and treat the merge target as a structurally safe object that has no prototype to pollute in the first place:

// FIXED
const express = require('express');
const _ = require('lodash');

const app = express();
app.use(express.json());

const ALLOWED_KEYS = new Set(['theme', 'features']);
const serverConfig = Object.assign(Object.create(null), {
  theme: 'light',
  features: Object.create(null),
});

app.post('/api/config', (req, res) => {
  const body = req.body;
  if (body === null || typeof body !== 'object') {
    return res.status(400).json({ error: 'invalid body' });
  }
  for (const key of Object.keys(body)) {
    if (!ALLOWED_KEYS.has(key)) continue;
    if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
    serverConfig[key] = body[key];
  }
  res.json({ ok: true });
});

Three things change. The merge target is constructed with Object.create(null), which produces an object whose prototype is null; there is no __proto__ accessor to walk into and no Object.prototype reachable from this object. The merge becomes a shallow copy gated by an explicit allowlist of expected keys, removing the recursion that turned the original code into a sink. And the dangerous keys are filtered defensively even inside the allowlist branch, because a future contributor expanding ALLOWED_KEYS should not silently re-open the hole. Pinning lodash to a patched release closes the immediate primitive but does not address the next deep-merge utility someone introduces.

The Hand-Rolled Deep-Merge That Ships Everywhere

Not every prototype pollution sink lives in lodash. CVE-2020-7598 covered minimist, the argument parser used by tens of thousands of npm packages, where parsing a crafted command-line argument like --__proto__.polluted=true set a property on Object.prototype. CVE-2022-21824 covered Node.js core itself, where the console module's option-merging code mishandled a polluted prototype and crashed the process. The common shape across all of these is a recursive helper that takes a string key, indexes into the target object with bracket notation, and recurses into the resulting value if it is itself an object. The vulnerable hand-rolled version is shorter than the fix:

// VULNERABLE
function deepMerge(target, source) {
  for (const key in source) {
    if (typeof source[key] === 'object' && source[key] !== null) {
      if (!target[key]) target[key] = {};
      deepMerge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

Two flaws sit in this code. The for...in loop iterates inherited properties as well as own properties, which means any pollution of Object.prototype by a previous request feeds back into this loop. The bracket assignment target[key] = source[key] happily writes when key is the string "__proto__". Both flaws are closed in the fixed version with own-property checks and an explicit dangerous-key denylist:

// FIXED
const FORBIDDEN = new Set(['__proto__', 'constructor', 'prototype']);

function safeDeepMerge(target, source) {
  if (source === null || typeof source !== 'object') return target;
  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)) {
      if (!Object.prototype.hasOwnProperty.call(target, key)) {
        target[key] = Object.create(null);
      }
      safeDeepMerge(target[key], value);
    } else {
      Object.defineProperty(target, key, {
        value, writable: true, enumerable: true, configurable: true,
      });
    }
  }
  return target;
}

Object.keys returns only own enumerable keys, which removes the inherited-property feedback loop. The FORBIDDEN denylist closes the obvious vectors. New nested objects are constructed with Object.create(null) so that subsequent recursion into them cannot reach a prototype. Object.defineProperty writes the value as a plain data descriptor without invoking any inherited setter that pollution might have planted. The fixed version is roughly twice the line count, which is precisely the reason the vulnerable version keeps shipping.

The Payload That Becomes a Privilege Escalation

The mechanic is easier to follow with the actual payload and a downstream check in front of you. Here is the sequence the attacker runs against the vulnerable Express handler above, followed by the gadget that turns it into a privilege escalation in code that has no idea it was ever attacked:

// 1. Attacker sends this body to POST /api/config
{
  "__proto__": { "isAdmin": true }
}

// 2. Some unrelated handler later in the request lifecycle
app.get('/api/admin/users', (req, res) => {
  const session = {}; // fresh object literal, no isAdmin set
  if (session.isAdmin) {              // reads from Object.prototype now
    return res.json(getAllUsers());
  }
  res.status(403).json({ error: 'forbidden' });
});

The attacker never authenticates. The session object is freshly minted by the application code, defaults to no admin field, and would normally fail the check. After pollution, every fresh object literal in the process inherits isAdmin: true until the process is restarted. The same primitive flips template-engine flags that switch a renderer into an unsafe mode, injects properties that downstream gadgets read to choose a function to invoke (the classic prototype-pollution-to-RCE chain in libraries like handlebars and certain Express render setups), and bypasses validation libraries that check for the absence of a property rather than its explicit value.

Why Prototype Pollution Reaches the Server

Three structural patterns in Node.js applications keep prototype pollution sinks plentiful. The first is configuration loading. Applications routinely deep-merge a default config with environment-specific overrides, then with feature flags fetched from a service, then with per-request overrides from the body of an admin endpoint. Every one of those merges is a candidate sink, and most use a generic helper rather than a hand-validated copy. The second is ORM and ODM model construction. Mongoose, Sequelize, and similar libraries take a JSON body and assign its keys onto a model instance; older versions of several ORMs would dutifully assign __proto__ onto the model, which then propagated into the prototype chain when the model was serialized back out. The third is GraphQL resolvers and middleware that read a JSON variables object and pass it to a generic input-mapper that walks the keys recursively to construct a typed input object. The pattern is the same whenever a JSON-shaped input fans out into a recursive keyed assignment over a server-side object.

Beyond Node: Prototype Pollution as DOM XSS

The bug class is not confined to Node.js. Snyk's 2018 disclosure against jQuery's $.extend(true, ...) showed that the same pattern existed in the browser, and a series of follow-up findings against client-side template engines and routing libraries demonstrated that polluting Object.prototype in the browser can plant attacker-controlled values that the framework subsequently reads when rendering markup. The chain typically runs: an attacker-controlled URL parameter or fragment is parsed into an object using a vulnerable parser; a known sink in a client-side template library reads a property from a fresh object that, post-pollution, returns an attacker-controlled string; the string is interpolated into HTML through an unsafe pathway and executes as DOM XSS. Browser-side prototype pollution is harder to reach than the server-side version but it survives a page reload through the URL and persists across the entire SPA's runtime, which is a different and arguably worse blast radius than a single XSS payload.

Detection: Where Each Layer Earns Its Keep

Static analysis catches prototype pollution by tracing user-controlled JSON input — the req.body of an Express handler, the variables field of a GraphQL resolver, the parsed query string of a router — through to a known recursive-merge sink: _.merge, _.defaultsDeep, _.set, $.extend(true, ...), hand-rolled deepMerge functions, and the long tail of similar helpers across npm. SAST also flags the structural shapes that produce custom sinks: a for...in loop that assigns target[key] = source[key] without filtering, a recursive function that takes target and source parameters and indexes both with the same key. SCA flags vulnerable versions of lodash, minimist, and the dozens of other libraries with documented prototype pollution CVEs in the dependency tree, including transitive dependencies that the application code never imports directly.

DAST submits the canonical payloads — {"__proto__": {"polluted": "yes"}}, {"constructor": {"prototype": {"polluted": "yes"}}} — against every JSON-accepting endpoint and watches for either a reflected indicator in subsequent responses or a side-channel signal like a flipped feature flag. The cheapest of the three layers for prototype pollution specifically is SCA, because the vast majority of real-world incidents trace back to an unpatched version of a well-known library; the cheapest layer for preventing the next custom sink is SAST, because it runs on the diff that introduces the vulnerable recursive merge. See why data flow analysis matters for the inter-procedural taint mechanics that follow a JSON body through several function boundaries before it reaches the merge sink.

Prevention Checklist

Six rules close the overwhelming majority of real-world prototype pollution. Apply them in order; each later rule assumes the earlier ones are in place.

  • Use Object.create(null) for any object that is built up from user-controlled keys. Config maps, request-scoped contexts, and lookup tables built from JSON should all start from a null-prototype object so there is no prototype chain to pollute.
  • Freeze Object.prototype at process startup. Calling Object.freeze(Object.prototype) before any third-party code runs makes the root prototype immutable and turns every pollution attempt into a thrown error in strict mode. The tradeoff is breakage in poorly written libraries that monkey-patch Object.prototype; in practice the surface is small and worth auditing once.
  • Use libraries with explicit own-property guards. Patched lodash (4.17.21+), recent minimist, and purpose-built helpers like safe-stable-stringify ship the right defaults; older versions and unmaintained alternatives should be replaced rather than shimmed.
  • Validate input shape with JSON Schema before any merge. Reject bodies that contain __proto__, constructor, or prototype keys at the edge with a schema validator like Ajv configured with strict mode, before the data ever reaches the merge sink.
  • Gate vulnerable library versions in CI with SCA. A pull request that introduces lodash <4.17.21 or minimist <1.2.6, including transitively, should fail the build and not generate a Dependabot ticket six months later.
  • Never deep-merge raw user input into a long-lived server-side object. If you find yourself reaching for a recursive merge over req.body, take a step back and write the explicit allowlist instead. The recursion is what creates the sink.

Where GraphNode Fits

GraphNode SAST traces user-controlled JSON input through Express, Fastify, NestJS, and GraphQL handlers into recursive merge sinks across lodash, minimist, jQuery, and hand-rolled helpers, flagging the specific lines where the sink is reachable from untrusted input. GraphNode SCA catches the dependency side, surfacing vulnerable lodash, minimist, and downstream library versions in direct and transitive dependencies before they merge to main. Prototype pollution is most often classified under A08 Software and Data Integrity Failures when the root cause is an unpatched library, and findings surface on the diff that introduced the vulnerable code or the vulnerable upgrade.

Closing

Prototype pollution is a bug class the JavaScript ecosystem talked itself into by shipping a deeply mutable language and then handing it utility functions that recurse over arbitrary keys without checking where they land. The fix at the language level — null-prototype objects, frozen Object.prototype, own-property guards — has been documented since the 2018 Arteau paper and is implemented in every modern best-practices guide. The bug keeps shipping anyway because the vulnerable shape is shorter than the safe one, the dangerous keys look like ordinary strings, and the blast radius reaches every object literal in the process. Move the detection upstream into the diff, gate the library versions in CI, and write the explicit allowlist instead of the recursive merge. Eight years after the bug class was named, that is still the only place the economics work in the defender's favor.

GraphNode SAST and SCA flag prototype pollution sinks and vulnerable library versions across your JavaScript stack — request a demo.

Request Demo