React2Shell CVE-2025-55182 复现及原理分析
今天早上起来也是被该漏洞披露刷烂了,网安圈几乎都在讨论这个话题:Critical Security Vulnerability in React Server Components
CVSS 评分甚至给到了满分,据说甚至可以媲美当年的 Log4j,赶上这个话题热度借此复现一波
此次复现基于源于上游 React 实现 CVE-2025-55182,使用 CVE-2025-66478 App Router 跟踪对 Next.js 应用程序的下游影响
时间 2025.12.4,网上普遍流行的 poc 只是理想化了显式暴露注册危险模块,正常情况下 Next.js 只注册业务函数
漏洞披露者 Lachlan Davidson 也在 https://react2shell.com/ 发布了关于无效概念验证的说明
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22 const serverManifest = {
'fs': {
id: 'fs',
name: 'readFileSync',
chunks: []
},
'child_process': {
id: 'child_process',
name: 'execSync',
chunks: []
},
'vm': {
id: 'vm',
name: 'runInThisContext',
chunks: []
},
'util': {
id: 'util',
name: 'promisify',
chunks: []
}
};
一直到 2025.12.5,nuclei 发布了关于该漏洞利用的显式 payload,原本的“哑弹”又变成核弹了,可以说只要是符合最低标准的环境确实可以轻易利用
同时 Lachlan Davidson 也发布了自己原始 poc
随后的这几天里陆陆续续爆出了更多的服务器入侵事件,大家慢慢发现越来越多的项目(似乎是一些 Wiki)涉及到 next.js,可以说是防不胜防
项目构建
1 | npx create-next-app@15.0.0 cve --yes |
检查是否包含脆弱 react-server-dom-webpack 包的版本 .\node_modules\next\dist\compiled

其并不是直接依赖,而是高度集成在 Next.js 的构建系统中
复现过程
没错,只需要一个基础框架就可以复现了
1 | POST / |

正如前文所说,只要是符合最低标准的环境,真的能轻易利用,如某知名 AI 产品 Dify;在 Poc 流出过后,其官方也是第一时间发布了 补丁
原理分析
整体上来讲核心漏洞就是原型链污染,再加上 Flight 协议的特殊的序列化逻辑,精心构造了 payload
但遗憾的是 Next.js 项目会进行编译操作,React Server Components、JSX 语法、TypeScript 类型、
import别名、以及区分"use client"和"use server"的边界,所有这些都需要经过编译才能被 Node.js 和浏览器理解。Node.js 原生是不认识 JSX 和 Flight 协议的特殊的序列化逻辑的我尝试了大量的 debug 调试但最终都无果,可能是我的方式存在问题,如果有知道正确调试方法的师傅欢迎留言,这里我们只好从源码来进行代码审计
这篇 文章 对整个 CVE 分析的十分通透,同时也很有条理,我的分析可能存在冗余,感兴趣的师傅也可移步这里
处理 HTTP 请求 handleAction()
精心构造的 action 类型
首先我们的 HTTP 请求会经过 node_modules/next/dist/server/app-render/action-handler.js line 253 的 handleAction() 方法

各个形参的含义
1 | { |
接着会处理并提取数据包括
contentTypeserverActionsManifest(Server Action 的清单,每个 Server Action 都有自己的 ID){ actionId, isURLEncodedAction, isMultipartAction, isFetchAction, isServerAction }(这一部分用于后续判断表单数据提交的类型,而在这里我们提交的就是 multipart 表单,并判断是否为 ServerAction)
Server Actions 是 Next.js App Router 中的服务器端函数,允许我们直接在 React 组件中调用服务器端代码,无需手动创建 API 路由(这和我们平时碰见的 web 项目有些区别,不过这也正是 App Router 的特性,同时说明了该 Payload 不适用于 Pages Router 架构)
其特征通常是标记了
'use server'指令的异步函数,可以在服务器端执行数据库操作、文件系统访问等
如何调用? 从客户端调用,也就是我们 payload 中通过表单提交或 JavaScript 调用
由于这里 next 是编译好的,因此这些定义得从 next.js 的源码 中去找到,位于 packages\next\src\server\app-render\action-handler.ts line 497



getServerActionRequestMetadata() 也清晰的告诉我们当 HTTP 请求中有 Next-Action 头时,代码中只会检查是否存在这个自定义头并且不为空,因此 payload 中用 x 代替就好,这是为后面的对表单解析反序列化操作做铺垫
如果以上三种都不是,则跳过处理
总结一下,handleAction() 一开始检查请求是否为 Server Action?并区分 fetch action(客户端 JavaScript 调用)和 MPA action(表单提交);并精心构造使得 isMultipartAction 和 isFetchAction 均为 true,这样逻辑才会走后续脆弱 react-server-dom-webpack 包下的反序列化操作
关于两种运行时环境下的 HTTP 请求处理方式
- 一种是 Edge Runtime 代码环境,用于处理非流式请求,这里并不是指我们的 HTTP 请求本身是非流式,而是因为该环境下只有浏览器原生 API,必须等待整个 body 加载完成

- 一种是 Node.js Runtime 代码环境,运行在 Node.js 服务器上,其有 完整的 Node.js API,通过 busboy 和 stream 模块可以流式(边读取 HTTP body,边解析字段)处理数据

不管是哪种最后都会有反序列化操作,这里形象化的阐述 Busboy 如何处理数据
1 | 原始 HTTP Body: |
未过滤的反序列化
具体的 decodeReply 逻辑跟进到 node_modules/next/dist/compiled/react-server-dom-webpack/cjs/react-server-dom-webpack-server.node.development.js line 3724 decodeReplyFromBusboy()

先跟进是如何定义 respose 这个对象的

中间三个较为重要,个人认为这里的 response 更多的起到一个载体作用,用于传递的主要 “上下文 “ 对象
1 | response = { |
这里的空字符串是预先传进去的,继续跟进了解 resolveField() 如何解析这三个字段的

resolveField(response, "0", value);先原样存储了 0 的键值对,由于先前
_prefix: "",key 转换成数字 0,最终获取response._chunks.get(0),返回 undefined,根据 JavaScript 的短路求值 特性,resolveModelChunk()不会被调用后续的字段 1 和 2 也是如此,
_formData存储了我们的键值对1
2
3
4
5
6
7
8
9
10
11response = {
_bundlerConfig: serverModuleMap,
_prefix: "",
_formData: FormData {
"0" => '{"then":"$1:__proto__:then","status":"resolved_model",...}',
"1" => '"$@0"',
"2" => '[]'
},
_chunks: Map {},
_temporaryReferences: WeakMap {}
}
值得一提的是,这是 React 本身的特性,应该是为了性能优化策略,故意延迟解析,只有调用某个 chunkID 时才会去解析那个 chunk
因此对于我们的 payload 总是会先填满 _formData
而后我们继续跟进 getChunk(response, 0)

由于之前 chunks 为空,这里会 new 一个 Chunk("resolved_model", chunk, id, response),并返回 chunk 这个 Thenable 对象
1 | function Chunk(status, value, reason, response) { |
await 的触发
回到 handleAction()

当 await 一个对象时,如果有 then 方法则调用
1
2
3
4
5
6
7
8 const obj = {
then(resolve, reject) {
resolve("called!");
}
};
const result = await obj;
console.log(result); // 会打印出来感觉这个点算是这个链子的关键,后续的 payload 也需要它来触发
在 react-server-dom-webpack line 3635,我们看到了 then 方法的定义

这里主要看两种状态:RESOLVED_MODEL = "resolved_model",有 JSON 字符串,尚未解析;INITIALIZED = "fulfilled",完全解码的 JS 值
而之前 new Chunk 对象肯定是还未解析的 JSON 字符串
继续跟进 initializeModelChunk

这里就是漏洞触发点了,在原始字符串上运行 JSON.parse() 以获取 JavaScript 对象
这里是当时学习原型链污染写的一个便于理解的 demo
打印
let o2 = {"a": 1, "__proto__": {"b": 2}}
打印
let o2 = JSON.parse('{"a": 1, "__proto__": {"b": 2}}')
“
__proto__“ 是否为键很关键
没有 JSON.parse,”__proto__“ 是一个特殊的原型属性
有 JSON.parse,”__proto__“ 是一个普通的属性
机制初识
1 | value = reviveModel( |
跟进 reviveModel(),这里开始递归遍历解析后的对象
1 | function reviveModel(response, parentObj, parentKey, value, reference) { |
这里我们用一个简易 demo 来看看会对其做些什么
1 | //FormData 对象 |
void 0 !== reference 等价于 undefined !== 0 为 true

显然进入的是该分支
1 | for (i in value) { // i = "then" |
这一次应该进入 string 分支,跟踪 parseModelString(),由于匹配到了 "$" === value[0],但是没有匹配的 "$" === value[1],因此
1 | value = value.slice(1); // "1:constructor:constructor" |
跟进 getOutlinedModel()
1 | reference = reference.split(":"); // ["1", "constructor", "constructor"] |
此时获取 chunkid 为 1 的块,由于 chunks 里还没有 1,继续 new Chunk("resolved_model", "[]", 1, response)
之后又进入 initializeModelChunk(id)

现在是 chunk1: Chunk {status: "resolved_model", value: "[]", reason: 1, _response: [..}}
1 | var rawModel = JSON.parse(chunk1.value); |
本来应该执行 for 循环,但是数组为空直接返回

随后

1 | chunk.status = "fulfilled"; |
现在 chunk1 就被完全初始化了,注意这里有点绕(要是能调试一下就好了 😣),还记得我们刚刚是在 getOutlinedModel() 初始化 chunk1 对吗?

这里有两个开关语句,现在该进入第二个 switch (id.status),此时 chunk1 的状态已经是 "fulfilled"
1 | parentObject = id.value; // [] |
相当于 []["constructor"]["constructor"],最终我们获取了 Function
在这里你需要理解 NodeJs 有关继承机制,有三个重要概念
prototype(原型)
prototype是函数(特别是构造函数)才拥有的一个属性,用来存储该构造函数 创建的所有实例 应该共享的属性和方法
__proto__(隐式原型)
__proto__是对象实例才拥有的一个属性,它指向创建该对象的构造函数 的prototype对象
1
2
3
4 function Person() {}
const person = new Person();
console.log(person.__proto__ === Person.prototype); // true
constructor(构造器)它的值指向拥有这个
prototype对象的构造函数本身,即Function,更形象一点是由哪个函数构造出来的,比如一个空数组 [] 是 Array 函数构造出来的
1
2
3
4 function Person() {}
const person = new Person();
console.log(Person.prototype.constructor === Person); // true对于我们这里的数组字面量
parentObject = [],是一个实例对象,因此没有prototype属性,有__proto__属性,也有constructor属性
[]["constructor"]["constructor"]等价于[].constructor.constructor,先是[Function: Array],再是[Function: Function],而这是 JavaScript 的动态构造函数,可以接受字符串形式的函数体并返回一个新函数,有点类似与 eval,其在全局作用域中运行
现在这个 Function 就是 getOutlinedModel() 的返回值,其是 parseModelString() 的返回值,接着还记得刚刚是递归调用了 reviveModel() 吗?

因此 value[i] = parentObj,value = { then: Function },回到 initializeModelChunk(chunk0)
1 | chunk0.status = "fulfilled"; |
现在返回 then 方法,进入第二个 switch 语句

至此整个分析就结束了,这个简易 demo 会出现语法错误,await 期望一个 thenable 对象,对象需要有一个 then 方法,这里 then 的值是 Function 构造函数本身,不是一个方法
开始“加料”
不过我们可以看到源 payload 还有其它参数,这些精心构造才是其精髓,之前这个 payload 只能获取一个返回值,为了更多操作的可能性,我们需要在 then 键的位置添加一个实际的 thenable,并希望获取一个 chunk 对象,因此有了以下 payload(这里就不再花大量篇幅去一步步走逻辑了)
1 | const payload = { |
现在我们已经获取了 .then 方法了,但可以控制 chunk 的更多属性,例如 status,reason 等
下面这个 payload 你会理解到 .then 的妙用
1 | const payload = { |

外层先匹配 "fulfilled",接着 resolve 匹配到这个进行构造的内嵌 thenable 对象,又返回到了开头!
由于这个 chunk 没有 value 以及 response 属性,后面肯定会出现 undefined,那我们就继续在 payload 手动添加这些属性!
在 parseModelString() 中有一个 $B
1 | case "B": |
如果我们将_formData 的 get 属性设置为 Function,在 _prefix 设置真正的恶意代码,那这不就实现了攻击吗(后面这个 obj 可以选择注释也可以不用,经验证不会影响结果)
1 | const payload = { |
现在的问题是中间还需要一些链子才能到我们最终想要实现的部分,由于之前是 value 会被进行 JSON.parse 处理,感觉我们 $B 的载荷得放到这个属性上,并且其最终得成为一个 thenable 对象,方便 await 调用触发
1 | "value":"{\"then\":\"$B0\"}" |
这里转义符号是为了确保第一次初始化时,将 value 值看成字符串,直到后续的内嵌 chunk 才会真正解析;而这里没有通过 [] 来构造 Function,这是因为我们也可以利用 string 来构造 Function 一样的,因此最小化 payload 为
1 | POST / |
至于源payload中出现的"_chunks": "$Q2",我暂时没有弄清楚作者的缘由

调用栈总结
1 | handleaction() // 识别成Server Action |
真的太复杂了,能想出这种链子的人得多牛啊…
总结
软件栈
应用必须运行在未打补丁的 Next.js 或 React Server Components 环境中:
- Next.js 版本: 运行在 v15.0.5 之前的版本,或者其他包含脆弱
react-server-dom-webpack包的版本 - 底层 React 版本: 依赖或直接使用 React 19.0.0-rc 或 19.1.0-rc 等包含漏洞实现的核心版本
代码特性
应用程序必须采用了暴露漏洞入口的架构和功能:
- 启用 App Router 架构: 应用必须使用 Next.js 的 App Router 架构(而不是 Pages Router),因为只有 App Router 才默认启用 React Server Components (RSC)
- 暴露 Server Action/Function 端点: 应用中至少要定义和导出一个 Server Action 或 Server Function。这会在服务器上创建一个可供客户端调用的 HTTP 端点,这个端点就是攻击者发送恶意 Flight Payload 的目标入口
网络环境
攻击者必须具备网络能力来接触到目标端点:
- 无须认证的访问: 攻击者必须能够向目标服务器发送 未经身份验证的 HTTP
POST请求。由于 Server Actions 的特性,该漏洞是 Pre-Auth RCE,不需要登录或特殊权限 - 请求类型: 攻击者需要构造一个带有特定 Header(如
Next-Action)的请求,并将恶意 Payload 作为请求体发送给服务器







