React Flight协议反序列化漏洞深度剖析
React Server Components既不会向浏览器发送HTML,也不会发送JSON。服务器组件渲染时,真正在网络中传输的是名为Flight的自定义流式协议。这是一种按行分隔的格式,拥有独立的类型系统、引用解析规则,以及一套在客户端重建可执行行为的逻辑。
大多数React开发者从未打开浏览器的网络面板查看过Flight的实际负载。它看起来像是JSON片段、美元符号开头的引用,以及模块指针的混合体,React运行时会在后台将其重新组装成可交互的组件树。框架会处理这一切,因此没人对此提出疑问。
但我不确定大多数团队是否认真思考过,这种信任背后隐含着怎样的风险。
2025年12月,CVE-2025-55182漏洞披露后,我开始深入拆解Flight协议。安全界将其称为React2Shell,这个名号绝非浪得虚名——它是Flight反序列化层中一个CVSS评分10.0的未认证远程代码执行漏洞。攻击者只需向Server Function端点发送一个构造好的HTTP请求,无需任何凭证,就能获取服务器的Shell权限。
美国网络安全与基础设施安全局(CISA)迅速将其纳入已知被利用漏洞目录。Sysdig的研究显示,朝鲜国家级黑客组织已在野利用该漏洞,通过以太坊区块链部署无文件恶意程序。这样严重的漏洞,足以引起所有人的警惕。
我研究了相关源码(核心逻辑主要在getOutlinedModel和getChunk函数中)后发现,React2Shell并非孤立的解析bug,而是深层问题的表象。Flight协议会从文本流中重建可执行引用、懒加载组件、服务器RPC端点及异步状态——本质上,这就是一套反序列化系统。
其攻击面远不止缺少一个hasOwnProperty检查那么简单。本文将详解Flight协议的网络传输机制、反序列化风险点、攻击者已利用的手段,以及仍未修复的暴露面。
最后,我会给出一套按优先级排序的实用防御方案,帮助你保护自己的Server Components:为每个Server Action添加 schema 验证、使用server-only包、强化跨站请求伪造(CSRF)防护(超越框架默认配置),以及评估Taint API和Web应用防火墙(WAF)的实际防护效果。
目录
- Flight协议的网络传输机制
- 为何Flight是反序列化风险点
- React2Shell漏洞原理
- 官方修复方案
- 按优先级排序的防御措施
- React2Shell之后的后续漏洞
- 仍存在的暴露风险
- 历史重演:同类漏洞的前车之鉴
- 未来走向与思考
Flight协议的网络传输机制
打开任意Next.js App Router页面的浏览器网络面板,找到返回Content-Type: text/x-component的请求——这就是Flight协议的传输内容。它并非单个JSON blob,而是一种流式的按行分隔格式,每一行都是一个独立的“数据行”,客户端React运行时会在数据到达时逐行处理。
以下是一个简单的Flight负载示例:
1:I["./src/components/ClientComponent.js",["chunks/main.js"],"default"]
2:J["$","article",null,{"children":"$1"}]
0:D{"name":"RootLayout","env":"Server"}
第一行是导入指令,告诉客户端从打包器的chunk映射中加载ClientComponent.js。第二行是一个JSON树,用于构建<article>HTML元素,其中children字段的"$1"是对第一行(即导入的组件)的引用。第0行定义了服务器执行上下文,标记这是一个运行在Server环境中的RootLayout。即便在这个微小的示例中,你也能看到结构数据、模块引用和跨chunk指针的混合模式,这正是Flight与普通JSON的区别所在。
数据行格式
每一行都遵循相同的语法:<行ID>:<行标签><负载>\n。行ID是一个数字标识符,供其他行引用;标签是单个字符(或短字符串),用于告知解析器后续数据的类型;负载则是实际内容。
我在源码中整理出以下行标签:
| 标签 | 名称 | 功能 |
|---|---|---|
| J | JSON树 | 序列化虚拟DOM节点、组件属性和HTML元素。 |
| M | 模块 | 特定客户端组件模块或chunk的元数据。 |
| I | 导入 | 告知客户端从打包器的chunk映射中加载模块。 |
| HL | 提示/预加载 | 指示浏览器预加载样式表、字体等资源。 |
| D | 数据 | 服务器渲染的元素上下文和环境信息。 |
| E | 错误 | 序列化的服务器端异常和错误边界信息。 |
目前看来,这似乎只是一个带有自定义标签的良性结构化数据格式,但真正的复杂性和攻击面隐藏在前缀系统中。
$前缀系统
这才是需要重点关注的部分。
当客户端解析器遇到以$开头的字符串时,不会将其视为普通文本,而是会拦截该字符串,检查前缀,并根据类型将其路由到对应的解析路径。这一逻辑在ReactFlightClient.js的parseModelString函数中实现,本质上是一个基于$后字符的大型分支判断。
| 前缀 | 类型 | 解析器行为 |
|---|---|---|
$ |
模型引用 | 解析为流中的另一个chunk(例如$2指向第2行)。 |
$: |
属性访问 | 遍历已解析chunk的属性(例如$1:user:name表示先解析chunk 1,再访问.user,最后访问.name)。 |
$S |
符号 | 创建原生JavaScript Symbol。 |
$F |
服务器引用 | 代表可调用的Server Action(即服务器上的RPC端点)。 |
$L |
懒加载组件 | 延迟组件加载,直到渲染树需要它时才加载。 |
$@ |
Promise/原始Chunk | 返回内部的Chunk包装对象本身(通常作为Thenable/Promise),而非其解析后的值。 |
$B |
Blob/二进制数据 | 触发二进制数据的Blob反序列化处理程序。 |
除$@外,其他前缀都会解析chunk并返回解析结果。$@则直接返回原始的内部Chunk对象——React用这个包装器跟踪解析状态、待处理回调和内部元数据(这也是它用于Promise的原因,同时也是攻击者利用它获取可变句柄的关键)。在我看来,通过协议暴露框架内部机制是一个设计失误,不过如果有合理的设计理由,我也很乐意了解。
另一个关键前缀是$:(属性访问),它允许协议指定类似$1:user:name的路径,指示解析器先解析chunk 1,再依次访问.user和.name。这是一种由流中数据驱动的任意属性遍历方式。如果你有过JavaScript原型污染审计经验,这种模式想必会很眼熟。
这绝非普通数据格式
Flight不是“加了额外步骤的JSON”。JSON仅传输数据,而Flight传输的是行为:它会重建触发客户端代码加载的模块引用、创建客户端可调用的服务器RPC端点、设置React运行时会await的Promise链,以及构建按需执行的懒加载组件边界。
无论React开发者是否意识到这一点,其机制与历史上引发过严重问题的反序列化系统极为相似。数据流不仅描述UI的外观,还会指示客户端运行时加载哪些代码、调用哪些函数,以及信任哪些内容。
如果你想自行研究实现细节,提前预警:chunk解析路径非常难以追踪。状态转换在多个辅助函数间跳转,命名也模糊了代码的实际功能。我放弃了静态阅读,直接设置断点调试。核心文件包括:客户端解析器的react-client/src/ReactFlightClient.js(重点关注parseModelString、getChunk、reviveModel和getOutlinedModel函数),以及序列化端的react-server/src/ReactFlightReplyServer.js。Server Action的回复处理逻辑在react-server/src/ReactFlightServer.js中。
为何Flight是反序列化风险点
反序列化漏洞的模式早已为人熟知:Java的ObjectInputStream催生了ysoserial工具,Python的pickle在load()时会执行代码,PHP的unserialize会触发__wakeup和__destruct方法链,.NET的BinaryFormatter则被彻底弃用。
共性模式是:反序列化攻击者可控的输入→在重建过程中触发行为→失去执行控制权。
那JavaScript应该能免疫这类问题,对吧?JSON.parse()只会生成普通数据对象,不会触发构造函数,也不会运行魔术方法,返回的内容完全与JSON字符串描述一致,不多不少。
对于原生JSON.parse()确实如此,但一旦框架在其外层包裹了自定义反序列化逻辑,情况就截然不同了——而Flight恰恰就是这么做的。
原型污染
JavaScript采用基于原型的继承机制,每个对象都有一个指向其原型的__proto__链接,属性查找会沿着这条链向上遍历。如果攻击者在对象重建过程中注入__proto__或constructor.prototype作为键,就会修改所有对象继承的共享基础原型,导致下游代码读取到攻击者可控的值却浑然不知。
Flight的$:前缀会对反序列化后的对象执行属性遍历。getOutlinedModel函数通过遍历每个分段,在父对象上依次访问属性,来处理$1:user:name这类冒号分隔的路径。如果路径分段包含__proto__或constructor,遍历就会直接沿着原型链向上——这并非理论风险,正是React2Shell漏洞的工作原理。
鸭子类型与Thenable
V8引擎(以及JavaScript规范)将任何带有.then属性的对象视为Thenable。当你await某个值时,运行时会检查其是否有.then属性,如果有且可调用,就会执行它——无需检查类,也无需验证内部槽位。
Flight会异步解析chunk。如果攻击者构造一个带有篡改后的.then属性的对象,并将其注入chunk解析流程,运行时就会在正常的await过程中调用攻击者的函数——语言特性本身就为攻击提供了便利。
我最初关注的是$F前缀,因为伪造Server Action引用看似是最明显的攻击面。但跟踪解析路径后发现,$:属性遍历的风险要有趣得多。我还花了几个小时研究chunk状态转换(待处理、阻塞、已解析、错误),试图寻找将chunk强制转换为异常状态的方法,但并未取得成果。
核心问题
Flight的这两类风险交织在一起,根源在于它不仅反序列化数据,还反序列化行为。$前缀系统决定了解析器的执行路径:$F创建可调用的服务器端点,$L设置懒加载代码逻辑,$B触发Blob处理程序,$@暴露框架内部状态。解析器的控制流完全由流中的内容决定。
一旦攻击者能够影响流的内容,就能控制解析器调用哪些函数、构造哪些对象,以及暴露哪些内部状态。
React2Shell漏洞原理
CVE-2025-55182(别名React2Shell)就是上述理论的实证。这是Flight反序列化层中一个CVSS评分10.0的未认证远程代码执行漏洞:只需一个HTTP请求,无需登录,即可获取服务器的完整Shell权限。
我将完整梳理这条漏洞利用链,因为理解它能揭示Flight协议赋予攻击者的强大权限——只要攻击者能控制数据流。
根本原因
漏洞存在于getOutlinedModel函数中,该函数负责解析$:引用系统中的深层属性路径。漏洞实例位于服务器端回复处理代码(ReactFlightReplyServer.js)中。当解析器遇到$1:user:name这类引用时,会按冒号拆分路径,并逐段遍历:
for (key = 1; key < reference.length; key++)
parentObject = parentObject[reference[key]];
短短两行代码,既没有hasOwnProperty检查,也没有验证属性是属于对象本身还是来自原型链的某个位置,直接执行parentObject[reference[key]]后继续遍历。
因此,攻击者只需传入$1:__proto__:constructor:constructor,遍历就会从普通JSON对象出发,依次经过Object.prototype、Object构造函数,最终到达Function构造函数。JavaScript中的Function相当于eval(),Function("任意代码")()会直接执行传入的代码。
代码中没有属性名称的白名单,也没有对__proto__的检查。我搜索了reviveModel和chunk初始化路径,没有发现任何过滤逻辑。
漏洞利用链
从“能够访问Function构造函数”到“实现远程代码执行”,需要结合Flight协议的多个特性。Resecurity的分析报告详细介绍了完整的利用链,以下是简化版流程:
- 步骤1:原型遍历至Function构造函数
通过
$:路径__proto__:constructor:constructor,从任意普通对象遍历到Object.prototype,再到Object构造函数,最终到达Function——JavaScript内置的eval()等价物。 - 步骤2:原始chunk自引用
$@0返回原始的内部Chunk包装器,而非其解析后的值,使攻击者能够获取React内部状态机的可变句柄。 - 步骤3:劫持Thenable
攻击者将chunk的
.then属性设置为Chunk.prototype.then,这样React的解析流程就会将被篡改的chunk视为合法的类Promise对象,并对其执行await操作。 - 步骤4:上下文混淆
在第二次反序列化过程中,负载将
_response._formData.get覆盖为被劫持的Function构造函数,并将攻击者的Shell命令放入_response._prefix中。 - 步骤5:通过Blob处理程序触发
$B0调用Blob处理程序,该程序内部会执行response._formData.get(response._prefix + blobId)——这等价于执行Function("攻击者的Shell命令")(),从而实现任意代码执行,权限与Node.js进程一致。
每一步都利用了Flight协议的合法特性,只是用法超出了设计者的预期。没有单一的“功能损坏”,漏洞是这些特性在攻击者控制输入时相互作用产生的结果。
影响范围
此次漏洞的影响极为严重:
- CVSS评分10.0,为最高等级。
- 未认证、预授权:无需任何凭证,且反序列化发生在应用级认证检查之前,因此即使是登录墙后的端点也存在暴露风险。
- 单次HTTP请求:只需向Server Function端点发送一个POST请求。
- 影响React 19.0.0、19.1.0、19.1.1和19.2.0版本,涉及
react-server-dom-webpack、react-server-dom-parcel和react-server-dom-turbopack。 - 漏洞披露后数天内,CISA就将其纳入已知被利用漏洞目录。
在野利用情况
漏洞披露后立即遭到利用。Sysdig发布研究报告称,朝鲜国家级黑客组织在漏洞披露后数小时内就将其武器化,用于部署EtherRAT恶意程序。EtherRAT是一种无文件植入程序,利用以太坊区块链进行命令与控制通信——研究人员称之为“EtherHiding”技术,由于无法查封区块链,清除这类恶意程序几乎不可能。
此外,帕洛阿尔托的Unit 42团队记录了一个名为KSwapDoor的后门程序,它在受感染的Linux系统中伪装成[kswapd1],与合法的kswapd0内核交换守护进程混在进程列表中;分析证实,KSwapDoor使用RC4加密保护内部字符串和配置数据,而命令与控制通信则通过AES-256-CFB加密,结合Diffie-Hellman密钥交换在P2P网络中传输。这些攻击活动的速度和复杂性——国家级黑客组织通过单个未认证HTTP请求部署新型恶意程序——凸显了反序列化层中CVSS 10.0漏洞的紧迫性:必须立即修补,而非优先分级处理。
官方修复方案
React团队的补丁简洁且针对性强。核心修改是在模块加载时缓存原生的hasOwnProperty方法:
var hasOwnProperty = Object.prototype.hasOwnProperty;
然后,反序列化路径中的所有属性检查都使用.call()调用这个缓存的引用:
hasOwnProperty.call(value, i);
即使攻击者在恶意对象上覆盖了hasOwnProperty,检查仍会使用原型上的原始方法,从而阻断了漏洞利用链依赖的原型链遍历。该修复已在React 19.0.1、19.1.2和19.2.1版本中发布。
这个修复是正确的,但我在阅读补丁时注意到,React团队强化了所有权检查,却保留了属性遍历模型。$:前缀仍然会遍历冒号分隔的路径,只是现在会验证每一步。我认为通过网络协议暴露任意属性遍历是一个设计失误,而补丁只是治标不治本。未来的漏洞很可能仍会出现在同一区域。
框架补丁封堵了已知的漏洞利用链,但并未改变核心问题:Flight协议仍然会从文本流中重建行为——可执行引用、模块导入、RPC端点、异步状态。这种重建发生在应用代码运行、验证逻辑触发、认证中间件处理请求之前。单纯依赖框架保护Server Components,意味着要相信复杂反序列化解析器的所有边缘情况都已被发现并修复。接下来的防御措施,是你可以采取的实际步骤,用于限制自身系统的风险范围。
按优先级排序的防御措施
以下措施中,有些能真正封堵攻击路径,有些则更多是心理安慰。我根据漏洞研究中的观察,按防护效果从高到低排序。如果你只有时间做一项修改,从顶部开始。
1. 为Server Action添加输入验证(Zod、Valibot)
这是应用层面最有效的防护措施。Flight反序列化器会在你的代码获得控制权之前,处理原始的、未经验证的网络输入。严格的schema验证是你对抗协议重建内容的主要防线。
在每个Server Action的最顶部添加schema验证调用,甚至要早于任何业务逻辑——我是指任何逻辑,包括日志记录。如果你在验证前就记录参数,而该参数触发了CVE-2025-55183中的字符串化漏洞,那么在验证生效前,你的源代码就已经泄露了。
Zod和Valibot都是不错的选择。验证类型、结构、字符串长度、数值范围和枚举值,拒绝任何不符合要求的输入。使用.safeParse()而非.parse()——如果错误边界处理不当,抛出异常的.parse()可能会在响应中暴露内部错误细节。
"use server"
import { z } from "zod"
const UpdateProfileSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
role: z.enum(["user", "editor"]),
})
export async function updateProfile(formData: FormData) {
const parsed = UpdateProfileSchema.safeParse({
name: formData.get("name"),
email: formData.get("email"),
role: formData.get("role"),
})
if (!parsed.success) return { error: "Invalid input" }
// 使用parsed.data继续执行,这是业务逻辑唯一会处理的数据结构
}
一个重要细节:如果你的Server Action接受普通对象参数(而非FormData),请验证整个参数——不要先解构再单独验证字段。解构发生在验证之前,意味着你已经在访问未验证输入的属性,而这正是Flight反序列化器可能被利用的操作。
"use server"
import { z } from "zod"
const CommentSchema = z.object({
postId: z.string().uuid(),
body: z.string().min(1).max(5000),
})
// 正确:先验证原始参数
export async function addComment(data: unknown) {
const parsed = CommentSchema.safeParse(data)
if (!parsed.success) return { error: "Invalid input" }
await db.comments.create(parsed.data)
}
// 错误:先解构再验证
export async function addCommentUnsafe(
{ postId, body }: { postId: string; body: string }
) {
// 到这一步时,你已经访问了反序列化输入的属性
const parsed = CommentSchema.safeParse({ postId, body })
// ...
}
如果你的Server Action不是以schema解析开头,那它就是一个潜在的漏洞。我认为这应该成为一条lint规则——如果你使用eslint-plugin-react,可以考虑编写一个自定义规则,标记所有未在第一条语句中包含验证调用的"use server"导出函数。
2. 使用server-only包
server-only包简单直接且有效。
在任何包含数据库凭证、原始API调用、内部业务逻辑,或其他绝不能跨服务器-客户端边界的文件顶部,导入server-only。如果客户端组件尝试直接或间接导入该文件,构建过程会失败并给出明确错误。
import "server-only"
import { db } from "./database"
export async function getUser(id: string) {
return db.query("SELECT * FROM users WHERE id = $1", [id])
}
需要注意的失败场景是桶文件。如果你通过index.ts同时导出server-only函数和客户端安全工具,任何导入该桶文件的客户端组件都会间接引入server-only模块,导致构建失败;更糟的是,如果桶文件本身没有导入server-only,可能会让服务器代码悄悄泄露到客户端。请将server-only模块放在单独的文件中,并使用独立的导入路径。
// 错误做法:桶文件混编跨边界代码
// src/utils/index.ts
export { getUser } from "./users" // 包含"server-only"
export { formatDate } from "./dates" // 客户端安全
// 正确做法:使用独立导入路径
// 客户端组件直接从"src/utils/dates"导入
// 服务器组件直接从"src/utils/users"导入
此外,它无法保护返回值中的数据泄露。如果服务器组件调用getUser()后,将完整的用户对象(包括passwordHash或internalRole)作为属性传递给客户端组件,这些数据会通过Flight流传输到浏览器。server-only防护的是代码不跨边界,而非代码返回的数据。你必须显式过滤返回的数据结构。
3. 强化CSRF防护
在CVE-2026-27978之后,仅依赖Next.js内置的Origin与Host头检查已不足够。Origin: null绕过漏洞表明,框架级CSRF防护存在边缘情况。
对于会改变状态的Server Action(任何写入数据、删除记录或修改权限的操作),在框架默认防护之上添加额外的防护层。
Cookie配置
为会话Cookie设置SameSite=Strict或SameSite=Lax。如果你使用next-auth或自定义会话库,请显式验证这一配置——不要依赖浏览器默认值,不同浏览器的默认值可能不同。
// next.config.js或你的认证配置
cookies: {
sessionToken: {
name: "__session",
options: {
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
path: "/",
},
},
}
显式CSRF令牌
对于高价值操作(修改密码、分配角色、支付操作等),在服务器端生成会话级CSRF令牌,将其嵌入隐藏表单字段或自定义头中,并在Server Action中先验证令牌再执行操作。
"use server"
import { cookies } from "next/headers"
import { validateCsrfToken } from "@/lib/csrf"
export async function deleteAccount(formData: FormData) {
const token = formData.get("csrf_token") as string
const sessionToken = (await cookies()).get("csrf_secret")?.value
if (!validateCsrfToken(token, sessionToken)) {
return { error: "Invalid request" }
}
// 执行删除操作
}
allowedOrigins的陷阱
无论如何,不要在Next.js配置的experimental.serverActions.allowedOrigins中添加'null'(即使官方建议有更微妙的表述,称“除非有意需要并额外保护”)。这个字符串字面量会匹配Origin: null——沙箱iframe发送的正是这个头——从而重新打开CVE-2026-27978的绕过漏洞。如果你看到合法请求出现CSRF失败,正确的修复方法是配置反向代理设置正确的Origin和Host头,而非削弱验证。
// 绝不要这样做
module.exports = {
experimental: {
serverActions: {
allowedOrigins: ["null"], // 重新开启CSRF绕过漏洞
},
},
}
4. 应用hasOwnProperty补丁
我在React2Shell部分已详细介绍过这个补丁。它正确且完全中和了已知的漏洞利用链,并且发布迅速,这一点值得肯定。
你需要做的是验证自己是否运行在已打补丁的版本上。远程代码执行漏洞的修复在React 19.0.1、19.1.2和19.2.1版本中发布。检查你的锁文件:
# npm
npm ls react react-dom react-server-dom-webpack
# pnpm
pnpm ls react react-dom react-server-dom-webpack
# yarn
yarn why react-server-dom-webpack
如果版本是19.0.0、19.1.0–19.1.1或19.2.0,你仍然面临远程代码执行风险,请立即更新。此外,拒绝服务(DoS)漏洞的修复(CVE-2025-55184、CVE-2025-67779、CVE-2026-23864)需要升级到19.0.4+、19.1.5+或19.2.4+版本。如果你在修复React2Shell后就停止关注,可能仍在运行存在DoS漏洞的版本。
这是一个被动补丁,而非结构性重构。
注:更多讨论见“未来走向与思考”部分。
5. 使用Taint API
React的taintObjectReference和taintUniqueValue函数用于向运行时注册对象或字符串。如果被标记的数据尝试通过Flight序列化,就会抛出错误。其目的是防止敏感数据(用户记录、API密钥、令牌)意外泄露到客户端。
实际使用示例如下:
import {
experimental_taintObjectReference as taintObjectReference
} from "react"
import "server-only"
export async function getUserRecord(id: string) {
const user = await db.users.findUnique({ where: { id } })
taintObjectReference(
"请勿将完整用户对象传递给客户端组件。仅选择所需字段。",
user
)
return user
}
如果服务器组件将被标记的user对象作为属性传递给客户端组件,React会在序列化时抛出你自定义的错误消息。这作为开发时的防护措施确实有用。
但有一个显著的局限性:污点跟踪的是对象引用,而非数据内容。任何数据转换都会打破跟踪:
const user = await getUserRecord(id)
// 污点丢失:扩展操作创建了新对象
<ClientProfile user={{ ...user }} />
// 污点丢失:单个属性不被跟踪
<ClientProfile token={user.apiToken} />
// 污点丢失:序列化往返创建了新引用
<ClientProfile user={JSON.parse(JSON.stringify(user))} />
// 污点触发:使用相同对象引用
<ClientProfile user={user} />
taintUniqueValue适用于特定字符串(如API密钥),但同样基于引用跟踪。如果相同的密钥值出现在不同变量中,污点不会随之转移。
请将污点API视为开发时的防护栏,而非安全边界。它能捕捉无心之失:比如开发者不小心将完整用户对象传递给客户端。但它无法阻止攻击者影响序列化内容,也无法在你的代码执行常规数据转换后继续生效。它是有用的纵深防御层,但不应作为你的主要边界。
6. 使用Web应用防火墙(WAF)
WAF可以为已知攻击模式添加检测层。它可以检查带有Next-Action头的POST请求,阻止包含constructor:constructor或__proto__链的负载,并标记包含E{"digest"模式的错误响应——这表明服务器正在泄露内部错误细节。
如果你运行WAF,建议添加以下特定规则:
# 阻止请求体中的原型污染尝试
规则:请求体包含"__proto__"或"constructor:constructor"
动作:拦截
范围:带有"Next-Action"头的POST请求
# 标记响应中潜在的Flight错误泄露
规则:响应体匹配/E\{"digest":"[^"]+"/
动作:记录 + 告警
范围:Content-Type为"text/x-component"的响应
# 阻止过大的Server Action负载
规则:带有"Next-Action"头的POST请求Content-Length > 1MB
动作:拦截(缓解CVE-2026-23864的zipbomb攻击向量)
但攻击者了解WAF的检查缓冲区大小(通常约为128KB)。在恶意负载前添加130KB的填充数据,WAF会检查填充数据,发现无异常后就会放行请求。分块传输编码也能达到同样的效果。
不要将WAF覆盖范围视为安全边界,而应将其视为减少噪音的工具。WAF能捕捉自动化扫描器和低水平攻击,这确实有价值,但有动机的攻击者会通过填充或编码技巧绕过它。真正能阻止复杂攻击的是前面列表中的防御措施:在输入到达业务逻辑前进行验证、防止敏感代码通过网络传输、保持版本更新。
React2Shell之后的后续漏洞
React2Shell并非终点。2025年12月漏洞披露后开展的安全审计,在同一反序列化层面发现了一系列相关漏洞。虽然它们的严重程度不及最初的远程代码执行漏洞,但仍值得关注,因为其中一些漏洞需要多轮补丁才能修复。
| CVE编号 | CVSS评分 | 类型 | 描述 | 修复版本 |
|---|---|---|---|---|
| CVE-2025-55184 | 7.5 | 拒绝服务 | Server Function反序列化中嵌套Promise导致无限递归,挂起Node.js事件循环。 | 19.0.2、19.1.3、19.2.2 |
| CVE-2025-67779 | 7.5 | 拒绝服务 | CVE-2025-55184的修复不完整,首次补丁未覆盖的边缘情况仍会导致相同的循环。 | 19.0.4、19.1.5、19.2.4 |
| CVE-2026-23864 | 7.5 | 拒绝服务/内存耗尽 | 请求体无限制缓冲及zipbomb式解压缩,导致内存耗尽。2026年1月披露。 | 19.0.4+、19.1.5+、19.2.4+ |
| CVE-2025-55183 | 5.3 | 信息泄露 | 构造特定请求,当Server Function对参数执行字符串化操作时,会反射函数源代码。 | 19.0.1、19.1.2、19.2.1 |
| CVE-2026-27978 | 5.3 | CSRF绕过 | Next.js将Origin: null(沙箱iframe)视为“缺失”而非“跨域”。 |
Next.js 16.1.7 |
这对DoS漏洞(CVE-2025-55184和CVE-2025-67779)是反序列化解析器难以正确修复的典型案例:首次补丁发布后,研究人员发现了未覆盖的边缘情况,因此需要第二轮修复。CVE-2026-23864则通过无限制内存分配而非CPU耗尽,新增了第三种DoS攻击向量。(版本检查细节见上文防御措施部分。)
CVE-2025-55183是一个隐蔽的漏洞。当Server Function对其某个参数调用JSON.stringify(或任何隐式字符串化操作)时,会触发源代码泄露。开发者经常会出于日志记录、调试或错误报告目的执行此类操作。
攻击者发送构造好的参数,当该参数被字符串化时,会导致反序列化解析器将函数自身的源代码反射回响应中。Server Action文件中的业务逻辑、数据库查询以及任何硬编码的机密信息,都会被任何能发送HTTP请求的人读取。
CVE-2026-27978则完全是另一类漏洞,是Next.js Server Action处理中的CSRF绕过问题。Next.js通过验证Origin头与Host头是否匹配来防止跨站请求伪造,但当请求来自沙箱<iframe>时,浏览器会发送Origin: null。
Next.js在action-handler.ts中的解析器将字符串'null'视为缺失的Origin,而非明确的跨域标识。因此,攻击者可以在沙箱iframe中嵌入表单,提交后利用受害者的已认证会话Cookie调用Server Actions。该漏洞在Next.js 16.1.7版本中修复。
上述CVE都已有补丁,但部分风险是结构性的,根植于Flight的设计理念。
Flight流的中间人攻击(MITM)
如果攻击者能处于服务器与客户端之间(如CDN被攻陷、缓存投毒、恶意代理),修改传输中的Flight流是可行的。该格式是明文且结构可预测。
假设攻击者控制了数据流,他们可以修改$I(导入)行,将组件加载重定向到webpack chunk映射中的其他模块;可以注入$F(服务器引用)标签,在渲染的UI中嵌入隐藏的RPC触发器;可以修改D(数据)行,更改组件属性,如果目标组件使用dangerouslySetInnerHTML,这将直接导致XSS漏洞。
Flight会对用户提供的字符串中的$前缀进行转义,防止数据被解析为协议指令,但这仅适用于通过序列化器传输的数据。中间人攻击者会直接向流中写入原始协议,转义机制无法发挥作用。
Server Action枚举
Server Action ID是构建时生成的混淆哈希,看起来是随机的。但server-reference-manifest.json会将每个Action ID映射到其源代码实现。如果该清单公开,攻击者就能获得完整的API映射。这种暴露通常源于托管配置错误、.next目录泄露或路径遍历漏洞。
已知Action ID会使Server Action面临标准的IDOR(不安全的直接对象引用)和参数篡改攻击。攻击者可以伪造带有篡改后参数的直接请求。开发者往往会盲目信任这些输入,因为它们来自React的内部机制。这种 misplaced信任带来的架构后果,将是我下一篇文章的重点。
加密闭包篡改
当Server Action捕获其作用域中的变量(闭包)时,Next.js会在发送到客户端前对其加密。密钥存储在NEXT_SERVER_ACTIONS_ENCRYPTION_KEY中,是base64编码的AES密钥(16、24或32字节)。decryptActionBoundArgs函数负责每次调用时的解密操作。
默认情况下,该密钥会在每次构建时重新生成,但多服务器部署通常会使用静态密钥。如果攻击者获得文件读取权限(如路径遍历、SSRF),就能提取密钥,解密闭包状态,修改内容(如更改userId、role或查询参数),然后重新加密。服务器会将伪造的闭包视为合法。
通过模块ID激活供应链攻击
我尚未完整演示这一攻击,但理论很简单。
Flight通过模块ID引用客户端组件,格式类似["360","static/chunks/app/page-7f3480.js"]。打包器会根据模块图在构建时分配这些ID。作为传递依赖存在于node_modules中的受损npm包会被打包进chunk,但由于没有组件引用它,因此处于惰性状态。
但如果攻击者通过中间人攻击、缓存投毒或服务器端注入,将$I导入引用注入Flight流,解析器应该会加载这个休眠模块。可能存在我未发现的chunk级验证,但如果模块ID有效且存在于清单中,我看不出有什么能阻止它。这种攻击不需要包在你的代码中被导入,只需存在于打包输出中即可。
历史重演:同类漏洞的前车之鉴
React Flight并非第一个为服务器-客户端通信发明自定义序列化格式,随后发现其存在攻击面的框架,也不会是最后一个。
Google Web Toolkit (GWT)曾使用自定义RPC协议在浏览器与服务器之间同步Java对象。BishopFox证明,攻击者可以操纵有线格式实现任意反序列化;最终GWT完全禁用了二进制序列化,这一过程耗时数年。
Java Server Faces (JSF)和ASP.NET都曾将ViewState序列化为客户端的隐藏表单字段。当加密签名薄弱或缺失时,攻击者可以篡改序列化状态,实现远程代码执行。微软和Oracle多次发布补丁,但底层模式仍不断重现。
模式总是相同:框架发明一种自定义有线格式,在服务器与客户端之间传输丰富的、有状态的、有时甚至是可执行的数据。设计者假设服务器是数据的唯一生产者,客户端是可信的消费者。然后有人证明,有线格式可以在传输过程中被篡改,或者服务器可以被诱骗反序列化攻击者可控的输入。React Flight是这一模式的最新案例,绝非特例。
未来走向与思考
React Flight协议解决了一个真正棘手的问题:将交互式组件树从服务器流式传输到客户端,同时支持渐进式 hydration、异步数据加载和服务器驱动的代码分割。它确实有效,这一点不容忽视。
但它的实现方式是通过流式文本协议序列化可执行引用、异步状态、模块指针和RPC端点,然后信任两端的流结构。React团队已修补了已知的漏洞利用链,hasOwnProperty修复是正确的,DoS漏洞的修复也已到位,源代码泄露漏洞已被封堵。
但我认为,通过面向网络的协议暴露任意属性遍历和可执行Thenable重建,是一个设计失误。$:、$@和$B是强大的内部原语,却能被一个未验证属性所有权的解析器访问。仅仅缺少一个检查,就导致了CVSS 10.0的严重漏洞。
“寄希望于解析器处理所有边缘情况”在历史上从未奏效,我不认为现在会例外。
相关代码位于react-client/src/ReactFlightClient.js。如果你使用Server Components,请务必阅读它,了解你的框架正在代表你信任哪些内容。
(gg, yk)
(gg, yk)