Smashing Magazine

Weaponizing And Defending The React Flight Protocol: Deserialization Sinks In RSCs

8.5内容质量

TL;DR · AI 摘要

React Server Components的Flight协议存在严重安全漏洞,攻击者可利用反序列化漏洞实现远程代码执行,需通过严格验证和防护措施防御。

核心要点

  • CVSS 10.0的React2Shell漏洞允许攻击者通过单一HTTP请求实现无凭证远程代码执行。
  • 防御需包含严格模式验证、CSRF防护和限制Server Function端点访问。
  • CISA已将该漏洞列入已知被利用漏洞目录,北韩黑客已实际攻击。

结构提纲

按章节快速跳转。

  1. 介绍React Server ComponentsFlight协议的基本概念及安全风险。

  2. ·React2Shell漏洞分析

    解析CVSS 10.0漏洞的利用机制和影响。

  3. 列出从模式验证到CSRF防护的多层次防御措施。

  4. 讨论CISA和Sysdig报告的攻击实例及影响。

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • React Flight协议安全
    • 漏洞分析
      • React2Shell CVSS 10.0
      • 反序列化机制
    • 防御措施
      • 模式验证
      • CSRF防护
    • 实际案例
      • CISA收录
      • 北韩攻击

金句 / Highlights

值得收藏与分享的关键句。

  • CVSS 10.0的React2Shell漏洞允许攻击者通过单一HTTP请求实现无凭证远程代码执行。

    第3段

    ⬇︎ 下载 PNG𝕏 分享到 X
  • Flight协议的反序列化机制是漏洞的核心,涉及模块指针和引用解析。

    第4段

    ⬇︎ 下载 PNG𝕏 分享到 X
  • CISA已将该漏洞列入已知被利用漏洞目录,北韩黑客通过以太坊区块链部署无文件植入。

    第5段

    ⬇︎ 下载 PNG𝕏 分享到 X
#React#安全#前端#漏洞防御
打开原文

利用与防御 React Flight 协议:RSC 中的反序列化漏洞点 —— Smashing Magazine

  • Durgesh Pawar
  • 2026 年 7 月 21 日
  • 0 条评论

利用与防御 React Flight 协议:RSC 中的反序列化漏洞点

  • 27 分钟阅读时间
  • 在 Twitter 上分享 , 在 LinkedIn 上分享

#### 关于作者

Durgesh Rajubhai Pawar 是一名自由开发者,专注于构建高性能、可访问的 Web 应用。他擅长协调复杂的系统……更多关于 Durgesh 的信息 ↬

#### 电子邮件通讯

每周前端与用户体验技巧。被 182,000+ 人信任。

  • 为 Angular、React 和 Vue 定制 Web 表单。你的后端。
  • 级联样式系统:与 Miriam Suzanne 一起构建稳健且易于维护的 CSS
  • 实时 UX 培训 —— 与 Vitaly Friedman 一起学习智能界面设计模式
  • 庆祝 1000 万开发者
  • 智能界面设计模式,45 节课程 + UX 培训
  • 与 Christine Vallaure 一起学习 Figma 工作流程大师班

虽然 React 服务器组件依赖自定义的 Flight 协议来流式传输交互式 UI,但这一机制也引入了攻击者可利用的强大反序列化漏洞点。Durgesh Pawar 分析了 CVSS 10.0 “React2Shell” 漏洞的原理,展示协议操控如何导致远程代码执行。他还介绍了从严格模式验证到 CSRF 防御的实用防御方案,帮助保护 React 应用免受这些结构性风险。

React 服务器组件不会向浏览器发送 HTML,也不会发送 JSON。当服务器组件渲染时,实际通过网络传输的是名为 Flight 的自定义流式协议。它是一种行分隔格式,拥有自己的类型系统、引用解析机制,以及在客户端重建可执行行为的规则。

大多数 React 开发者从未打开过网络面板,实际查看过 Flight 数据负载。它看起来像是 JSON 片段、以美元符号开头的引用,以及 React 运行时静默重新组装成实时组件树的模块指针。框架处理了这些内容,所以没有人会质疑它。

我不确定大多数团队是否认真思考过这种信任究竟意味着什么。

我在 2025 年 12 月 CVE-2025-55182 漏洞披露后开始深入分析 Flight 协议。安全社区将其称为 React2Shell,确实有其道理。这是一个 CVSS 10.0 的未经身份验证的远程代码执行漏洞,位于 Flight 反序列化层。只需向服务器函数端点发送一次精心构造的 HTTP 请求,攻击者就能获得 shell 访问权限。无需任何凭证。

联邦网络安全与基础设施局(CISA)已将其添加到已知被利用漏洞目录中。Sysdig 将实际攻击案例与朝鲜国家支持的黑客组织通过以太坊区块链部署无文件植入物的行为关联起来。这就是那种能引起你注意的 CVE。

在研究源码(主要是 getOutlinedModel 和 getChunk 函数,这两个函数实际包含了关键的解析逻辑)后,我意识到 React2Shell 并不是一个孤立的解析错误,而是一个症状。Flight 协议从文本流中重建可执行引用、延迟加载组件、服务器 RPC 端点和异步状态。这是一个反序列化系统。

攻击面远不止于一个缺失的hasOwnProperty检查。本文将介绍Flight在传输过程中的工作原理,反序列化漏洞点所在位置,攻击者已经利用的案例,以及仍然暴露的风险。

这将引导你为自己的Server Components制定一套按优先级排序的防御方案:对每个Server Action进行模式验证,使用仅限服务端的包,强化跨站请求伪造(CSRF)防护(超越框架默认配置),以及评估Taint API和Web应用防火墙(WAFs)所提供的防护能力。

目录

  • 传输中的Flight
  • 为什么Flight是反序列化漏洞点
  • React2Shell的运作机制
  • 修复方案
  • 按影响程度排序的防御措施
  • React2Shell之后发生的事
  • 仍然暴露的风险
  • 这并非首次发生
  • 未来发展方向

传输中的Flight

在任意Next.js App Router页面上打开浏览器的Network标签,查找返回Content-Type: text/x-component的请求。这就是Flight。它不是一个简单的JSON数据块,而是一种流式传输、以行分隔的格式,每行都是一个自包含的"行",客户端的React运行时会随着连接接收数据逐步处理。

以下是一个简单Flight负载的实际示例:

code
1:I["./src/components/ClientComponent.js",["chunks/main.js"],"default"]
2:J["$","article",null,{"children":"$1"}]
0:D{"name":"RootLayout","env":"Server"}

第1行是导入指令,告诉客户端从打包器的chunk映射表中加载ClientComponent.js。第2行是一个构建<article>HTML元素的JSON树,其中children中的"$1"是对第1块(已导入组件)的引用。第0行定义了服务端执行上下文,标记这是一个在服务端环境中运行的RootLayout。即使在这个小例子中,你也可以看到结构化数据、模块引用和跨块指针的混合,这使得Flight与普通JSON有所不同。

行格式

每行都遵循相同的语法:<ROW_ID>:<ROW_TAG><PAYLOAD>\n。行ID是其他行可以引用的数字标识符。标签是一个字符(或短字符串),告诉解析器后续是什么类型的数据。负载是实际内容。

在阅读源码时我发现的行标签如下:

标签 | 名称 | 功能 --- | --- | --- J | JSON树 | 序列化的虚拟DOM节点、组件属性和HTML元素 M | 模块 | 特定客户端组件模块或chunk的元数据 I | 导入 | 告诉客户端从打包器的chunk映射表加载模块 HL | 提示/预加载 | 指示浏览器预加载资源(如样式表或字体) D | 数据 | 服务端渲染元素上下文和环境信息 E | 错误 | 序列化的服务端异常和错误边界

到目前为止,这可能看起来像一个带有自定义标签的良性结构化数据格式,但真正的复杂性和攻击面存在于前缀系统中。

$前缀系统

这就是我开始更加关注的地方。

当客户端解析器遇到以$开头的字符串值时,不会将其视为字面文本。它会拦截该字符串,检查前缀,并通过特定类型的解析路径进行处理。这个过程发生在ReactFlightClient.js中的parseModelString函数,本质上是对$后字符进行的大规模switch语句处理。

前缀 | 类型 | 解析器处理方式 --- | --- | --- $ | 模型引用 | 解析为流中的另一个块(例如$2指向第2行) $: | 属性访问 | 用于访问对象属性

进入已解析块的属性(例如:

code
$1:user:name

)。

code
$S

符号

创建一个原生 JavaScript

code
Symbol

code
$F

服务器引用

表示一个可调用的服务器操作(服务器上的一个 RPC 端点)。

code
$L

延迟组件

在渲染树中需要时才加载组件。

code
$@

Promise/原始块

返回内部 Chunk 包装对象本身(通常充当 Thenable/Promise),而不是其解析后的值。

code
$B

Blob/二进制

触发二进制数据的 Blob 反序列化处理程序。

其他所有前缀都会解析块并返回解析后的结果。$@ 会返回原始的内部 Chunk 对象,这是 React 用来跟踪解析状态、挂起回调和内部元数据的包装器(这也是为什么它用于 Promise,以及为什么攻击者会利用它来获取可变句柄)。尽管我认为通过协议暴露框架内部机制看起来像是一个设计错误,但如果存在相关理由,我倒是想听听。

另一个关键前缀是 $:(属性访问)。它允许协议指定类似 $1:user:name 的路径,告诉解析器先解析块 1,然后访问 .user,最后在结果上访问 .name。这种由流中数据驱动的任意属性遍历模式,如果你曾花时间审计 JavaScript 的原型污染问题,应该会觉得很眼熟。

这不仅仅是一种数据格式

Flight 并不是带有额外步骤的 JSON。JSON 给你数据,Flight 给你行为。它重建触发客户端代码加载的模块引用,创建客户端可调用的服务器操作端点,设置 React 运行时会等待的 Promise 链,并构建按需执行的延迟加载组件边界。

无论 React 开发者是否这样认为,其工作机制与历史上曾引发问题的反序列化系统非常相似。流不仅仅描述 UI 的样子,它还指示客户端运行时加载哪些代码、调用哪些函数以及信任什么内容。

如果你想亲自阅读实现,有个警告:块解析路径很难跟踪。状态转换在辅助函数之间来回跳转,命名也掩盖了代码实际在做什么。我放弃了静态阅读,直接设置了断点。关键文件是客户端解析器的 react-client/src/ReactFlightClient.js(查找 parseModelString、getChunk、reviveModel 和 getOutlinedModel),以及服务器端序列化部分的 react-server/src/ReactFlightServer.js。服务器操作的回复处理程序位于 react-server/src/ReactFlightReplyServer.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 的运作方式。

鸭子类型与可兑现对象

V8 引擎(及 JavaScript 规范)将任何包含 .then 属性的对象视为可兑现对象。当你使用 await 时,运行时会检查 .then 属性并调用它(如果存在)。无需类检查,无需内部槽验证。只要 .then 是可调用的,就会被触发。

Flight 异步解析块。如果攻击者构造一个包含被篡改 .then 属性的对象,并将其引入块解析流程,运行时会在正常的 await 行为中调用攻击者的函数。语言语义本身会完成这个操作。

我最初关注的是 $F,因为伪造服务器操作引用似乎是最明显的攻击面。但在追踪解析路径后,$: 属性遍历看起来更加有趣。我还花了几小时研究块状态转换(pending、blocked、resolved、errored),试图强制块进入意外状态,但这种方法没有取得成果。

核心问题

这两个风险在 Flight 中汇聚,因为协议不仅仅反序列化数据,还反序列化行为。$: 前缀系统决定了解析器采用的执行路径:$F 创建可调用的服务器端点,$L 设置延迟代码加载,$B 触发 blob 处理器,$@ 暴露内部框架状态。解析器的控制流完全由流中的内容决定。

如果攻击者能影响流的内容,他们就能控制解析器调用哪些函数、构造哪些对象、暴露哪些内部状态。

React2Shell 的运作机制

这就是证明理论的 CVE 漏洞。CVE-2025-55182,昵称为 React2Shell,是一个 CVSS 10.0 的未经身份验证的远程代码执行漏洞,存在于 Flight 反序列化层。只需一个 HTTP 请求,无需登录,即可获得完整 shell 权限。

我想完整演示整个利用链,因为理解它能揭示 Flight 协议在攻击者控制流时赋予的惊人能力。

根本原因

漏洞位于 getOutlinedModel 函数,该函数负责解析 $: 引用系统中的深层属性路径。利用链中使用的实例位于服务端回复处理代码(ReactFlightReplyServer.js)中。当解析器遇到类似 $1:user:name 的引用时,它会按冒号分割并逐段遍历路径。以下是漏洞代码段:

code
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 构造函数”到“实现远程代码执行(RCE)”,需要将多个 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-like 对象并等待其执行。
  • 步骤 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”。由于无法控制区块链,这种攻击几乎无法被清除。

单独地,Palo Alto 的 Unit 42 团队记录了一个名为 KSwapDoor 的后门,该后门在受感染的 Linux 系统中伪装成 [kswapd1],与合法的 kswapd0 内核交换守护进程一起融入进程列表;他们的分析确认 KSwapDoor 使用 RC4 加密保护其内部字符串和配置数据,而 C2 通信则通过 AES-256-CFB 在 P2P 网格网络上运行,使用 Diffie-Hellman 密钥交换。这些活动的速度和复杂性——国家支持的攻击者通过单个未认证的 HTTP 请求部署新型植入程序——凸显了为什么反序列化层中的 CVSS 10.0 漏洞需要立即修补,而不是优先级处理。

修复方案

React 团队的补丁干净且针对性强。核心更改是在模块加载时缓存真实的 hasOwnProperty 方法:

code
var hasOwnProperty = Object.prototype.hasOwnProperty;

然后,反序列化路径中的每个属性检查都使用 .call() 调用缓存的引用:

code
hasOwnProperty.call(value, i);

即使攻击者在恶意对象上遮蔽 hasOwnProperty,检查也会使用原始原型方法。驱动 gadget 链的原型链遍历已被阻止。此修复方案已包含在 React 19.0.1、19.1.2 和 19.2.1 版本中。

该修复是正确的。但通读补丁后,我注意到 React 团队在强化所有权检查的同时,保留了属性遍历模型。$: 前缀仍然遍历冒号分隔的路径;它只是现在验证每一步。我认为通过网络协议暴露任意属性遍历是一个设计错误,而该补丁只是治标不治本。如果未来出现新漏洞,它们很可能仍会来自同一区域。

框架补丁关闭了已知的 gadget 链,但并未改变根本动态:Flight 协议仍然从文本流中重建行为——可执行引用、模块导入、RPC 端点、异步状态。这种重建发生在你的应用程序代码运行之前,发生在你的验证逻辑触发之前,甚至发生在你的认证中间件看到请求之前。仅依赖框架保护你的 Server Components 意味着信任所有复杂反序列化解析器的边缘情况都已被发现并修复。以下防御措施是你可以采取的实际步骤,以限制自身系统的风险范围。

按影响程度排序的防御措施

其中一些措施能真正关闭实际攻击路径,其他则主要让你感觉更安全。我根据在漏洞研究中看到的情况,将这些措施按影响程度从高到低排序。如果你只能进行一项更改,请从最上面开始。

1. 服务器操作上的输入验证(Zod, Valibot)

这是你在应用层能做的最具影响力的措施。Flight 反序列化器在你的代码接管之前处理原始的、未经验证的网络输入。严格的模式验证是你对抗协议重建内容的主要防御手段。

在每个服务器操作的最开始处(在任何业务逻辑运行之前——包括日志记录之前)添加模式验证调用。如果你在验证参数之前记录参数,而该参数触发了 CVE-2025-55183 的字符串化漏洞,你将在验证有机会运行之前就泄露了源代码。

Zod 和 Valibot 在这方面都能很好地工作。验证类型、形状、字符串长度、数值范围和枚举值,拒绝任何不匹配的内容。使用 .safeParse() 而不是 .parse() —— 如果你对错误边界处理不当,抛出异常的版本可能会在响应中暴露内部错误细节。

code
"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 反序列化器可以利用的操作方式。

code
"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 解析开头,这将是一个等待发生的漏洞。我认为这应该被设为代码检查规则 —— 如果你正在使用 eslint-plugin-react,可以考虑编写自定义规则,标记任何没有在首行语句中调用验证的 "use server" 导出。

2. 仅限服务端的包

仅限服务端的包简单直接且有效。

在包含数据库凭证、原始 API 调用、内部业务逻辑或任何不应跨越服务端-客户端边界的文件顶部导入 server-only。如果客户端组件尝试导入该文件(直接或间接),构建将失败并显示明确错误。

code
import "server-only"
import { db } from "./database"

export async function getUser(id: string) {
  return db.query("SELECT * FROM users WHERE id = $1", [id])
}

需要特别注意的失败模式是汇总文件(barrel files)。如果你通过一个同时导出客户端安全工具的 index.ts 汇总文件重新导出 server-only 函数,任何从该汇总文件导入的客户端组件都会间接引入 server-only 模块并导致构建失败 —— 更糟糕的是,如果汇总文件本身没有包含 server-only 导入,可能会静默地让服务端代码通过。将 server-only 模块保留在单独文件中,并使用独立的导入路径。

code
// 不要这样做:汇总文件导出混合了边界
// src/utils/index.ts
export { getUser } from "./users"     // 包含 "server-only"
export { formatDate } from "./dates"  // 客户端安全

// 请这样做:分离导入路径 // 客户端组件直接从 "src/utils/dates" 导入 // 服务端组件直接从 "src/utils/users" 导入

code

它也无法防止通过返回值泄露数据。如果服务端组件调用 getUser() 并将完整的用户对象(包括 passwordHash 或 internalRole)作为 props 传递给客户端组件,这些数据会通过 Flight 流传输到浏览器。仅服务端的防护机制只能阻止代码跨越边界,而不能阻止代码返回的数据。你必须显式过滤返回的数据结构。

### 3. CSRF 防护

在 CVE-2026-27978 事件后,仅依赖 Next.js 内置的 Origin 与 Host 头检查已不足以防护。Origin: null 的绕过案例表明,框架级别的 CSRF 防护存在边界情况。

对于会改变状态的服务端操作(任何写入数据、删除记录或修改权限的操作),应在框架默认防护基础上增加自己的防护机制。

**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: "/", }, }, }

code

**显式 CSRF 令牌**。对于高价值操作(密码修改、角色分配、支付操作),在服务端为每个会话生成一个 CSRF 令牌,将其嵌入隐藏表单字段或自定义头中,并在服务端操作前验证该令牌。

"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" } } // 继续执行删除操作 }

code

**allowedOrigins 的陷阱**。在任何情况下都不要在 Next.js 配置中将 'null' 添加到 experimental.serverActions.allowedOrigins(即使官方建议更细致地说明“除非明确需要且额外防护”)。该字符串字面量匹配 Origin: null——沙盒 iframe 发送的精确头字段——这会重新打开 CVE-2026-27978 的绕过漏洞。如果合法请求出现 CSRF 验证失败,请修复反向代理配置以设置正确的 Origin 和 Host 头,而不是削弱验证。

// 绝对不要这样做 module.exports = { experimental: { serverActions: { allowedOrigins: ["null"], // 重新打开 CSRF 绕过漏洞 }, }, }

code

### 4. hasOwnProperty 补丁

我在 React2Shell 部分已详细说明过这一点。修复方案是正确的,它完全中和了已知的 gadget 链。修复速度很快,这点我表示赞赏。

当前需要执行的操作是验证你是否实际运行了补丁版本。RCE 修复已发布在 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

code

如果你看到版本号 19.0.0、19.1.0–19.1.1 或 19.2.0,说明你的系统存在远程代码执行(RCE)漏洞,请立即升级。不仅如此:拒绝服务(DoS)修复(CVE-2025-55184、CVE-2025-67779、CVE-2026-23864)需要 19.0.4+、19.1.5+ 或 19.2.4+ 版本。如果你在 React2Shell 事件后升级过但之后没有继续关注,可能仍在运行存在 DoS 变体漏洞的版本。

这是一个应急补丁,而非结构性的重新设计。

注意:关于后续改进方案,详见《Where This Goes Next》部分。

### 5. 污染 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 }

code

如果服务器组件将被标记的用户对象作为 props 传递给客户端组件,React 会在序列化时抛出错误,并显示你自定义的错误信息。这在开发阶段确实能起到有效的防护作用。

但需注意(这是一个关键限制):污染机制追踪的是对象引用,而非数据内容。任何衍生操作都会导致追踪失效:

const user = await getUserRecord(id)

// 污染信息丢失。展开操作会创建新对象 <ClientProfile user={{ ...user }} />

// 污染信息丢失。单个属性未被追踪 <ClientProfile token={user.apiToken} />

// 污染信息丢失。序列化往返会创建新引用 <ClientProfile user={JSON.parse(JSON.stringify(user))} />

// 污染机制生效。引用指向相同对象 <ClientProfile user={user} />

code

`taintUniqueValue` 用于标记特定字符串(如 API 密钥),但同样是基于引用的。如果相同密钥值出现在不同变量中,污染追踪不会自动跟随。

请将污染机制视为开发阶段的防护栏,而非安全边界。它能有效拦截开发者无意的错误(如意外将完整用户对象传递给客户端),但无法阻止攻击者操控序列化内容,也无法抵御你代码中常规的数据转换操作。它是一个有用的纵深防御层,但不应作为主要安全边界。

### 6. WAF(Web 应用防火墙)

Web 应用防火墙可以增加对已知攻击模式的检测能力。它们可以检查携带 `Next-Action` 请求头的 POST 请求,阻止包含 `constructor:constructor` 或 `__proto__` 链的负载,并标记包含 `E{"digest"` 模式的错误响应(表明服务器泄露了内部错误细节)。

如果你正在使用 WAF,建议添加以下规则:

阻止请求体中的原型污染尝试

Rule: 请求体包含 "__proto__" 或 "constructor:constructor" Action: 阻止 Scope: 携带 "Next-Action" 请求头的 POST 请求

标记响应中的 Flight 错误泄露

Rule: 响应体匹配 /E\{"digest":"[^"]+/ Action: 记录 + 报警 Scope: Content-Type 为 "text/x-component" 的响应

阻止过大的 Server Action 负载

Rule: POST 请求携带 "Next-Action" 请求头且 Content-Length > 1MB Action: 阻止(缓解 CVE-2026-23864 的 zipbomb 攻击向量)

code

但攻击者了解WAF的检测缓冲区,且通常大小约为128KB。在恶意负载前添加130KB的填充数据,WAF会检测填充内容,未发现异常并放行请求。分块传输编码技巧可以实现相同效果。

这种失败模式是将WAF覆盖范围视为安全边界而非噪声过滤层。WAF确实能拦截自动化扫描器和低级攻击,这具有实际价值。但有动机的攻击者可通过填充数据或编码技巧绕过WAF。真正能阻止复杂攻击的防御措施是列表中更早提到的:在数据到达业务逻辑前进行输入验证、避免敏感代码通过网络传输、以及保持系统更新到已修复版本。

## React2Shell 之后的情况

React2Shell 并非终点。2025年12月披露后进行的安全审计,揭示了同一反序列化接口中一系列相关漏洞。这些漏洞均不如原始RCE严重,但值得关注,因为其中一些漏洞需要多轮补丁修复。

| CVE         | CVSS | 描述                                                                 | 修复版本                |
|-------------|------|----------------------------------------------------------------------|-------------------------|
| CVE-2025-55184 | 7.5  | 服务器函数反序列化中嵌套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 | 10.0 | 无限制请求体缓冲和类似zipbomb的解压缩,导致内存耗尽。2026年1月披露。 | 19.0.4+, 19.1.5+, 19.2.4+ |
| CVE-2025-55183 | 5.3  | 特制请求在函数对参数进行字符串化时,会反射出服务器函数的源代码。       | 19.0.1, 19.1.2, 19.2.1 |
| CVE-2026-27978 | 6.5  | CSRF绕过:Next.js将`Origin: null`(沙盒化iframe)视为“缺失”而非“跨域”。 | Next.js 16.1.7        |

DoS漏洞对(CVE-2025-55184和CVE-2025-67779)是反序列化解析器难以正确修复的典型教材案例。首次修复发布后,研究人员发现其遗漏的边界情况,需要进行第二轮修复。CVE-2026-23864通过无限制内存分配引入了第三个DoS向量,而非CPU耗尽。(详见上方防御章节的具体版本检查。)

CVE-2025-55183是较为隐蔽的漏洞。这是一个源代码暴露漏洞,当服务器函数对其参数调用JSON.stringify(或任何隐式字符串化)时触发。开发者经常出于日志记录、调试或错误报告目的进行此类操作。

攻击者发送特制参数,当其被字符串化时,会导致反序列化解析器在响应中反射出函数自身的源代码。业务逻辑、数据库查询以及存放在服务器动作文件中的任何硬编码密钥,都可能被任何能发送HTTP请求的人读取。

CVE-2026-27978属于完全不同的漏洞类别。这是Next.js服务器动作处理中的CSRF绕过漏洞。Next.js通过验证Origin头与Host头匹配来防止跨站请求伪造。但当请求来自沙盒化<iframe>时,浏览器会发送Origin: null。

action-handler.ts中的Next.js解析器将字符串'null'视为缺失的源而非明确的跨域指示。因此攻击者可将表单嵌入沙盒化iframe,提交后利用受害者的认证会话cookie调用服务器动作。该漏洞在Next.js 16.1.7中修复。

## 仍存在的暴露点

上述 CVE 漏洞已有补丁。但部分风险是结构性的,根植于 Flight 的设计原理中。

### 飞行流中间人攻击(MITM)

如果攻击者能位于服务器和客户端之间(CDN 被入侵、缓存投毒、恶意代理),在飞行流传输过程中篡改数据似乎可行。该协议采用结构可预测的明文格式。

假设攻击者能控制流传输,可修改 $I(导入)行,将组件加载重定向到 webpack chunk 映射中的其他模块。可注入 $F(服务引用)标签,在渲染的 UI 中嵌入隐藏的 RPC 触发器。可修改 D(数据)行来更改组件属性,如果目标组件使用了 dangerouslySetInnerHTML,这将直接形成 XSS 攻击路径。

Flight 会对用户输入字符串中的 $ 前缀进行转义,防止数据被解释为协议指令。但这仅适用于经过序列化器处理的数据。MITM 攻击者可直接向流中写入原始协议数据,转义机制无法提供保护。

### 服务动作枚举

服务动作 ID 是构建时生成的混淆哈希值,看起来是随机的。但 server-reference-manifest.json 会将每个动作 ID 映射到其源实现。公开的清单文件会向攻击者暴露完整的 API 地图。这种暴露通常源于托管配置错误、暴露的 .next 目录或路径遍历漏洞。

已知的动作 ID 会使服务动作暴露在标准 IDOR 和参数篡改攻击之下。攻击者可伪造带有篡改参数的直接请求。开发者通常会盲目信任这些输入,因为它们源自 React 的内部机制。这种 misplaced trust(错误信任)带来的架构后果将是我在下一篇文章中的重点分析。

### 加密闭包篡改

当服务动作捕获周围作用域中的变量(闭包)时,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 但永远不会被加载(因为没有组件引用它),处于惰性状态。

但如果攻击者通过 MITM、缓存投毒或服务端注入向 Flight 流中注入 $I 导入引用,解析器应加载这个休眠模块。可能存在我未注意到的 chunk 级验证。但如果模块 ID 有效且存在于清单文件中,我看不到任何阻止加载的因素。攻击无需在代码中任何地方导入该包,只需存在于打包输出中即可。

## 这并非首次发生

React Flight 并不是首个发明自定义序列化格式用于服务端-客户端通信的框架,也不是首个发现这种设计会形成攻击面的框架。它也不会是最后一个。

Google Web Toolkit (GWT) 使用自定义的 RPC 协议在浏览器和服务器之间同步 Java 对象。BishopFox 演示了攻击者如何通过操纵传输格式实现任意反序列化;最终 GWT 完全禁用了二进制序列化。这一过程耗时数年。

Java Server Faces (JSF) 和 ASP.NET 都会将 ViewState 序列化为客户端的隐藏表单字段。当加密签名较弱或缺失时,攻击者可以篡改序列化状态并实现远程代码执行。微软和甲骨文多次修补此问题,但根本性的问题模式反复出现。

这一模式始终如一:框架会发明自定义的传输格式在服务器和客户端之间传输丰富、有状态的、有时可执行的数据。设计者假设服务器是这些数据的唯一生产者,客户端是受信任的消费者。随后总有人证明传输格式可以被篡改,或服务器可以被欺骗以反序列化攻击者控制的输入。React Flight 是这一模式的最新案例。它并非特例。

## 未来发展方向

React Flight 协议解决了真正的难题:以支持渐进式水合、异步数据加载和服务器驱动代码分割的方式,从服务器向客户端流式传输交互式组件树。它确实有效。我不想忽视这一点。

但它的实现方式是通过流式文本协议序列化可执行引用、异步状态、模块指针和 RPC 端点,并在两端都信任该流的结构。React 团队已修复已知的利用点。hasOwnProperty 修复是正确的。拒绝服务修复已就位。源代码暴露漏洞已关闭。

我认为通过网络协议暴露任意属性遍历和可执行 Thenable 重建是一个设计失误。$:、$@ 和 $B 是强大的内部原语,可通过未验证属性所有权的解析器访问。缺少一次检查,结果就是 CVSS 10.0 的漏洞。

> 随着更多框架采用服务器驱动的 UI 模式,行业将需要比“服务器是可信的”更强的原语:对序列化负载进行加密验证、签名组件树,以及对 Flight 流本身进行内容完整性检查。

历史上依赖解析器处理所有边界情况的假设从未奏效,我看不到现在为何会开始奏效。

代码位于 react-client/src/ReactFlightClient.js。如果你部署了服务器组件,请阅读它。了解你的框架代表你信任了哪些内容。

(gg, yk)

了解更多:

Smashing Newsletter 每周发送前端与 UX 技巧,直接送达你的邮箱。只包含真正实用的内容。

前端与 UX 工作坊,在线参与。包含实用收获、直播课程、视频录像和友好的问答环节。

TypeScript 50 课:全面讲解 TypeScript,包含代码解析和示例。还有其他纸质书籍。