From 95e1b25f4e9336643d0a9834431a09e58b16bf74 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Fri, 4 Sep 2026 14:10:20 +0200 Subject: [PATCH] Harden executable and MPQ safety checks --- setup_tool_dynamic.py | 318 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 284 insertions(+), 34 deletions(-) diff --git a/setup_tool_dynamic.py b/setup_tool_dynamic.py index c7db236..9f7bec6 100644 --- a/setup_tool_dynamic.py +++ b/setup_tool_dynamic.py @@ -6,6 +6,7 @@ import tkinter as tk import types from tkinter import messagebox +import remote_packages import setup_tool_dynamic_core as _dynamic_core # Keep the feature-branch implementation intact and layer only the executable # normalization policy here. This makes the B-total policy easy to audit and @@ -75,26 +76,154 @@ _CUSTOM_GLUES_SITES = ( (0x2F11F1, 0x5E, 0xB2), ) +# Numeric fields are intentionally allowed to keep legitimate values already +# selected by Turtle/Octo/community launchers. Safety comes from a multi-anchor +# 1.12.1/5875 identity check plus tight per-field supported ranges; the exact +# source bytes are then snapshotted so the upstream patcher may only preserve +# them or replace them with the Tool's requested value. +_NUMERIC_SITES = ( + ("fov", 0x4089B4, "FoV", 0.5, 3.5), + ("farclip", 0x40FED8, "Farclip", 777.0, 10000.0), + ("frill", 0x467958, "Frill Distance", 0.0, 1000.0), + ("nameplate", 0x40C448, "Nameplate Distance", 0.0, 150.0), + ("maxcam", 0x4089A4, "Max Camera Distance", 1.0, 250.0), +) +_SOUND_SITE = ("sound", 0x435D38, "Sound Channels", 1, 128) +_CLIENT_BUILD_OFFSET = 0x437BFC +_CLIENT_VERSION_OFFSET = 0x437C04 +_CLIENT_BUILD = b"5875" +_CLIENT_VERSION = b"1.12.1" + + +def _strict_verify_mpq(path): + """Validate the classic MPQ header and table bounds, not only its magic.""" + try: + size = os.path.getsize(path) + if size < 32: + raise remote_packages.RemotePackageError( + "Downloaded MPQ is too small to contain a valid header." + ) + + with open(path, "rb") as handle: + header = handle.read(32) + except remote_packages.RemotePackageError: + raise + except OSError as exc: + raise remote_packages.RemotePackageError( + f"Could not inspect downloaded MPQ: {exc}" + ) from exc + + if len(header) != 32 or header[:4] != b"MPQ\x1A": + raise remote_packages.RemotePackageError( + "Downloaded file is not a valid MPQ archive." + ) + + try: + ( + header_size, + archive_size, + format_version, + sector_size_shift, + hash_table_offset, + block_table_offset, + hash_table_entries, + block_table_entries, + ) = struct.unpack_from(" size: + raise remote_packages.RemotePackageError( + "Downloaded MPQ has an invalid header size." + ) + if archive_size < header_size or archive_size > size: + raise remote_packages.RemotePackageError( + "Downloaded MPQ has an invalid archive size." + ) + if format_version not in (0, 1): + raise remote_packages.RemotePackageError( + f"Downloaded MPQ uses unsupported format version {format_version}." + ) + if sector_size_shift > 16: + raise remote_packages.RemotePackageError( + "Downloaded MPQ has an invalid sector-size shift." + ) + + tables = ( + ("hash", hash_table_offset, hash_table_entries), + ("block", block_table_offset, block_table_entries), + ) + for table_name, table_offset, entry_count in tables: + if entry_count <= 0: + raise remote_packages.RemotePackageError( + f"Downloaded MPQ has an empty {table_name} table." + ) + table_size = entry_count * 16 + if ( + table_offset < header_size + or table_offset > archive_size + or table_size > archive_size - table_offset + ): + raise remote_packages.RemotePackageError( + f"Downloaded MPQ has an out-of-bounds {table_name} table." + ) + + +def _install_strict_mpq_policy(): + """Harden every visual-MPQ path used by the dynamic installer exactly once.""" + if getattr(remote_packages, "_modernization_strict_mpq_policy", False): + return + + original_download_remote_mpq = remote_packages._download_remote_mpq + + def strict_download_remote_mpq(*args, **kwargs): + temp_path = original_download_remote_mpq(*args, **kwargs) + try: + _strict_verify_mpq(temp_path) + except remote_packages.RemotePackageError as exc: + try: + os.remove(temp_path) + except OSError: + pass + label = kwargs.get("label") or "Downloading visual mod" + raise remote_packages.RemoteSourceUnavailable( + f"{label}: remote source returned an invalid MPQ package ({exc})." + ) from exc + return temp_path + + def strict_managed_mpq_is_current(target_dir, mod_id, revision): + if not remote_packages.managed_mod_is_current(target_dir, mod_id, revision): + return False + files = remote_packages._load_managed_manifest(target_dir, mod_id) + if len(files) != 1: + return False + path = os.path.join(target_dir, files[0]) + try: + _strict_verify_mpq(path) + return True + except remote_packages.RemotePackageError: + return False + + remote_packages._verify_mpq = _strict_verify_mpq + remote_packages._download_remote_mpq = strict_download_remote_mpq + remote_packages.managed_mpq_is_current = strict_managed_mpq_is_current + remote_packages._modernization_strict_mpq_policy = True + + +_install_strict_mpq_policy() + class ModernWowSetupTool(_ModernWowSetupToolCore): """Remote-fallback tool with authoritative, fail-safe Vanilla Tweaks output.""" def _vanilla_tweaks_signature(self): signature = super()._vanilla_tweaks_signature() - # v3 adds source preflight and preserves client-owned Custom GlueXML - # regions instead of letting vanilla-tweaks overwrite unknown loader code. - signature["selected_patch_normalization"] = 3 + # v4 adds source build fingerprints and exact staged numeric-state checks. + signature["selected_patch_normalization"] = 4 return signature - @staticmethod - def _validate_float_field(data, offset, label, minimum, maximum): - current = struct.unpack_from("