
这几天应该很多人在忙着应急这个“核弹级”漏洞,它已经成了安全圈里的“新顶流”。但网上还没有比较详细的分析文章,而我又对公开POC中的一些细节实在好奇,所以今天来一探究竟。
因为我对React不够了解,所以本文没有任何扩展内容,仅仅只是把漏洞利用过程调试了一遍,并且因为利用过程有点绕,所以配上了流程图方便理解。
背景知识
React Server Components (RSC)
RSC 是 React 19 推出的新渲染模型,核心思想是让部分组件在服务器端执行,仅将序列化后的”结果”传输给客户端。
主要特性:
- 服务端执行:组件代码可直接在服务端执行业务逻辑与数据获取(如访问数据库、调用内部服务)
- Flight 协议传输:客户端接收经过序列化的”React 树描述”,再在浏览器中反序列化为 React 元素
- 流式渲染:支持增量传输,先发送已计算完成的部分,后续再补充剩余内容
与传统 SSR 的区别:
| 特性 | 传统 SSR | RSC |
|---|---|---|
| 输出格式 | 完整 HTML 字符串 | 组件树的数据格式(Flight stream) |
| 客户端处理 | 仅做 Hydrate | 作为完整 React 应用运行 |
这套机制已成为 Next.js App Router 的默认架构。
React Flight 协议
数据结构概述
FormData 与 Chunk 索引
在典型的 Server Action 请求中,Next.js / React 发送 multipart/form-data 请求,表单字段结构如下:
name="0": 主 payload(如参数列表)name="1": 第 1 个模型块(model chunk)name="2": 第 2 个模型块...: 更多块
$N 引用语法
Flight 协议使用 $ 前缀加数字表示对特定 chunk 的引用,冒号分隔的路径用于访问嵌套属性:
"$1": 引用 chunk1 本身"$2:fruitName": 引用 chunk2 解析后对象的fruitName属性"$3:user:email": 引用 chunk3 中的.user.email
Flight 数据解析流程
假设客户端发送如下请求体:
1 | ------WebKitFormBoundaryABC123 |
可以看作:
1 | chunks = { |
解析过程:
1 | "$1:profile:name" |
漏洞根因
漏洞的核心问题在于:路径解析逻辑未通过 hasOwnProperty 限制可访问的属性范围,导致攻击者可以沿原型链访问任意属性,包括 __proto__、constructor 等敏感属性。
流程图
漏洞代码分析
入口函数:decodeReplyFromBusboy
decodeReplyFromBusboy 是服务端解析客户端 FormData 的入口函数,位于 ReactFlightDOMServerNode.js:
1 |
|
调用链: resolveField → resolveModelChunk → initializeModelChunk
字段解析:resolveField
1 |
|
核心解码逻辑:initializeModelChunk
1 |
|
关键步骤:
- [1] 对
chunk.value进行 JSON 解析,得到如{then: "$1:...", ...}的对象 - [2]
reviveModel处理 JSON,将字符串引用还原为实际对象
还原对象:reviveModel
1 |
|
POC 中 chunk[0].value 经 JSON 解析后为 object 类型,进入 [3] 处的循环,遍历所有 key-value 并递归调用 reviveModel。这是反序列化的核心过程——将数据还原为实际对象。
当遇到第一个 key then,其值为 $1:__proto__:then(字符串类型),会走不同分支:
1 | function reviveModel(...): any { |
字符串解析:parseModelString
parseModelString 包含一个大型 switch 表,根据前缀字符路由到不同处理逻辑:
1 |
|
引用解析:getOutlinedModel
1 |
|
漏洞利用过程
解析 chunk[0] 并注册回调
- **解析
$1:__proto__:then**:value[0]为$,value[1]为1(chunk 1 的引用),进入 [5] - **
getOutlinedModel处理1:__proto__:then**:- 按
:分割得到path = ["1", "__proto__", "then"] - 解析
path[0]获取 chunk[1],此时状态为PENDING - 进入 **[6]**,调用
Chunk.prototype.then(自定义实现) - 将
createModelResolver回调 push 到chunk.value中,待后续触发
- 按
图示:回调注册过程
- 继续处理 chunk[0] 的其他 key,完成后开始处理 chunk[1]
解析 chunk[1] 并触发回调
chunk[1] 的值为 "$@0",在 [4] 处解析,调用 getChunk 获取 chunk[0]。
一路向上返回到initializeModelChunk,之后返回至resolveModelChunk
1 |
|
随后调用 wakeChunkIfInitialized:
1 |
|
此时 chunk 状态为 INITIALIZED,进入 wakeChunk 调用之前注册的 resolveListeners。
图示:resolveListeners 包含
then和get两个 key 的回调
原型链遍历
回调中执行以下循环:
1 | for (let i = 1; i < path.length; i++) { |
图示:原型链遍历过程
这个循环类似于文件系统中的 ../../ 路径遍历,但作用于 JavaScript 原型链。
以 path = ["1", "__proto__", "then"] 为例,value 初始为 chunk[0](Chunk 实例):
| 迭代 | 操作 | 结果 |
|---|---|---|
| 1 | value = chunk["__proto__"] |
Chunk.prototype |
| 2 | value = Chunk.prototype["then"] |
Chunk.prototype.then 函数 |
get 属性的处理类似,最终可获取 Chunk.constructor.constructor(即 Function 构造函数)。
构造伪造 Chunk
经过上述处理,chunk[0].value 被构造为:
1 | { |
这是一个伪造的 thenable 对象,当 await decodeReplyFromBusboy 执行时会调用其 then 方法。
触发代码执行
在 then 方法中,由于 status 为 resolved_model,会调用 initializeModelChunk → reviveModel。
此时 value 为 {"then":"$B1337"},解析 $B1337 进入 B 分支(Blob 处理):
1 | case 'B': { |
[8] 处调用 response._formData.get(blobKey),而 _formData.get 已被替换为 Function 构造函数
因此实际执行:
1 | Function("debugger;throw new Error('test');4919") |
返回的函数被写回 then 属性:
1 | { |
后续 await decodeReplyFromBusboy 再次触发 then 函数时,恶意代码被执行,实现 RCE。
参考链接
https://gist.github.com/maple3142/48bc9393f45e068cf8c90ab865c0f5f3



