Преглед изворни кода

Merge pull request #26 from zeex64/feat_patch_pkg_verification

feat: allow custom package loading
stan пре 3 недеља
родитељ
комит
08348ad64d

+ 2 - 0
Sunrise/Sunrise.vcxproj

@@ -211,6 +211,7 @@
     <ClCompile Include="src\client\memory\current_process_memory.cpp" />
     <ClCompile Include="src\client\hooks\network\investment\investment_derived_rebuild.cpp" />
     <ClCompile Include="src\client\hooks\network\investment\investment_family5_rearm.cpp" />
+    <ClCompile Include="src\client\hooks\package_trust\package_trust_bypass.cpp" />
     <ClCompile Include="src\client\hooks\bitmap\bitmap_hook_lifecycle.cpp" />
     <ClCompile Include="src\client\hooks\bitmap\bitmap_ref_guard.cpp" />
     <ClCompile Include="src\client\hooks\external_server\external_server_route.cpp" />
@@ -1083,6 +1084,7 @@
     <ClInclude Include="src\client\hooks\network\investment\internal.h" />
     <ClInclude Include="src\client\hooks\queuez\internal.h" />
     <ClInclude Include="src\client\hooks\queuez\queuez_hook_lifecycle.h" />
+    <ClInclude Include="src\client\hooks\package_trust\package_trust_bypass.h" />
     <ClInclude Include="src\client\patterns\game\assert_handler\assert_signature_bytes.h" />
     <ClInclude Include="src\client\patterns\game\config_getter\config_getter_signature_bytes.h" />
     <ClInclude Include="src\client\patterns\game\packages\package_key_signature_bytes.h" />

+ 220 - 0
Sunrise/src/client/hooks/package_trust/package_trust_bypass.cpp

@@ -0,0 +1,220 @@
+#include "package_trust_bypass.h"
+
+#include <Windows.h>
+
+#include <algorithm>
+#include <array>
+#include <cstddef>
+#include <cstdint>
+#include <cstdio>
+#include <cstring>
+#include <string_view>
+
+#include "../../../core/logging/log.h"
+#include "../../hooking/detour.h"
+#include "../../patterns/image_scan.h"
+
+namespace sunrise::client::hooks::package_trust {
+namespace {
+
+using patterns::scan_main_image_unique;
+using patterns::signature;
+using patterns::signature_length;
+
+/**
+ * Native package-header validator. The matched prologue tests its sixth argument and returns -93
+ * when RSA verification did not mark the header trusted. The following bytes enter the ordinary
+ * structural checks, keeping this target distinct from the RSA implementation itself.
+ */
+constexpr std::string_view kValidatorSignatureText =
+    "40 53 48 83 EC 20 80 7C 24 58 00 44 0F B7 DA 4C 8B D1 BB 01 00 00 00 "
+    "75 0D BB A3 FF FF FF 8B C3 48 83 C4 20 5B C3";
+/** Masked form of the validator text, which is the form the image scan takes. */
+constexpr auto kValidatorSignature =
+    signature<signature_length(kValidatorSignatureText)>(kValidatorSignatureText);
+
+/**
+ * The patchable registrar's extended-header authentication failure. Native code loads -89 here,
+ * then joins the common result/cleanup path. The site is unique in this client build.
+ */
+constexpr std::string_view kExtendedHeaderFailureText = "B8 A7 FF FF FF E9 ? ? ? ?";
+constexpr auto kExtendedHeaderFailure =
+    signature<signature_length(kExtendedHeaderFailureText)>(kExtendedHeaderFailureText);
+
+/**
+ * Cached-data authentication gate used while registering/loading base packages. The hash routine
+ * returns a boolean in AL. Native code conditionally jumps to the ordinary success continuation;
+ * otherwise it enters the unique "Failed to validate cached data hash" error path with -89.
+ */
+constexpr std::string_view kCachedDataHashGateText =
+    "84 C0 0F 85 ? ? ? ? E9 ? ? ? ? 48 8B 45 48 89 08";
+/** Masked form of the gate text, which is the form the image scan takes. */
+constexpr auto kCachedDataHashGate =
+    signature<signature_length(kCachedDataHashGateText)>(kCachedDataHashGateText);
+
+/** Only the MOV EAX immediate changes; the native continuation remains untouched. */
+constexpr std::size_t kResultImmediateOffset = 1;
+constexpr std::array<std::byte, 4> kSuccessResult{
+    std::byte{0x01}, std::byte{0x00}, std::byte{0x00}, std::byte{0x00}};
+
+/** Replace JNZ rel32 (0F 85) with NOP; JMP rel32 (90 E9), retaining its native destination. */
+constexpr std::size_t kCachedDataBranchOffset = 2;
+constexpr std::array<std::byte, 2> kAlwaysTakeSuccessBranch{std::byte{0x90}, std::byte{0xE9}};
+
+/** ABI recovered from the validator's native call site. */
+using ValidateHeader = std::int32_t(__fastcall*)(const std::uint32_t* validationMask,
+                                                 std::uint16_t packageGroup,
+                                                 std::uint64_t buildSignature,
+                                                 std::int32_t expectedFileSize,
+                                                 std::uint16_t localeToken,
+                                                 std::uint8_t rsaTrusted,
+                                                 const void* header) noexcept;
+
+hooking::detour::Handle g_handle{};
+std::byte* g_extendedHeaderResult{};
+std::array<std::byte, kSuccessResult.size()> g_extendedHeaderOriginal{};
+std::byte* g_cachedDataBranch{};
+std::array<std::byte, kAlwaysTakeSuccessBranch.size()> g_cachedDataBranchOriginal{};
+
+/** Writes instruction bytes and restores the page's original protection. */
+template <std::size_t Size>
+[[nodiscard]] bool write_code(std::byte* destination,
+                              const std::array<std::byte, Size>& value) noexcept {
+    if (destination == nullptr) {
+        return false;
+    }
+    DWORD originalProtection = 0;
+    if (VirtualProtect(destination, value.size(), PAGE_EXECUTE_READWRITE, &originalProtection)
+        == FALSE) {
+        return false;
+    }
+    std::memcpy(destination, value.data(), value.size());
+    FlushInstructionCache(GetCurrentProcess(), destination, value.size());
+    DWORD ignored = 0;
+    return VirtualProtect(destination, value.size(), originalProtection, &ignored) != FALSE;
+}
+
+/** Runs every native validation rule while forcing only the RSA result to trusted. */
+std::int32_t __fastcall validate_header(const std::uint32_t* validationMask,
+                                        std::uint16_t packageGroup,
+                                        std::uint64_t buildSignature,
+                                        std::int32_t expectedFileSize,
+                                        std::uint16_t localeToken,
+                                        std::uint8_t,
+                                        const void* header) noexcept {
+    if (header != nullptr) {
+        const auto* const bytes = static_cast<const std::byte*>(header);
+        std::uint16_t packageId = 0;
+        std::uint16_t patchId = 0;
+        std::uint32_t headerFileSize = 0;
+        std::memcpy(&packageId, bytes + 0x04, sizeof packageId);
+        std::memcpy(&patchId, bytes + 0x20, sizeof patchId);
+        std::memcpy(&headerFileSize, bytes + 0x164, sizeof headerFileSize);
+        if (headerFileSize != static_cast<std::uint32_t>(expectedFileSize)) {
+            std::array<char, 256> event{};
+            const int length = std::snprintf(event.data(),
+                                             event.size(),
+                                             "ev=package_trust stage=header_size result=mismatch "
+                                             "package=0x%04X patch=%u header=%u expected=%u",
+                                             packageId,
+                                             patchId,
+                                             headerFileSize,
+                                             static_cast<std::uint32_t>(expectedFileSize));
+            if (length > 0) {
+                core::log::write(core::log::Channel::client,
+                                 core::log::Level::error,
+                                 std::string_view(event.data(),
+                                                  (std::min)(static_cast<std::size_t>(length),
+                                                             event.size() - 1)));
+            }
+        }
+    }
+    const auto original = reinterpret_cast<ValidateHeader>(g_handle.original);
+    return original(
+        validationMask, packageGroup, buildSignature, expectedFileSize, localeToken, 1, header);
+}
+
+} // namespace
+
+/** Attaches the native package-header trust bypass. */
+bool install() noexcept {
+    if (g_handle.attached) {
+        return true;
+    }
+    std::byte* const target =
+        scan_main_image_unique(kValidatorSignature, "package_header_validator");
+    std::byte* const extendedHeaderFailure =
+        scan_main_image_unique(kExtendedHeaderFailure, "package_extended_header_failure");
+    std::byte* const cachedDataHashGate =
+        scan_main_image_unique(kCachedDataHashGate, "package_cached_data_hash_gate");
+    if (target == nullptr || extendedHeaderFailure == nullptr || cachedDataHashGate == nullptr) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::error,
+                         "ev=package_trust stage=resolve result=fail");
+        return false;
+    }
+    const hooking::detour::Spec spec{target, reinterpret_cast<void*>(&validate_header)};
+    if (!hooking::detour::install(spec, g_handle)) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::error,
+                         "ev=package_trust stage=attach result=fail");
+        return false;
+    }
+    g_extendedHeaderResult = extendedHeaderFailure + kResultImmediateOffset;
+    std::memcpy(
+        g_extendedHeaderOriginal.data(), g_extendedHeaderResult, g_extendedHeaderOriginal.size());
+    if (!write_code(g_extendedHeaderResult, kSuccessResult)) {
+        (void)hooking::detour::uninstall(g_handle);
+        g_extendedHeaderResult = nullptr;
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::error,
+                         "ev=package_trust stage=extended_header result=fail");
+        return false;
+    }
+    g_cachedDataBranch = cachedDataHashGate + kCachedDataBranchOffset;
+    std::memcpy(
+        g_cachedDataBranchOriginal.data(), g_cachedDataBranch, g_cachedDataBranchOriginal.size());
+    if (!write_code(g_cachedDataBranch, kAlwaysTakeSuccessBranch)) {
+        (void)write_code(g_extendedHeaderResult, g_extendedHeaderOriginal);
+        (void)hooking::detour::uninstall(g_handle);
+        g_extendedHeaderResult = nullptr;
+        g_cachedDataBranch = nullptr;
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::error,
+                         "ev=package_trust stage=cached_data result=fail");
+        return false;
+    }
+    core::log::write(core::log::Channel::client,
+                     core::log::Level::info,
+                     "ev=package_trust stage=attach result=ok mode=package_integrity_bypass");
+    return true;
+}
+
+/** Detaches the native package-header trust bypass. */
+bool uninstall() noexcept {
+    bool restored = true;
+    if (g_cachedDataBranch != nullptr) {
+        const bool cachedDataRestored = write_code(g_cachedDataBranch, g_cachedDataBranchOriginal);
+        restored = restored && cachedDataRestored;
+        if (cachedDataRestored) {
+            g_cachedDataBranch = nullptr;
+        }
+    }
+    if (g_extendedHeaderResult != nullptr) {
+        const bool extendedHeaderRestored =
+            write_code(g_extendedHeaderResult, g_extendedHeaderOriginal);
+        restored = restored && extendedHeaderRestored;
+        if (extendedHeaderRestored) {
+            g_extendedHeaderResult = nullptr;
+        }
+    }
+    const bool detached = !g_handle.attached || hooking::detour::uninstall(g_handle);
+    return restored && detached;
+}
+
+/** @return True while the validator detour is attached. */
+bool is_installed() noexcept {
+    return g_handle.attached && g_extendedHeaderResult != nullptr && g_cachedDataBranch != nullptr;
+}
+
+} // namespace sunrise::client::hooks::package_trust

+ 18 - 0
Sunrise/src/client/hooks/package_trust/package_trust_bypass.h

@@ -0,0 +1,18 @@
+#pragma once
+
+namespace sunrise::client::hooks::package_trust {
+
+/**
+ * Accepts package RSA, extended-header hash and cached-data hash authentication. Native package
+ * parsing, decompression, file-size, table and bounds validation remain active.
+ * @return True when the validator detour is attached.
+ */
+[[nodiscard]] bool install() noexcept;
+
+/** @return True when the validator detour is detached. */
+[[nodiscard]] bool uninstall() noexcept;
+
+/** @return True while the validator detour is attached. */
+[[nodiscard]] bool is_installed() noexcept;
+
+} // namespace sunrise::client::hooks::package_trust

+ 9 - 0
Sunrise/src/client/runtime/client_hook_activation.cpp

@@ -20,6 +20,7 @@
 #include "../hooks/graphics/graphics_hook_lifecycle.h"
 #include "../hooks/network/runtime.h"
 #include "../hooks/noclip/runtime.h"
+#include "../hooks/package_trust/package_trust_bypass.h"
 #include "../hooks/polled_input/runtime.h"
 #include "../hooks/queuez/queuez_hook_lifecycle.h"
 #include "../hooks/retail_log/retail_log_lifecycle.h"
@@ -126,9 +127,16 @@ void clear_game_targets() noexcept {
         report_resolve_failure();
         return false;
     }
+    // Steam initialization installs package trust before base-package registration. Keep this
+    // idempotent check beside the other main-image hooks so activation also verifies ownership.
+    if (!hooks::package_trust::install()) {
+        clear_game_targets();
+        return false;
+    }
     // The SignOn config blob carries this token. It must reach State before any hook owns the
     // resolved targets: extraction cannot recover from a missing bootstrap token.
     if (!content::bootstrap::publish_token()) {
+        (void)hooks::package_trust::uninstall();
         clear_game_targets();
         return false;
     }
@@ -137,6 +145,7 @@ void clear_game_targets() noexcept {
                          core::log::Level::error,
                          "ev=activate stage=game_network result=fail");
         if (!hooks::network::has_game_ownership()) {
+            (void)hooks::package_trust::uninstall();
             clear_game_targets();
         }
         return false;

+ 8 - 0
Sunrise/src/client/runtime/client_runtime_lifecycle.cpp

@@ -8,6 +8,7 @@
 #include "../hooks/graphics/graphics_hook_lifecycle.h"
 #include "../hooks/network/runtime.h"
 #include "../hooks/noclip/runtime.h"
+#include "../hooks/package_trust/package_trust_bypass.h"
 #include "../hooks/polled_input/runtime.h"
 #include "../hooks/queuez/queuez_hook_lifecycle.h"
 #include "../hooks/retail_log/retail_log_lifecycle.h"
@@ -48,6 +49,13 @@ bool shutdown() noexcept {
         ReleaseSRWLockExclusive(&runtime::g_lock);
         return false;
     }
+    if (!hooks::package_trust::uninstall()) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::error,
+                         "ev=shutdown stage=package_trust result=fail");
+        ReleaseSRWLockExclusive(&runtime::g_lock);
+        return false;
+    }
     hooks::bitmap::uninstall();
     hooks::bootflow::uninstall();
     hooks::noclip::uninstall();

+ 1 - 1
Sunrise/src/state/content_manifest/cache/content_manifest_cache_reader.cpp

@@ -129,7 +129,7 @@ LoadStatus load(const wchar_t* path,
     count = header.rowCount;
     const std::span<const Row> occupied = rows.first(count);
     Fingerprint computed{};
-    if (!valid(occupied) || !fingerprint::rows(occupied, computed)
+    if (!valid(occupied) || !fingerprint::catalog(occupied, directoryFingerprint, computed)
         || computed != header.buildFingerprint) {
         clear_rows(rows, count);
         return LoadStatus::invalid;

+ 3 - 3
Sunrise/src/state/content_manifest/cache/format.h

@@ -11,10 +11,10 @@ namespace sunrise::state::content_manifest::cache {
 /** 8 owned ASCII bytes mark generated content-manifest caches. */
 inline constexpr std::array<char, 8> kCacheMagic{'S', 'U', 'N', 'C', 'M', 'A', 'N', 'F'};
 /**
- * Version 2 stores one public row per installed file with two SHA-256 ids. Version 1 kept only
- * the highest patch per package id, so its rows cannot be reused.
+ * Version 3 binds the public manifest id to package sizes and write times. Version 2 used only
+ * public row fields, allowing the Client to reuse stale header bytes after a package changed.
  */
-inline constexpr std::uint32_t kCacheVersion = 2;
+inline constexpr std::uint32_t kCacheVersion = 3;
 
 #pragma pack(push, 1)
 /** Fixed prefix that checks cache version, layout, directory and selected build. */

+ 1 - 0
Sunrise/src/state/content_manifest/content_manifest_state_runtime.cpp

@@ -123,6 +123,7 @@ bool initialize(void* module, std::wstring_view packagesDirectory) noexcept {
         core::ui::busy::begin(core::ui::busy::Task::manifestBuild);
         complete = scanner::extract(packagesDirectory,
                                     std::span(g_candidates).first(candidateCount),
+                                    directoryFingerprint,
                                     g_stagedRows,
                                     rowCount,
                                     buildFingerprint,

+ 22 - 0
Sunrise/src/state/content_manifest/fingerprint/content_manifest_fingerprint.cpp

@@ -19,6 +19,16 @@ constexpr std::array<std::byte, 20> kRowDomain{
     std::byte{'t'}, std::byte{'e'}, std::byte{'n'}, std::byte{'t'},    std::byte{'R'},
     std::byte{'o'}, std::byte{'w'}, std::byte{'s'}, kDomainTerminator, kRowDomainVersion,
 };
+/** Catalog version 1 binds public rows to package sizes and write times. */
+constexpr std::byte kCatalogDomainVersion{1};
+/** This domain separates client cache identities from their component hashes. */
+constexpr std::array<std::byte, 23> kCatalogDomain{
+    std::byte{'S'}, std::byte{'u'},    std::byte{'n'},        std::byte{'r'}, std::byte{'i'},
+    std::byte{'s'}, std::byte{'e'},    std::byte{'C'},        std::byte{'o'}, std::byte{'n'},
+    std::byte{'t'}, std::byte{'e'},    std::byte{'n'},        std::byte{'t'}, std::byte{'C'},
+    std::byte{'a'}, std::byte{'t'},    std::byte{'a'},        std::byte{'l'}, std::byte{'o'},
+    std::byte{'g'}, kDomainTerminator, kCatalogDomainVersion,
+};
 /** UUID version 8 reserves the payload bits for this deterministic public hash. */
 constexpr std::uint8_t kUuidVersionBits = 0x80;
 /** RFC UUID variants set the high 2 bits of byte 8 to binary 10. */
@@ -168,6 +178,18 @@ bool rows(std::span<const Row> manifestRows, Fingerprint& output) noexcept {
     return hasher.finish(output);
 }
 
+/** Builds the client-visible identity for rows and their exact installed inventory revision. */
+bool catalog(std::span<const Row> manifestRows,
+             const Fingerprint& directoryFingerprint,
+             Fingerprint& output) noexcept {
+    output = {};
+    Fingerprint rowFingerprint{};
+    Hasher hasher;
+    return rows(manifestRows, rowFingerprint) && hasher.update(kCatalogDomain)
+           && hasher.update(directoryFingerprint) && hasher.update(rowFingerprint)
+           && hasher.finish(output);
+}
+
 /** Formats an application-defined UUID from a public row fingerprint. */
 void guid(const Fingerprint& buildFingerprint, Guid& output) noexcept {
     output = {};

+ 11 - 0
Sunrise/src/state/content_manifest/fingerprint/content_manifest_fingerprint.h

@@ -64,6 +64,17 @@ private:
  */
 [[nodiscard]] bool rows(std::span<const Row> rows, Fingerprint& output) noexcept;
 
+/**
+ * Builds the client-visible manifest identity from public rows and installed-file metadata.
+ * @param rows Sorted, already-checked rows.
+ * @param directoryFingerprint Identity covering package names, sizes, and write times.
+ * @param output Cleared SHA-256 result.
+ * @return True when both identity components are hashed.
+ */
+[[nodiscard]] bool catalog(std::span<const Row> rows,
+                           const Fingerprint& directoryFingerprint,
+                           Fingerprint& output) noexcept;
+
 /**
  * Formats an application-defined UUID from a public row fingerprint.
  * @param buildFingerprint SHA-256 identity for the complete row set.

+ 3 - 1
Sunrise/src/state/content_manifest/scanner/content_manifest_header_extraction.cpp

@@ -50,6 +50,7 @@ void copy_row(const Candidate& candidate, Row& output) noexcept {
 /** Checks every listed header and emits one row per installed file. */
 bool extract(std::wstring_view directory,
              std::span<Candidate> candidates,
+             const Fingerprint& directoryFingerprint,
              std::span<Row> rows,
              std::size_t& count,
              Fingerprint& buildFingerprint,
@@ -92,7 +93,8 @@ bool extract(std::wstring_view directory,
         return std::string_view(first.name.data(), first.nameLength)
                < std::string_view(second.name.data(), second.nameLength);
     });
-    if (!valid(std::span<const Row>(occupied)) || !fingerprint::rows(occupied, buildFingerprint)) {
+    if (!valid(std::span<const Row>(occupied))
+        || !fingerprint::catalog(occupied, directoryFingerprint, buildFingerprint)) {
         count = 0;
         return false;
     }

+ 2 - 0
Sunrise/src/state/content_manifest/scanner/internal.h

@@ -35,6 +35,7 @@ struct Candidate final {
  * Checks every listed header and emits one row per installed file.
  * @param directory Installed packages directory used by inventory.
  * @param candidates Mutable inventory scratch, reordered during grouping.
+ * @param directoryFingerprint Inventory identity produced by the matching scan.
  * @param rows Fixed output storage for canonical manifest rows.
  * @param count Receives the emitted row count.
  * @param buildFingerprint Receives the SHA-256 fingerprint of the emitted public rows.
@@ -43,6 +44,7 @@ struct Candidate final {
  */
 [[nodiscard]] bool extract(std::wstring_view directory,
                            std::span<Candidate> candidates,
+                           const Fingerprint& directoryFingerprint,
                            std::span<Row> rows,
                            std::size_t& count,
                            Fingerprint& buildFingerprint,

+ 11 - 0
Sunrise/src/steam/runtime/steam_lifecycle.cpp

@@ -3,6 +3,7 @@
 #include <atomic>
 
 #include "../../client/hooks/egress/runtime.h"
+#include "../../client/hooks/package_trust/package_trust_bypass.h"
 #include "../../client/runtime/runtime.h"
 #include "../../core/logging/log.h"
 #include "../../core/runtime/core_runtime.h"
@@ -58,6 +59,16 @@ bool initialize(void* module) noexcept {
         ReleaseSRWLockExclusive(&g_lifecycleLock);
         return false;
     }
+    // Base generation (_0) packages register during bootload, before the first callback pump can
+    // run the ordinary main-image hook sweep. Package trust must therefore attach at Steam init.
+    if (!client::hooks::package_trust::install()) {
+        core::log::write(core::log::Channel::client,
+                         core::log::Level::error,
+                         "ev=steam_init stage=package_trust result=fail");
+        (void)core::shutdown();
+        ReleaseSRWLockExclusive(&g_lifecycleLock);
+        return false;
+    }
     context::advance_generation();
     g_initialized.store(true, std::memory_order_release);
     core::log::write(core::log::Channel::client, core::log::Level::info, "ev=steam_init result=ok");