Prototype Pollution: From Merge Helpers to RCE Gadgets
A single __proto__ key in attacker JSON can corrupt every object in a Node app. We trace client and server pollution to real impact and the fixes.
Prototype pollution is a JavaScript flaw where attacker input injects properties into Object.prototype — the object every other object inherits from — so a single __proto__ key can flip security flags, inject config, or chain to RCE across the whole process. This is how to find the polluting sink and prove global impact.
Finding it
The danger lives in code that recursively copies attacker-controlled keys into an object: deep merge, deep clone, extend, and config/query-string parsers are the usual suspects. The magic keys are __proto__, constructor, and prototype.
Where to hunt:
- JSON request bodies merged into options objects (
Object.assignloops,lodash.merge, custommerge). - Query-string parsers that build nested objects from
a[b][c]=dsyntax. - Config loaders that deep-merge user-supplied settings.
- Client-side: URL params or
postMessagedata fed into a merge, then read by the page.
Grep the source for merge, extend, clone, defaultsDeep, and [key] = inside a recursion. In black-box mode, send a body with a __proto__ payload and then look for a behavioral change anywhere in the app. For client-side flows, the ppmap and pp-finder tools fuzz URL/JSON entry points for the magic keys and report which reach a sink:
ppmap -u "https://app.example.com/?q=1" # probes params for prototype pollution
Proof of concept
The minimal server-side detection sends a polluting body and then checks whether an unrelated default changed. Here is the vulnerable pattern and the proof it pollutes globally:
function merge(target, src) {
for (const k in src) {
if (typeof src[k] === "object" && src[k] !== null) {
target[k] = target[k] || {};
merge(target[k], src[k]);
} else {
target[k] = src[k]; // assigning __proto__.X pollutes the prototype
}
}
}
merge({}, JSON.parse('{"__proto__":{"polluted":"yes"}}'));
console.log(({}).polluted); // "yes" -> every object is now affected
Against a live API, send the payload and probe a separate endpoint for the injected property:
POST /api/settings HTTP/1.1
Host: app.example.com
Content-Type: application/json
{"__proto__": {"isAdmin": true}}
Then call an endpoint that reads user.isAdmin off a plain object it just built — if it now treats you as admin, the pollution stuck and crossed request boundaries. A reliable black-box oracle is to pollute a property the framework reflects, e.g.:
POST /api/x HTTP/1.1
Content-Type: application/json
{"__proto__": {"status": 510}}
If a subsequent unrelated request suddenly returns HTTP 510, the prototype is polluted process-wide — a clean, observable confirmation with no source access.
On the client side, pollute via the URL and trigger a DOM-XSS gadget:
https://app.example.com/?__proto__[src]=data:,alert(document.domain)//
If a library later reads config.src off a plain object to build a script element, your value becomes the script source.
Going further
Pollution by itself only sets a property; impact needs a gadget — existing code that reads a property off a plain object and does something dangerous. Hunt these specifically:
- A child-process spawn that reads options (
shell,NODE_OPTIONS,env) off a polluted object -> RCE. - A template engine that reads
options.outputFunctionNameor similar -> code injection. - An HTML sink that reads a polluted attribute -> XSS.
A potent server-side chain pollutes NODE_OPTIONS to --require a malicious file, or sets an EJS/Pug option that becomes evaluated code. Demonstrate the strongest gadget your target's dependency tree exposes; because the polluted prototype is shared by every object everywhere, the eventual sink is often in code the original author never imagined was reachable. Capture the polluting request, the cross-request/global oracle, and the gadget's effect. Test only authorized targets.