Prototype Pollution in JavaScript and Node.js: Exploitation and Prevention

Prototype pollution lets attackers inject properties into JavaScript's Object.prototype, affecting every object in the application. It escalates from data corruption to remote code execution via gadget chains in popular frameworks. This guide covers how it works, exploitation paths, and prevention patterns for Node.js and frontend applications.

Prototype pollution is a JavaScript-specific vulnerability class that emerges from the language’s prototype-based inheritance model. An attacker who can pollute Object.prototype — the root prototype from which almost every JavaScript object inherits — can inject properties that appear on objects across the entire application. Depending on how those properties are used downstream, the impact ranges from application logic corruption to remote code execution.

The vulnerability is more common than its name suggests. Most JavaScript applications include at least one function that recursively merges user-controlled data into objects without sanitisation — and that’s typically enough.

How Prototype-Based Inheritance Works

Every JavaScript object has an internal [[Prototype]] link pointing to another object. When you access a property on an object, JavaScript walks up the prototype chain until it finds the property or hits null at the top:

const obj = {};
console.log(obj.toString); // found on Object.prototype
console.log(obj.__proto__ === Object.prototype); // true
console.log(obj.__proto__.__proto__); // null (end of chain)

The special keys __proto__, constructor, and prototype can be used to traverse or modify this chain. When user-controlled input reaches a merge or assignment operation that processes these keys, the attacker can write to Object.prototype itself.

The Vulnerability Pattern

Vulnerable Deep Merge

This pattern appears in countless libraries and utility functions:

// VULNERABLE: naive recursive merge
function merge(target, source) {
  for (const key in source) {
    if (typeof source[key] === 'object' && source[key] !== null) {
      if (!target[key]) target[key] = {};
      merge(target[key], source[key]);  // recurse
    } else {
      target[key] = source[key];  // direct assignment
    }
  }
  return target;
}

// Attacker sends: {"__proto__": {"isAdmin": true}}
const userInput = JSON.parse('{"__proto__": {"isAdmin": true}}');
merge({}, userInput);

// Now EVERY object in the application has isAdmin === true
const newObj = {};
console.log(newObj.isAdmin); // true — polluted!

The for...in loop iterates over __proto__ as a key. The recursive call hits merge(target["__proto__"], {isAdmin: true}) — which writes isAdmin directly onto Object.prototype.

Other Vulnerable Patterns

// Object.assign does NOT cause prototype pollution (it skips __proto__)
// But path-based property setters do:

function setPath(obj, path, value) {
  const parts = path.split('.');
  let current = obj;
  for (let i = 0; i < parts.length - 1; i++) {
    current = current[parts[i]];  // VULNERABLE if parts[i] is "__proto__"
  }
  current[parts[parts.length - 1]] = value;
}

// Attacker input: path="__proto__.isAdmin", value=true
setPath({}, '__proto__.isAdmin', true);

Libraries that have historically been vulnerable: lodash (merge/set — patched in 4.17.12+), jquery (deep extend — patched), hoek, deep-extend, mixin-deep, qs. Check your dependency tree.

Exploitation Paths

Logic Bypass

The simplest impact: injecting a truthy property that authentication or authorization logic checks:

// Application code
if (user.isAdmin) {
  grantAccess();
}

// After pollution with {"__proto__": {"isAdmin": true}}
// Every user passes this check, including unauthenticated requests
const anonymousUser = {};
if (anonymousUser.isAdmin) {  // true — inherited from Object.prototype!
  grantAccess();
}

Remote Code Execution via Gadget Chains

Prototype pollution escalates to RCE when combined with code paths that use the polluted property in a dangerous way. In Node.js environments, several framework-level gadget chains have been documented:

Ejs template engine gadget (pre-patch):

// Pollution:
// {"__proto__": {"outputFunctionName": "process.mainModule.require('child_process').execSync('id')//"}}

// When ejs renders any template, it uses outputFunctionName internally.
// The injected value causes it to execute the attacker's shell command.
const ejs = require('ejs');
ejs.render('<%= user %>', { user: 'test' });
// → executes id command on the server

Handlebars gadget (pre-patch):

// {"__proto__": {"__lookupGetter__": ..., "escapeExpression": ...}}
// Enables access to __defineGetter__ which leads to function construction
// with arbitrary code execution

Modern versions of ejs and Handlebars have patched their gadgets, but the pattern shows why prototype pollution is considered a critical severity in server-side JavaScript: the impact depends on what gadgets exist in the dependency tree, and most applications have enough dependencies to produce a usable chain.

Tool: pp-finder

The pp-finder tool can scan a Node.js project for potential prototype pollution gadgets in installed packages:

npx pp-finder --scan ./node_modules

Detection

Static Analysis

Semgrep rules for detecting unsafe merge patterns:

rules:
  - id: prototype-pollution-merge
    patterns:
      - pattern: |
          for (var $KEY in $SRC) {
            $DST[$KEY] = $SRC[$KEY];
          }
      - pattern-not: |
          if ($KEY === '__proto__' || ...) { continue; }
    message: "Potential prototype pollution in object merge — add key sanitisation"
    languages: [javascript, typescript]
    severity: WARNING
    
  - id: prototype-pollution-set-path
    pattern: |
      $OBJ[$KEY] = $VALUE
    pattern-inside: |
      function $F(...) {
        ...
        var $PARTS = $PATH.split('.');
        ...
      }
    message: "Path-based property setter may be vulnerable to prototype pollution"
    languages: [javascript]
    severity: WARNING

Runtime Detection

// Detect prototype pollution at runtime (for monitoring / WAF)
const originalProto = Object.getPrototypeOf({});
const originalKeys = Object.getOwnPropertyNames(originalProto);

function checkPrototypePollution() {
  const currentKeys = Object.getOwnPropertyNames(Object.prototype);
  const injected = currentKeys.filter(k => !originalKeys.includes(k));
  if (injected.length > 0) {
    console.error('PROTOTYPE POLLUTION DETECTED:', injected);
    // Alert, log, or throw
  }
}

// Run periodically or wrap around merge operations
setInterval(checkPrototypePollution, 5000);

Prevention

1. Use Safe Merge Functions

// SAFE: check for dangerous keys before merging
function safeMerge(target, source) {
  const DANGEROUS_KEYS = ['__proto__', 'constructor', 'prototype'];
  
  for (const key of Object.keys(source)) {  // Object.keys() skips __proto__
    if (DANGEROUS_KEYS.includes(key)) {
      continue;  // skip dangerous keys entirely
    }
    if (typeof source[key] === 'object' && source[key] !== null) {
      if (!target[key]) target[key] = {};
      safeMerge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

Object.keys() is safer than for...in because it only returns own enumerable properties, not inherited ones — but it still returns '__proto__' if the key was explicitly set. The explicit deny-list is still needed.

2. Use Object.create(null) for Accumulator Objects

// Objects created with null prototype cannot be polluted
// and don't inherit from Object.prototype
const safeConfig = Object.create(null);
safeConfig.debug = false;
safeConfig.admin = false;

// __proto__ injection against Object.create(null) has no effect
// because there's no prototype chain to walk up

Use Object.create(null) for objects that accumulate untrusted properties (parsed query strings, JSON payloads used as maps).

3. JSON Schema Validation Before Merge

Validate the structure of user input before it touches any merge operation:

import Ajv from 'ajv';
const ajv = new Ajv();

const userInputSchema = {
  type: 'object',
  properties: {
    name: { type: 'string' },
    preferences: {
      type: 'object',
      additionalProperties: false,  // block unexpected keys
      properties: {
        theme: { type: 'string', enum: ['light', 'dark'] }
      }
    }
  },
  additionalProperties: false  // block __proto__, constructor, etc.
};

const validate = ajv.compile(userInputSchema);
if (!validate(userInput)) {
  throw new Error('Invalid input');
}
// safe to use userInput here

additionalProperties: false in AJV blocks any key not explicitly listed in properties — including __proto__ and constructor.

4. Freeze Object.prototype

For applications that don’t need to extend the built-in prototype:

// At application startup, before any untrusted input is processed
Object.freeze(Object.prototype);

// Now attempts to modify Object.prototype throw in strict mode
// or silently fail in non-strict mode

This is a defence-in-depth measure. It breaks some poorly-written third-party code that legitimately adds to prototypes (uncommon in modern code, but check your dependencies first).

5. Keep Dependencies Patched

The most common source of exploitable prototype pollution in real applications is a vulnerable version of lodash, jquery, qs, or similar utility libraries. Audit your lockfile:

npm audit
# or more specifically:
npx better-npm-audit audit --level moderate

For automated detection in CI, tools like Snyk and Socket.dev flag prototype pollution vulnerabilities in transitive dependencies before they reach production.