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]=1as setting theadminproperty onObject.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__andconstructor. - Client-side, via the URL. “DOM-based prototype pollution”:
the page reads
location.hashor 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)orif (req.body.role === 'admin')is now reading the pollutedObject.prototype.isAdminfrom 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.spawnuses an options object whoseshellandenvproperties an attacker can pollute. A pollutedObject.prototype.shell = '/bin/sh -c attacker-cmd'turns the next benignspawn()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:
- 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.
- Pollute. Send a payload with
__proto__,constructor, orprototypekeys that walk the merge function up toObject.prototype. - 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
Mapinstead of plain objects.Mapdoesn’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, andprototypeat the trust boundary. A single guard at the input-parsing layer protects every downstream merge call.
Patch the libraries you use.
lodash4.17.12+,qs6.9.7+,minimist1.2.6+,handlebars4.7.7+,object-path0.11.5+. Runnpm auditorpnpm auditand 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.