blueredix logo
info vuln-class-prototype-pollution

Prototype pollution, in plain English

A JavaScript-specific bug class where attacker-controlled input rewrites properties on the global Object prototype, infecting every object the application creates. The path from a single bad merge to authentication bypass and remote code execution.

What this is

Every object in JavaScript inherits properties from a hidden parent called its prototype. Plain objects ({}) inherit from Object.prototype, the root of the chain. Prototype pollution is what happens when an attacker can write a property onto that root prototype: every object the application has now or will create inherits the attacker-controlled property.

The setup is almost always the same. The application takes structured input from the user (JSON body, query string, form data) and merges it into an existing object using a recursive helper. The merge function doesn’t reject special keys, so a payload like

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

ends up writing isAdmin: true onto Object.prototype itself. Two seconds later, a totally unrelated check elsewhere in the application asks “is this user an admin?”, and gets true, because that property is now visible on every object.

Where this comes from

The pattern lives in a handful of widely-used Node.js libraries and patterns:

  • Recursive deep-merge / extend / set-by-path utilities. lodash.merge, lodash.set, lodash.defaultsDeep, object-path, set-value, mixin-deep, dot-prop. Each has had a published CVE: lodash CVE-2018-3721, CVE-2019-10744; minimist CVE-2020-7598; set-value CVE-2019-10747; and so on for years.
  • Permissive query-string parsers. qs (the parser Express used by default for years) treats ?__proto__[admin]=1 as setting the admin property on Object.prototype. CVE-2022-24999 is the Express variant that ended up patching this.
  • Hand-rolled “merge user options” helpers. The pattern is so natural that engineers reinvent it constantly: walk the input, copy keys onto a target, recurse into nested objects. Nine times out of ten the implementation forgets to filter __proto__ and constructor.
  • Client-side, via the URL. “DOM-based prototype pollution”: the page reads location.hash or a query parameter, parses it as JSON, and merges it into a config object. The attacker doesn’t need a server bug at all.

The bug isn’t unique to lodash. It’s a property of JavaScript itself combined with code that doesn’t expect attacker-influenced keys.

Why it matters

Prototype pollution by itself sets a property on Object.prototype. Whether that turns into a full incident depends on what else in the codebase reads that property. The standard escalation paths are:

  • Authentication or authorisation bypass. Code that does if (user.isAdmin) or if (req.body.role === 'admin') is now reading the polluted Object.prototype.isAdmin from any object that doesn’t shadow it. Several CVEs in CMS frameworks and SaaS admin panels follow this exact pattern.
  • Remote code execution. Node’s child_process.spawn uses an options object whose shell and env properties an attacker can pollute. A polluted Object.prototype.shell = '/bin/sh -c attacker-cmd' turns the next benign spawn() call elsewhere in the app into RCE. Same trick with template engines (Handlebars CVE-2021-23383), with body parsers, and with package install hooks.
  • Cross-site scripting. Client-side prototype pollution often pollutes the configuration of an HTML sanitiser or a templating helper. The page renders attacker-controlled HTML on the next request because the polluted property silently turns sanitisation off.
  • Crash / availability. A polluted property can break invariants that downstream code depends on (e.g. Object.prototype.toString = "..."), and the application starts throwing exceptions on every request.

How attackers actually exploit it

The exploitation chain has three stages:

  1. Find a sink. A code path that walks attacker-controlled input and writes it into an object: a JSON merge endpoint, a settings import, a profile save.
  2. Pollute. Send a payload with __proto__, constructor, or prototype keys that walk the merge function up to Object.prototype.
  3. Trigger the gadget. Find a downstream code path that reads the property you just polluted and treats its truthiness or value as authoritative.

Steps 1 and 3 are the work; step 2 is the same payload across every target. Public exploit databases catalogue dozens of “polluted property X → escalation Y” gadgets per popular framework.

How it gets fixed

Three layers, applied together:

Use the right data structure for attacker input.

  • For dictionary-shaped data, use Map instead of plain objects. Map doesn’t go through the prototype chain, so polluting one doesn’t affect the others.
  • For object literals that need to be safe by construction, use Object.create(null) to create an object with no prototype.
  • Reject keys named __proto__, constructor, and prototype at the trust boundary. A single guard at the input-parsing layer protects every downstream merge call.

Patch the libraries you use.

  • lodash 4.17.12+, qs 6.9.7+, minimist 1.2.6+, handlebars 4.7.7+, object-path 0.11.5+. Run npm audit or pnpm audit and resolve advisories tied to prototype pollution; nearly every popular utility library has shipped a fix.

Freeze the prototype.

  • A defence-in-depth measure: at application startup, run Object.freeze(Object.prototype). This prevents any code (including buggy dependencies you haven’t audited) from writing onto the prototype. It can break libraries that monkey-patch the prototype, so test thoroughly before enabling.

For client-side code, the same advice plus: don’t JSON.parse or deep-merge attacker-controlled data into your own configuration objects. Treat location.hash and query parameters as untrusted at the boundary.

How blueredix surfaces prototype pollution

The scanner reports CVEs in third-party JavaScript libraries whose description names prototype pollution, and detects vulnerable versions of well-known offenders (lodash, minimist, qs, handlebars) included via <script> tags or visible in source maps. We don’t actively send __proto__ payloads against your application; that is penetration-testing work, not automated scanning.

Further reading