2025 年 12 月 3 日,React 官方联合 Vercel 发布紧急安全公告,披露 React Server Components(RSC)存在致命远程代码执行漏洞(CVE-2025-55182),CVSS 评分高达 10.0(最高风险等级)。该漏洞可通过构造恶意表单请求,在未授权情况下执行服务器任意代码,影响数百万基于 React RSC 和 Next.js 的应用。
这个漏洞波及所有使用了受影响版本 React Server Components 的应用。RSC 作为 React 18 的明星特性,以其独特的"服务器直出组件"能力,正被越来越多的框架(如 Next.js)所集成。然而,正是这个被寄予厚望的特性,却打开了潘多拉的魔盒。这意味着攻击者可以不费吹灰之力,在你的服务器上执行任意代码,你的服务器在他们面前几乎是"裸奔"状态。当然,如果你只是使用 React 在客户端渲染,是不受该漏洞影响的。
本文将详细解析这一严重漏洞,揭开 CVE-2025-55182(又称 React2Shell)的神秘面纱。主要包含以下内容:
- 理解核心概念:什么是 RSC?什么是反序列化漏洞?
- 深入攻击原理:攻击者是如何构造恶意"数据包"的?
- 源码级剖析 :
react-server-dom的代码里到底藏着什么"后门"? - 学习与反思:我们能从这次事件中学到什么?
一、理解漏洞的必备知识
在解析漏洞之前,我们必须先理解三个关键概念:RSC、Flight 协议和反序列化漏洞。
1. React Server Components:前端开发的"新科技"
你可以把 RSC 想象成一种"混血"组件。它在服务器上运行,可以直接读数据库、访问文件,就像后端代码一样;但它的产物又不是冷冰冰的 HTML,而是一种能被客户端 React "听懂"的特殊指令。
和传统服务端渲染 (SSR) 有什么不一样?
先看两者的加载流程图:
简单来说:
- SSR 返回的是"死"的 HTML,客户端需要加载 JavaScript 才能让页面"活"过来(这个过程叫水合,Hydration)。
- RSC 返回的是"活"的指令,客户端 React 直接执行这些指令来更新 UI,整个过程更丝滑,而且服务器组件的代码完全不影响客户端的包体积。
2. "Flight" 协议:RSC 的专属"航班"
RSC 的指令是如何从服务器飞到客户端的呢?答案是搭乘"Flight"这趟专属航班。
Flight 协议就是 React 定义的一套数据传输格式,专门用来打包和运输 RSC 的渲染结果。
3. 反序列化漏洞:过于信任
这是我们理解本次漏洞的核心
- 序列化:把程序中的数据(比如一个对象)变成一串文本(比如 JSON 字符串),方便存储或传输
- 反序列化:把那串文本再变回程序里的数据(比如将 JSON 转换为对象)
漏洞出在哪?
出在解析时存在不安全的文本。如果服务器在反序列化时,完全信任收到的数据,那么攻击者就可以在文本里藏一颗"炸弹"。
想象一下,一个应用在反序列化用户设置时,如果攻击者能控制 JSON,并传入这样的内容:
json
{
"username": "attacker",
"on_load": "function() { require('child_process').execSync('rm -rf /'); }"
}
如果服务器傻傻地把 on_load 的值当作函数来执行,那后果不堪设想。
React2Shell 漏洞,本质上就是 react-server-dom 在处理 Flight "航班"送来的"包裹"时,发生了极其危险的不安全反序列化。
现在,基础知识已经铺垫完毕。下一章,我们将正式进入源码,看看攻击者是如何一步步利用这个缺陷,最终实现远程代码执行(RCE)的。
二、深入源码:RCE 的三步必杀技
接下来,我们将一步步还原攻击者是如何利用 react-server-dom 的设计缺陷,完成远程代码执行(RCE)这致命一击的。
整个攻击过程,可以概括为"三步必杀":
- 第一步:空投"特洛伊木马" ------ 构造一个精心设计的恶意 Flight 数据包
- 第二步:偷取"万能钥匙" ------ 从 JavaScript 的原型链中"偷"出
Function构造函数这个危险的工具 - 第三步:引爆"逻辑炸弹" ------ 诱导 React 的解析器执行一个看似无害、实则致命的操作,引爆炸弹
让我们逐一分解。
1. 空投"特洛伊木马"(恶意 Payload)
攻击的起点,是向服务器发送一个特制的 HTTP POST 请求。这个请求的 body 部分,是一个 multipart/form-data 格式的数据,也就是我们前面说的 Flight "包裹"。
这个"包裹"里藏着攻击者的"特洛伊木马"。下面是一个简化的 Payload,我们会逐一拆解:
javascript
// 这是一个简化的 Payload
const payload = {
// 入口点,告诉解析器从对象 '1' 开始处理
'0': '$1',
// 伪装成一个 Promise 的核心对象
'1': {
// ... (一些迷惑性的属性)
'value': '{"then":"$3:map","0":{"then":"$B3"},"length":1}', // 触发 Promise 链的核心
'_response': '$4', // 指向我们伪造的"响应"对象,里面藏着"武器"
'then': '$2:then' // 让自己表现得像一个 Promise
},
// 一个真正的 Promise,用来驱动流程
'2': '$@3',
// 一个普通的空数组,但它的构造函数是我们的目标
'3': [],
// 伪造的"Response"对象,携带了我们的"武器"和"弹药"
'4': {
// "弹药":想要执行的恶意代码
'_prefix': 'require("child_process").execSync("touch /tmp/pwned"); //',
// "武器":指向 Function 构造函数的"小工具" (Gadget)
'_formData': {
'get': '$3:constructor:constructor'
},
}
}
这个 payload 的核心思想是利用 Flight 协议的引用机制($ 符号),将一堆普通的数据对象('1', '4' 等)包装打扮成 React 内部处理流程中的关键角色,比如 Chunk(数据块)和 Response(响应对象),从而骗过服务器的解析器。
2. 偷取"万能钥匙"(Function 构造函数)
在 JavaScript 的世界里,eval() 函数因为能直接执行字符串代码,早已臭名昭著。但它有个"兄弟"------Function() 构造函数,同样能实现动态代码执行,却更容易被忽视。
javascript
// 这两种方式都能执行字符串里的代码
eval('console.log("Hello from eval")');
new Function('console.log("Hello from Function constructor")')();
攻击者的目标,就是拿到这个 Function 构造函数。他们是怎么做到的呢?答案藏在 payload 的 '4' 号对象里:
'get': '$3:constructor:constructor'
当 react-server-dom 的解析器看到这个引用时,它会进行一场惊心动魄的"原型链攀爬":
即全局 Function 对象); D -- 赋值给 --> E(_formData.get 属性);
通过两次 .constructor 的访问,攻击者从一个平平无奇的空数组 [] 出发,最终摸到了 JavaScript 的"万能钥匙"------全局 Function 对象!
现在,攻击者的状态是:
- "弹药"已上膛 :恶意代码字符串
require('child_process')...存放在_prefix属性中。 - "武器"已到手 :
_formData.get属性已经变成了Function构造函数。
至此万事俱备,只欠东风。
3. 第三步:引爆"逻辑炸弹" (触发 RCE)
最后的引爆点,隐藏在 react-server-dom 包处理 Flight 数据的某个函数(如 parseModel)中(已简化):
javascript
// react-server-dom-webpack/server.node.js (简化后)
function resolveModel(response, id, model) {
// ... 省略大量代码 ...
const value = model.value; // value 可能是 '{"then":"$B3"...}'
// 当解析器遇到 '$B' 开头的特殊指令时
if (value[0] === '$' && value[1] === 'B') {
const props = JSON.parse(value.slice(2)); // props 会被解析为 3
// 致命的引爆点
return response._formData.get(response._prefix + props.toString());
}
// ...
}
当我们的恶意 payload 被送到这里时,一场"完美的风暴"形成了:
- 解析器处理到对象
'1'的value属性,发现里面有'$B3'这个特殊指令,进入了if分支。 props被解析为数字3。- 最关键的一行代码被执行:
response._formData.get(response._prefix + props.toString())。
我们把变量替换成攻击者准备好的东西:
response:就是我们伪造的'4'号对象。response._formData.get:就是Function构造函数。response._prefix:就是恶意代码字符串require('child_process')...。props.toString():就是字符串'3'。
所以,这行代码实际上等价于:
javascript
Function('require("child_process").execSync("touch /tmp/pwned"); //' + '3')
一个新的匿名函数被动态创建,函数体就是攻击者注入的恶意代码。
由于 React 内部的 Promise 链处理机制,这个刚刚诞生的函数会被立即调用执行。
攻击者成功在服务器上执行了任意代码,RCE 达成。服务器的控制权就此易手。
三、修复方案
在如此严重的漏洞面前,React 团队的响应是迅速的。他们很快发布了补丁,修复了 React2Shell。那么,他们是如何修复的呢?
修复的核心思想:"不再信任"
这次漏洞的根源在于对客户端输入数据的"过度信任"。因此,修复的核心思想就顺理成章地变成了------"不再信任"。
具体的修复措施可以总结为三板斧:
-
增强验证:在反序列化 Flight 数据流的每一步,都加入了严格的类型和格式校验。解析器不再是"盲目"执行,而是像一个严格的安检员,检查每个"包裹"的尺寸、形状、内容是否符合规定,任何不符合预期的可疑数据都会被直接拒绝。
-
阻断原型链 :补丁特别封堵了通过
.constructor属性进行原型链攀爬的路径。这意味着,攻击者无法再通过[].constructor.constructor这种方式轻易地拿到Function构造函数或其他危险的全局对象。通往"万能钥匙"的密道被彻底堵死了。 -
隔离内部状态 :修改了解析逻辑,严格限制了在反序列化过程中对 React 内部状态对象(如
_response)的访问和修改。这相当于给解析器的核心区域加了一道"防火墙",防止攻击者通过伪造的数据来篡改其内部行为。
四、总结
CVE-2025-55182 漏洞虽然已经被修复,但它给我们所有开发者,尤其是前端开发者,敲响了一记响亮的警钟:
永远不要信任来自外部的任何数据
无论是用户的输入、API 的返回,还是像 Flight 这样看似"内部"的协议数据,只要它来源于你的应用之外,就必须被视为有潜在威胁的。不安全反序列化漏洞的本质,就是违背了这条法则,破坏了安全的基石。