August 27, 2026
The Bug Bounty Hunter’s Guide to Prototype Pollution
Finding and Breaking .Js prototype for the devs to fix.

By Ajekigbe Michael. A
7 min read
In February 2026, researchers disclosed CVE-2026–27212. A prototype pollution bug in Swiper, a slider library sitting in tens of thousands of production frontends. CVSS 9.4. The interesting part isn't that Swiper had a bug. It's that Swiper already had a fix for this exact class of bug, added years earlier after an older CVE, and the new report showed how to walk straight past it.
That's the thing about prototype pollution. It doesn't retire. It's been showing up in disclosures since 2018, and it's still landing critical CVEs in 2026, in libraries maintained by teams who know exactly what it is.
If you hunt bugs or do web app pentests and you're not actively probing for this, you're leaving a bug class on the table that keeps paying out.
What You're Actually Looking For
Every plain JavaScript object inherits from one shared object: Object.prototype. There's only one of it per running process, browser tab included.
Prototype pollution is what happens when an attacker gets a property written onto that shared object instead of the one it was meant for. Once that happens, every object in the app inherits it, including objects that never touched the attacker's input.
You need three things for this to matter:
- A source. Somewhere the app takes attacker-controlled keys and merges, clones, or sets them into an object. Think
__proto__, or the equivalentconstructor.prototypepath. - A gadget. Some piece of the app that reads a property that's normally undefined, and does something meaningful when it's suddenly set.
user.isAdmin.options.execArgv.config.NODE_OPTIONS. - A sink. Where that gadget's behavior actually causes damage. A DOM write, an
ifstatement guarding access, a call tospawn().
No gadget, no impact. This is the part people skip, and it's why so many "I found prototype pollution" reports get closed as informational. Finding the source is the easy 20%. The gadget is the bug.
Where It Actually Hides
You'll find sources almost anywhere an app deep-merges attacker input into an object:
- Config and options merging.
Object.assign, customextend()/merge()helpers, CLI tools that layer a user config file on top of defaults. - Query string parsers. Libraries that expand
a[b][c]=dsyntax into nested objects.qsis the classic example. - JSON body parsing in Express-style apps, especially anywhere that does
merge(defaultUser, req.body)instead of picking specific fields. - Clone and defaults functions. Lodash's
_.merge,_.defaultsDeep, and even_.zipObjectDeephave all shipped pollution CVEs at different points (CVE-2019-10744, CVE-2020-8203, CVE-2025-13465). Same root cause, three separate patches, years apart. - Client-side widget libraries that expose an
extendDefaults-style API, the Swiper case being the most recent example. - Template engines and file upload/config-import features. Kibana alone has had prototype pollution CVEs land in 2019, 2020, 2024, and 2025, each time through a different feature (Timelion, TSVB, the Upgrade Assistant, integration file uploads).
Notice the pattern. It's rarely one team being careless. It's the same design flaw resurfacing in a new corner of the codebase every time a new feature does an "unsafe merge."
Why It's Actually Dangerous
The impact ladder runs wider than people expect:
Denial of service. No gadget needed. Just breaking something the app depends on, like Array.prototype.indexOf, is enough to crash requests app-wide.
Client-side XSS. If the pollution reaches a DOM sink like innerHTML, you get script execution without ever touching the app's actual XSS filters, because you never went through the normal input path at all.
Authentication and authorization bypass. PortSwigger's Web Security Academy lab for this is a good mental model: pollute Object.prototype.isAdmin = true, and every user object in the app suddenly answers isAdmin as true, because nobody explicitly set it to false on their own object.
Remote code execution. This is where it gets serious. The 2023 USENIX paper "Silent Spring" found working RCE gadgets in Node's own standard library and in high-profile apps including NPM CLI, Parse Server, and Rocket.Chat, just by polluting properties that later reach require() or a child_process call. Sonar's writeup on Blitz.js chains three separate gadgets, ending in a polluted NODE_OPTIONS-style argument getting passed into a spawned process. PortSwigger's own RCE lab pollutes execArgv so a spawned Node child process runs an attacker's --eval argument.
None of that requires a second vulnerability to set up, unlike the more contrived denylist-bypass cases. If the app hands attacker JSON straight into an unsafe merge and a gadget exists downstream, that's often a direct, one-shot path to code execution.
Why It Keeps Coming Back
The root cause is a language feature, not a typo. __proto__ acts as a live accessor for an object's prototype. Any code that blindly copies keys from an untrusted object into another one will happily copy that accessor too, unless it's explicitly filtered.
And "explicitly filtered" is where teams keep getting it wrong. The usual first fix is a denylist: block keys named __proto__, constructor, prototype. That's exactly what Swiper had, and it's exactly what CVE-2026-27212 got past, by overriding Array.prototype.indexOf (the method Swiper's filter relied on to check the denylist) so the check silently stopped working.
The lesson for both sides of this: a denylist check is only as trustworthy as the built-in methods it depends on. If your security check calls a method that lives on a prototype an attacker could theoretically reach first, you've built your lock out of the same material as the door.
Why It Keeps Coming Back
The root cause is a language feature, not a typo. __proto__ acts as a live accessor for an object's prototype. Any code that blindly copies keys from an untrusted object into another one will happily copy that accessor too, unless it's explicitly filtered.
And "explicitly filtered" is where teams keep getting it wrong. The usual first fix is a denylist: block keys named __proto__, constructor, prototype. That's exactly what Swiper had, and it's exactly what CVE-2026-27212 got past, by overriding Array.prototype.indexOf (the method Swiper's filter relied on to check the denylist) so the check silently stopped working.
The lesson for both sides of this: a denylist check is only as trustworthy as the built-in methods it depends on. If your security check calls a method that lives on a prototype an attacker could theoretically reach first, you've built your lock out of the same material as the door.
How to Actually Test For It
Start black box, manually, before reaching for tools. Send a request with a polluting parameter and check whether it lands globally:
GET /some/endpoint?__proto__[pollutedTest]=yes123GET /some/endpoint?__proto__[pollutedTest]=yes123or, for JSON bodies:
json
{"__proto__": {"pollutedTest": "yes123"}}{"__proto__": {"pollutedTest": "yes123"}}Then hit an entirely unrelated endpoint or page and check for the value on a fresh object. In a browser console, that's just:
js
({}).pollutedTest({}).pollutedTestIf constructor[prototype][x]=y works where __proto__ doesn't (some parsers strip the literal __proto__ string but not the equivalent path), try that variant too.
For client-side testing, use DOM Invader in Burp Suite Pro. Enable it in the DOM Invader settings, reload the page, and it'll auto-detect pollution sources in the URL and in postMessage payloads, then scan for gadgets that reach a DOM sink. When it finds one, it builds the exploit for you and confirms it by popping alert() in a new window. It's genuinely the fastest way to go from "maybe" to "confirmed" on the client side.
For automation and CI-style scanning, ppmap and ppfuzz fingerprint gadgets and generate exploit payloads without needing a full Burp Pro license. The PPScan browser extension does something similar passively as you browse.
Server-side is the harder half. You can't just watch the DOM. PortSwigger's black-box methodology (and Gareth Heyes' writeup on detecting it without triggering a denial of service) focuses on timing and behavioral differences: pollute a property, then look for a response that changed in a way you can only explain by that property existing somewhere it shouldn't. Burp's server-side-prototype-pollution extension automates a lot of this probing.
Once you have a confirmed source, go hunting for the gadget by reading, not guessing. Pull the app's dependency tree and grep for:
- Recursive merge or clone helpers touching request data
- Anywhere
child_process,spawn,exec, orrequiregets called with values that trace back to config objects - Template engine option reads
- Anything reading
process.envequivalents from a config object that itself came from user input
The source-to-gadget-to-sink chain is the whole report. If you can only show the source, say so honestly in your writeup and flag it as such, don't imply full impact you haven't demonstrated. Triagers see through that fast, and it burns trust for your next submission.
Fixing It (What to Put in Your Remediation Section)
For your report, give devs something more useful than "upgrade the library":
Freeze the prototype where you can. Object.freeze(Object.prototype) blocks almost every exploitation path outright. The tradeoff: if any dependency legitimately extends built-in prototypes, this can break things, so it needs testing, not blind rollout.
Use Object.create(null) for any object that will hold untrusted keys. No prototype chain, nothing to pollute.
Prefer Map over plain objects for user-supplied key/value data. Map doesn't inherit from Object.prototype at all.
Validate input against a strict schema. With ajv, setting additionalProperties: false rejects any key, including __proto__, that isn't explicitly expected.
If a recursive merge is unavoidable, filter dangerous keys with direct string comparison, not a method that lives on a mutable prototype. key === "__proto__" || key === "constructor" || key === "prototype" doesn't care if someone's tampered with Array.prototype.indexOf upstream. This is the exact lesson from the Swiper bypass: don't build your filter on top of something an attacker might get to first.
Patch on a schedule, not a panic. This bug class doesn't show up once and get fixed. Lodash has shipped patches for it across seven years. Kibana has shipped them across five. Treat it as a recurring category in your dependency review, not a one-time checkbox.
The Conclusion Most Writeups Skip
A pollution source without a gadget is a curiosity, not a finding. If you're building a portfolio piece or a bug bounty report around this, the difference between "I set a property on Object.prototype" and "I turned that into an auth bypass" is the entire value of the submission. Spend your time there.
Have you ever come across a ptototype pollution vulnerable? Share your exprience in the comments.