深入解析 React 史上最严重的 RCE 漏洞 CVE-2025-55182

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) 有什么不一样?

先看两者的加载流程图:

graph TD subgraph "传统 SSR (Server-Side Rendering)" A[浏览器请求页面] --> B(服务器); B --> C[React 组件渲染成 HTML 字符串]; C --> D[返回完整 HTML]; D --> E(客户端); E --> F[加载 JS 进行 Hydration]; end subgraph "RSC (React Server Components)" G[浏览器请求或触发 UI 更新] --> H(服务器); H --> I[服务器组件执行并序列化为 Flight 数据流]; I --> J[返回 Flight 数据流]; J --> K(客户端 React); K --> L[解析数据流并无缝更新 UI]; end

简单来说:

  • SSR 返回的是"死"的 HTML,客户端需要加载 JavaScript 才能让页面"活"过来(这个过程叫水合,Hydration)。
  • RSC 返回的是"活"的指令,客户端 React 直接执行这些指令来更新 UI,整个过程更丝滑,而且服务器组件的代码完全不影响客户端的包体积。

2. "Flight" 协议:RSC 的专属"航班"

RSC 的指令是如何从服务器飞到客户端的呢?答案是搭乘"Flight"这趟专属航班。

Flight 协议就是 React 定义的一套数据传输格式,专门用来打包和运输 RSC 的渲染结果。

sequenceDiagram participant Client as 客户端 (React App) participant Server as 服务器 (Node.js) Client->>Server: 发起 Flight 请求 (需要 UserProfile 组件) Server->>Server: 执行 UserProfile 服务器组件 Server->>Server: 将组件输出序列化为 Flight 数据格式 Server-->>Client: 流式返回 Flight 数据 Client->>Client: 接收并解析 Flight 数据 Client->>Client: 将新 UI 更新到 DOM 中

3. 反序列化漏洞:过于信任

这是我们理解本次漏洞的核心

  • 序列化:把程序中的数据(比如一个对象)变成一串文本(比如 JSON 字符串),方便存储或传输
  • 反序列化:把那串文本再变回程序里的数据(比如将 JSON 转换为对象)

漏洞出在哪?

出在解析时存在不安全的文本。如果服务器在反序列化时,完全信任收到的数据,那么攻击者就可以在文本里藏一颗"炸弹"。

graph TD subgraph "正常流程" A["对象:{user: 'React'}"] -- 序列化 --> B["JSON:{"user":"React"}"]; B -- 网络传输 --> C(接收端); C -- 反序列化 --> D["还原对象:{user: 'React'}"]; end subgraph "攻击场景" E["恶意对象"] -- 序列化 --> F["恶意 JSON:{"user":"hacker",callback:"恶意代码"}"]; F -- 网络传输 --> G(接收端); G -- 不安全的反序列化 --> H["执行恶意 callback"]; H -- RCE --> I[服务器被控制]; end

想象一下,一个应用在反序列化用户设置时,如果攻击者能控制 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)这致命一击的。

整个攻击过程,可以概括为"三步必杀":

  1. 第一步:空投"特洛伊木马" ------ 构造一个精心设计的恶意 Flight 数据包
  2. 第二步:偷取"万能钥匙" ------ 从 JavaScript 的原型链中"偷"出 Function 构造函数这个危险的工具
  3. 第三步:引爆"逻辑炸弹" ------ 诱导 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 的解析器看到这个引用时,它会进行一场惊心动魄的"原型链攀爬":

graph TD A("$3") -- 解析为 --> B("空数组 []"); B -- .constructor --> C(数组的构造函数 Array); C -- .constructor --> D(Array 构造函数的构造函数
即全局 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. 解析器处理到对象 '1'value 属性,发现里面有 '$B3' 这个特殊指令,进入了 if 分支。
  2. props 被解析为数字 3
  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 达成。服务器的控制权就此易手。

flowchart TD A[接收恶意 Flight 数据包] --> B{解析 Payload}; B --> C(按引用 '$3:constructor:constructor' 查找); C --> D[获得 Function 构造函数并赋值给 _formData.get]; B --> E[解析到 '$B3' 指令]; E --> F["执行 _formData.get(_prefix + '3')"]; F --> G["等价于 Function('恶意代码' + '3')()"]; G --> H[恶意代码在服务器上执行]; H --> I((RCE 成功));

三、修复方案

在如此严重的漏洞面前,React 团队的响应是迅速的。他们很快发布了补丁,修复了 React2Shell。那么,他们是如何修复的呢?

修复的核心思想:"不再信任"

这次漏洞的根源在于对客户端输入数据的"过度信任"。因此,修复的核心思想就顺理成章地变成了------"不再信任"

具体的修复措施可以总结为三板斧:

  1. 增强验证:在反序列化 Flight 数据流的每一步,都加入了严格的类型和格式校验。解析器不再是"盲目"执行,而是像一个严格的安检员,检查每个"包裹"的尺寸、形状、内容是否符合规定,任何不符合预期的可疑数据都会被直接拒绝。

  2. 阻断原型链 :补丁特别封堵了通过 .constructor 属性进行原型链攀爬的路径。这意味着,攻击者无法再通过 [].constructor.constructor 这种方式轻易地拿到 Function 构造函数或其他危险的全局对象。通往"万能钥匙"的密道被彻底堵死了。

  3. 隔离内部状态 :修改了解析逻辑,严格限制了在反序列化过程中对 React 内部状态对象(如 _response)的访问和修改。这相当于给解析器的核心区域加了一道"防火墙",防止攻击者通过伪造的数据来篡改其内部行为。

四、总结

CVE-2025-55182 漏洞虽然已经被修复,但它给我们所有开发者,尤其是前端开发者,敲响了一记响亮的警钟:

永远不要信任来自外部的任何数据

无论是用户的输入、API 的返回,还是像 Flight 这样看似"内部"的协议数据,只要它来源于你的应用之外,就必须被视为有潜在威胁的。不安全反序列化漏洞的本质,就是违背了这条法则,破坏了安全的基石。

相关推荐
shoa_top2 小时前
一文带你掌握 JSONP:从 Script 标签到手写实现
前端·面试
八荒启_交互动画2 小时前
【基础篇007】GeoGebra工具系列_多边形(Polygon)
前端·javascript
清风扶我腰_直上青天三万里2 小时前
vue框架无痛开发浏览器插件,好用!!本人使用脚手架开发了一款浏览器tab主页加收藏网址弹窗,以后可以自己开发需要的插件了!!
前端
知其然亦知其所以然2 小时前
小米的奇幻编程之旅:当 JavaScript 语法变成了一座魔法城
前端·javascript·面试
webkubor2 小时前
一次 H5 表单事故:100vh 在 Android 上到底坑在哪
前端·javascript·vue.js
是一碗螺丝粉2 小时前
突破小程序5层限制:如何用“逻辑物理分离”思维实现无限跳转
前端·架构
神秘的猪头2 小时前
🎉 React 的 JSX 语法与组件思想:开启你的前端‘搭积木’之旅(深度对比 Vue 哲学)
前端·vue.js·react.js
三十_2 小时前
如何正确实现圆角渐变边框?为什么 border-radius 对 border-image 不生效?
前端·css
江公望2 小时前
VUE3 data()函数浅谈
前端·javascript·vue.js