From b0986175738eb7a0a9dce4857bb8a63d1d38e2b7 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 20 Aug 2026 21:15:27 +0000 Subject: [PATCH] feat(fifa17-hook): patch FIFA17 TLS gates in-process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone B: move FIFA17 ProtoSSL certificate compatibility into version.dll so the client-local contract is openfut.cfg + LSX + version.dll with no external /proc-writing patcher. The proven external openfut-autopatch remains the oracle and is NOT removed; this reaches behavioral parity for the fail-closed patches. Patch set (ASLR-relocated at runtime; fail-closed byte-verified; one-shot): - FIFA17.exe ProtoSSL cert gates (REQUIRED_FOR_TLS), preferred base 0x140000000: GATE1 rva 0x6132548 0f85 76010000 (JNZ) -> 90*6 (NOP) GATE2 rva 0x61361b0 48 89 5c (prologue) -> 31 c0 c3 (xor eax,eax; ret) Applied as a pair only when BOTH read their known original, exactly like the external patcher's cert_pass; polled until the STEAMPUNKS packer unpacks them. - CardsDLL empty-My-Packs store crash-guard (REQUIRED_FOR_STORE_TLS, bug 6c), preferred base 0x180000000: rva 0x14858 75 0f (JNZ) -> 7f 0f (JG). Applied once CardsDLL maps (module-late). Deliberately NOT ported: the external patcher's 8 unconditional STORE_PATCHES. They carry no recovered original bytes (cannot be fail-closed) and are re-applied every tick (would require the constant-rewrite loop this milestone forbids); the external source records no rationale for them. Documented in the Vault ADR. Architecture: - patch_mem.rs: generic fail-closed primitive over a Mem trait — classify (ORIGINAL/ALREADY_PATCHED/MISMATCH), apply_checked (read->classify->write only on ORIGINAL->reread verify), VirtualQuery-guarded read + VirtualProtect/Flush write (WinMem). Trait abstraction makes every outcome host-testable without FIFA. - fifa17_tls.rs: FIFA17-specific patch table + bounded poll worker (250ms, 15min cap, no busy-spin) started from fifa17::install() after the network redirect. Never patches an absolute address; never blind-writes on mismatch; a write/verify failure is reported, never pretended. Phase 11: removed the season_trace CACHE_PACKNAMES_FAILED->SUCCESS force-success bypass (a staging-only behavior-changer that was armed unconditionally in the candidate); season_trace is now genuinely read-only passive tracing. sbc_dispatch and store_entry remain the intended REPAIR_PROMOTED fixes. Tests: 39 hook tests (26 baseline + 13 new: classify states, apply/idempotence, no-blind-write on mismatch, unreadable-module wait, write-failure reporting, RVA/ live-addr relocation across bases, cert-gate pairing, patch-table integrity). clippy --features fifa17 -D warnings clean; fmt clean; x86_64-pc-windows-gnu cross-build. No network-config authority added (routing stays Milestone A). Runtime validation (x64dbg site check + Windows/Linux retail) still outstanding. --- openfut-hook/src/fifa17.rs | 6 + openfut-hook/src/fifa17_tls.rs | 334 ++++++++++++++++++++++++++++ openfut-hook/src/lib.rs | 3 + openfut-hook/src/patch_mem.rs | 358 +++++++++++++++++++++++++++++++ openfut-hook/src/season_trace.rs | 16 +- 5 files changed, 702 insertions(+), 15 deletions(-) create mode 100644 openfut-hook/src/fifa17_tls.rs create mode 100644 openfut-hook/src/patch_mem.rs diff --git a/openfut-hook/src/fifa17.rs b/openfut-hook/src/fifa17.rs index de2f56c..c385d26 100644 --- a/openfut-hook/src/fifa17.rs +++ b/openfut-hook/src/fifa17.rs @@ -144,6 +144,12 @@ unsafe extern "system" fn worker(_: *mut core::ffi::c_void) -> u32 { "fifa17: NO redirect installed (openfut.cfg missing/invalid) — EA traffic left untouched\n", ), } + + // FIFA17 TLS/certificate + store crash-guard compatibility (Milestone B). + // Spawns its own bounded polling worker: patches the FIFA17.exe ProtoSSL cert + // gates once the packer unpacks them, then the CardsDLL store guard once UT + // loads it. Fail-closed and one-shot; replaces the external openfut-autopatch. + crate::fifa17_tls::install(); // The promoted SBC dispatch repair (and the evidence traces it decides on) arms // itself from the build; its safety is the runtime signature/evidence gate. The // remaining legacy experiment modules stay inert unless their env gate is `1`. diff --git a/openfut-hook/src/fifa17_tls.rs b/openfut-hook/src/fifa17_tls.rs new file mode 100644 index 0000000..850b2eb --- /dev/null +++ b/openfut-hook/src/fifa17_tls.rs @@ -0,0 +1,334 @@ +//! FIFA 17 in-process TLS/certificate + store crash-guard compatibility. +//! +//! Ports the *proven* subset of the external `openfut-autopatch` patch set into +//! `version.dll`, so the client-local contract no longer needs an external +//! `/proc`-writing patcher. Two concerns, both fail-closed and one-shot: +//! +//! 1. ProtoSSL certificate gates in FIFA17.exe (REQUIRED_FOR_TLS) — let the +//! TLS handshake against the OpenFUT bridge cert succeed. Present only after +//! the STEAMPUNKS packer maps/decrypts the real code, so they are polled for. +//! 2. The empty-"My Packs" store resolver crash-guard in CardsDLL +//! (REQUIRED_FOR_STORE_TLS, bug 6c) — CardsDLL loads lazily on entering UT, +//! so it is applied once the module appears. +//! +//! Deliberately NOT ported: the eight unconditional `STORE_PATCHES` from the +//! external patcher. They carry no recovered original bytes (cannot be +//! fail-closed) and are re-applied every tick (would require the very +//! constant-rewrite loop this milestone forbids); the external patcher's own +//! source records no rationale for them. See the Vault ADR. +//! +//! Every address is ASLR-relocated from its preferred image base at runtime +//! (`live = module_base + (static_va - preferred_base)`); nothing patches an +//! absolute address. Every write goes through [`crate::patch_mem`]'s fail-closed +//! primitive: original → write+verify, already-patched → no-op, anything else → +//! logged and skipped. + +use crate::patch_mem::{self, ApplyOutcome, Mem, PatchState, WinMem}; +use crate::write_log; +use std::time::{Duration, Instant}; + +/// FIFA17.exe preferred image base (confirmed: futmem reports the client mapped +/// flat at this base; Wine honours it, native Windows ASLR may not — hence the +/// runtime-base + RVA model below). +const FIFA17_PREFERRED_BASE: u64 = 0x1_4000_0000; +/// CardsDLL_Win64_retail.dll preferred image base. +const CARDS_PREFERRED_BASE: u64 = 0x1_8000_0000; + +/// Which module a site lives in. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Module { + Fifa17Exe, + CardsDll, +} + +impl Module { + const fn preferred_base(self) -> u64 { + match self { + Module::Fifa17Exe => FIFA17_PREFERRED_BASE, + Module::CardsDll => CARDS_PREFERRED_BASE, + } + } + + /// Runtime base of the loaded module, or `None` if not mapped yet. FIFA17.exe + /// is the main image (null name); CardsDLL is resolved by its retail name. + unsafe fn runtime_base(self) -> Option { + match self { + Module::Fifa17Exe => patch_mem::module_base(core::ptr::null()), + Module::CardsDll => { + patch_mem::module_base(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) + .or_else(|| patch_mem::module_base(c"CardsDLL.dll".as_ptr().cast())) + } + } + } +} + +/// One fail-closed byte patch, expressed as a static VA in its module's preferred +/// image so the derivation `RVA = VA - preferred_base` is auditable. +struct Site { + module: Module, + static_va: u64, + orig: &'static [u8], + patch: &'static [u8], + label: &'static str, +} + +impl Site { + const fn rva(&self) -> u64 { + patch_mem::rva(self.static_va, self.module.preferred_base()) + } + fn live_addr(&self, base: usize) -> usize { + patch_mem::live_addr(base, self.rva()) + } +} + +// ── ProtoSSL certificate gates (FIFA17.exe) — REQUIRED_FOR_TLS ────────────────── +// GATE1: JNZ rel32 -> 6×NOP (fall through the cert-verify failure branch). +// GATE2: function prologue -> `xor eax,eax; ret` (cert-verify returns 0/false). +// Applied as a pair, exactly like the external patcher: written only when BOTH +// read their known original, treated as done when BOTH already hold the patch. +const GATE1: Site = Site { + module: Module::Fifa17Exe, + static_va: 0x1_4613_2548, + orig: &[0x0f, 0x85, 0x76, 0x01, 0x00, 0x00], + patch: &[0x90, 0x90, 0x90, 0x90, 0x90, 0x90], + label: "GATE1", +}; +const GATE2: Site = Site { + module: Module::Fifa17Exe, + static_va: 0x1_4613_61b0, + orig: &[0x48, 0x89, 0x5c], + patch: &[0x31, 0xc0, 0xc3], + label: "GATE2", +}; + +// ── Empty "My Packs" store resolver crash-guard (CardsDLL) — REQUIRED_FOR_STORE_TLS +// JNZ 0x14869 (75 0f) -> JG 0x14869 (7f 0f): routes zero/negative store category +// ids through the Browse path instead of a NULL deref. Fail-closed one-shot. +const STORE_GUARD: Site = Site { + module: Module::CardsDll, + static_va: 0x1_8001_4858, + orig: &[0x75, 0x0f], + patch: &[0x7f, 0x0f], + label: "empty-mypacks-store-guard", +}; + +/// Poll cadence while waiting for the packer to unpack / CardsDLL to load. Low +/// frequency: the thread sleeps between ticks, so idle CPU is negligible. +const POLL: Duration = Duration::from_millis(250); +/// Upper bound on the whole worker's lifetime so it can never spin forever if the +/// user never enters Ultimate Team (CardsDLL never loads). +const MAX_WAIT: Duration = Duration::from_secs(15 * 60); + +/// Decision for the FIFA17.exe cert-gate pair. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum CertAction { + /// Not both readable yet, or a mixed/unrecognised state — keep polling. + Wait, + /// Both gates hold their known original — safe to apply the pair. + Apply, + /// Both gates already hold the patch — nothing to do. + Done, +} + +/// Pure pairing rule (unit-tested): only act when both gates agree. +fn cert_action(g1: Option, g2: Option) -> CertAction { + match (g1, g2) { + (Some(PatchState::AlreadyPatched), Some(PatchState::AlreadyPatched)) => CertAction::Done, + (Some(PatchState::Original), Some(PatchState::Original)) => CertAction::Apply, + _ => CertAction::Wait, + } +} + +/// Arm the FIFA17 TLS/store compatibility patcher: spawns a bounded background +/// worker so it never touches the loader lock and never blocks `install()`. +pub fn install() { + std::thread::spawn(|| unsafe { worker() }); +} + +unsafe fn worker() { + write_log("fifa17_tls: patch worker start\n"); + let mut mem = WinMem; + let start = Instant::now(); + let mut cert_done = false; + let mut guard_done = false; + // Throttle the "still waiting" diagnostics to one line each. + let mut logged_cert_wait = false; + let mut logged_guard_wait = false; + + loop { + if !cert_done { + cert_done = try_cert_gates(&mut mem, &mut logged_cert_wait); + } + if !guard_done { + match Module::CardsDll.runtime_base() { + Some(cbase) => guard_done = try_store_guard(&mut mem, cbase), + None => { + if !logged_guard_wait { + write_log("fifa17_tls: waiting for CardsDLL (enter Ultimate Team)\n"); + logged_guard_wait = true; + } + } + } + } + if cert_done && guard_done { + write_log("fifa17_tls: TLS patch set complete\n"); + return; + } + if start.elapsed() >= MAX_WAIT { + write_log(&format!( + "fifa17_tls: worker stop (timeout {MAX_WAIT:?}); cert_gates_done={cert_done} store_guard_done={guard_done}\n" + )); + return; + } + std::thread::sleep(POLL); + } +} + +/// Apply the FIFA17.exe cert-gate pair. Returns `true` once the pair is settled +/// (applied or already patched); `false` while still unpacking / not both ready. +unsafe fn try_cert_gates(mem: &mut WinMem, logged_wait: &mut bool) -> bool { + let base = match Module::Fifa17Exe.runtime_base() { + Some(b) => b, + None => return false, + }; + let g1_addr = GATE1.live_addr(base); + let g2_addr = GATE2.live_addr(base); + let g1 = patch_mem::read_state(mem, g1_addr, GATE1.orig, GATE1.patch); + let g2 = patch_mem::read_state(mem, g2_addr, GATE2.orig, GATE2.patch); + + match cert_action(g1, g2) { + CertAction::Done => { + write_log("fifa17_tls: cert gates already patched\n"); + true + } + CertAction::Apply => { + let o1 = patch_mem::apply_checked(mem, g1_addr, GATE1.orig, GATE1.patch); + let o2 = patch_mem::apply_checked(mem, g2_addr, GATE2.orig, GATE2.patch); + if o1.is_patched() && o2.is_patched() { + write_log(&format!( + "fifa17_tls: PATCHED cert gates ({} @ {g1_addr:#x} {o1:?}; {} @ {g2_addr:#x} {o2:?})\n", + GATE1.label, GATE2.label + )); + true + } else { + write_log(&format!( + "fifa17_tls: cert gate write FAILED ({} {o1:?}; {} {o2:?}) — TLS NOT installed\n", + GATE1.label, GATE2.label + )); + // Terminal: a write/verify failure will not fix itself by retrying. + true + } + } + CertAction::Wait => { + if !*logged_wait { + write_log(&format!( + "fifa17_tls: cert gates not ready (still unpacking?) {}={g1:?} {}={g2:?}\n", + GATE1.label, GATE2.label + )); + *logged_wait = true; + } + false + } + } +} + +/// Apply the CardsDLL store crash-guard once CardsDLL is mapped. Returns `true` +/// once the site is settled (its bytes are final the moment CardsDLL is loaded, +/// so any read outcome is a terminal decision — no further polling). +unsafe fn try_store_guard(mem: &mut WinMem, cbase: usize) -> bool { + let addr = STORE_GUARD.live_addr(cbase); + let outcome = patch_mem::apply_checked(mem, addr, STORE_GUARD.orig, STORE_GUARD.patch); + match outcome { + ApplyOutcome::NotReadable => false, // CardsDLL mapped but this page not yet — retry + ApplyOutcome::Applied | ApplyOutcome::AlreadyPatched => { + write_log(&format!( + "fifa17_tls: store guard {} @ {addr:#x} {outcome:?} (VERIFIED empty-My-Packs)\n", + STORE_GUARD.label + )); + true + } + ApplyOutcome::Mismatch => { + let mut cur = [0u8; patch_mem::MAX_PATCH_LEN]; + let n = STORE_GUARD.patch.len(); + let seen = if mem.read(addr, &mut cur[..n]) { + patch_mem::hex(&cur[..n]) + } else { + "unreadable".into() + }; + write_log(&format!( + "fifa17_tls: SKIP store guard @ {addr:#x}: unexpected {seen} (build mismatch)\n" + )); + true + } + ApplyOutcome::WriteFailed | ApplyOutcome::VerifyFailed => { + write_log(&format!( + "fifa17_tls: store guard @ {addr:#x} {outcome:?}\n" + )); + true + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_site_is_well_formed() { + for s in [&GATE1, &GATE2, &STORE_GUARD] { + assert_eq!( + s.orig.len(), + s.patch.len(), + "{}: orig/patch length", + s.label + ); + assert!(!s.orig.is_empty(), "{}: empty", s.label); + assert!( + s.patch.len() <= patch_mem::MAX_PATCH_LEN, + "{}: exceeds MAX_PATCH_LEN", + s.label + ); + assert_ne!(s.orig, s.patch, "{}: orig == patch", s.label); + } + } + + #[test] + fn rvas_match_the_recovered_derivation() { + assert_eq!(GATE1.rva(), 0x613_2548); + assert_eq!(GATE2.rva(), 0x613_61b0); + assert_eq!(STORE_GUARD.rva(), 0x1_4858); + } + + #[test] + fn live_addresses_track_the_runtime_base() { + // At the preferred base the live address is the recorded static VA. + assert_eq!(GATE1.live_addr(0x1_4000_0000), 0x1_4613_2548); + assert_eq!(STORE_GUARD.live_addr(0x1_8000_0000), 0x1_8001_4858); + // Relocated bases shift every site by the same delta. + assert_eq!(GATE1.live_addr(0x3_0000_0000), 0x3_0613_2548); + } + + #[test] + fn cert_pair_only_acts_when_both_gates_agree() { + use PatchState::*; + assert_eq!( + cert_action(Some(Original), Some(Original)), + CertAction::Apply + ); + assert_eq!( + cert_action(Some(AlreadyPatched), Some(AlreadyPatched)), + CertAction::Done + ); + // Not yet unpacked / partial / mismatched => never a blind half-write. + assert_eq!(cert_action(None, None), CertAction::Wait); + assert_eq!(cert_action(Some(Original), None), CertAction::Wait); + assert_eq!( + cert_action(Some(Original), Some(AlreadyPatched)), + CertAction::Wait + ); + assert_eq!( + cert_action(Some(Mismatch), Some(Mismatch)), + CertAction::Wait + ); + } +} diff --git a/openfut-hook/src/lib.rs b/openfut-hook/src/lib.rs index 1acb9e5..6d0c491 100644 --- a/openfut-hook/src/lib.rs +++ b/openfut-hook/src/lib.rs @@ -15,7 +15,10 @@ mod connect_hook; mod connectex_hook; #[cfg(feature = "fifa17")] mod fifa17; +#[cfg(feature = "fifa17")] +mod fifa17_tls; mod iat; +mod patch_mem; #[cfg(feature = "fifa17")] mod sbc_dispatch; #[cfg(feature = "fifa17")] diff --git a/openfut-hook/src/patch_mem.rs b/openfut-hook/src/patch_mem.rs new file mode 100644 index 0000000..fde026c --- /dev/null +++ b/openfut-hook/src/patch_mem.rs @@ -0,0 +1,358 @@ +//! Generic, fail-closed byte-patch primitive shared by per-game compatibility +//! patch tables (currently FIFA 17's TLS/store gates in [`crate::fifa17_tls`]). +//! +//! The decision logic is expressed against the [`Mem`] trait rather than raw +//! process memory, so every outcome — ORIGINAL / ALREADY_PATCHED / MISMATCH and +//! the write/verify path — is unit-testable on the host without a live client. +//! [`WinMem`] is the in-process Windows implementation used at runtime. +//! +//! FAIL-CLOSED INVARIANT: a site is written only when its live bytes are *exactly* +//! the known original. Already-patched is an idempotent no-op; anything else is +//! reported and left untouched — an unrecognised or not-yet-unpacked build is +//! never blindly overwritten. + +/// Longest patch payload across all tables (FIFA17 GATE1 is 6 bytes). Sizes the +/// fixed stack buffers so no slicing panic is reachable from the patch logic. +pub const MAX_PATCH_LEN: usize = 6; + +/// Byte-level access to the target's address space. +pub trait Mem { + /// Fill `buf` from `addr`. `false` = not readable yet (page uncommitted / + /// module not mapped / not unpacked) — the caller waits, it is not an error. + fn read(&self, addr: usize, buf: &mut [u8]) -> bool; + /// Write `data` at `addr`. `false` = the write could not be performed. + fn write(&mut self, addr: usize, data: &[u8]) -> bool; +} + +/// Fail-closed classification of live bytes against a site's original/replacement. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PatchState { + /// Live bytes are the known original — safe to patch. + Original, + /// Live bytes already equal the replacement — idempotent. + AlreadyPatched, + /// Neither — unrecognised/not-yet-ready build; must be left untouched. + Mismatch, +} + +/// Pure classification (no memory access). +pub fn classify(cur: &[u8], orig: &[u8], patch: &[u8]) -> PatchState { + if cur == patch { + PatchState::AlreadyPatched + } else if cur == orig { + PatchState::Original + } else { + PatchState::Mismatch + } +} + +/// Outcome of a checked patch attempt at one site. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApplyOutcome { + /// Bytes were the original and were written and re-read as the replacement. + Applied, + /// Bytes already equalled the replacement; nothing written. + AlreadyPatched, + /// Bytes were neither original nor replacement; nothing written. + Mismatch, + /// Bytes could not be read yet (module/page not available) — retry later. + NotReadable, + /// The write itself failed (protection change or copy). + WriteFailed, + /// Wrote, but the re-read did not equal the replacement. + VerifyFailed, +} + +impl ApplyOutcome { + /// Whether the site now holds the replacement (freshly or already). + pub fn is_patched(self) -> bool { + matches!(self, ApplyOutcome::Applied | ApplyOutcome::AlreadyPatched) + } +} + +/// Read → classify → (only on ORIGINAL) write → re-read verify. Never writes on +/// MISMATCH; treats ALREADY_PATCHED as success. `orig`/`patch` must be equal, +/// non-empty and within [`MAX_PATCH_LEN`]. +pub fn apply_checked(mem: &mut M, addr: usize, orig: &[u8], patch: &[u8]) -> ApplyOutcome { + debug_assert_eq!(orig.len(), patch.len()); + debug_assert!(!patch.is_empty() && patch.len() <= MAX_PATCH_LEN); + let n = patch.len(); + let mut cur = [0u8; MAX_PATCH_LEN]; + if !mem.read(addr, &mut cur[..n]) { + return ApplyOutcome::NotReadable; + } + match classify(&cur[..n], orig, patch) { + PatchState::AlreadyPatched => ApplyOutcome::AlreadyPatched, + PatchState::Mismatch => ApplyOutcome::Mismatch, + PatchState::Original => { + if !mem.write(addr, patch) { + return ApplyOutcome::WriteFailed; + } + let mut after = [0u8; MAX_PATCH_LEN]; + if !mem.read(addr, &mut after[..n]) || &after[..n] != patch { + return ApplyOutcome::VerifyFailed; + } + ApplyOutcome::Applied + } + } +} + +/// RVA of a static VA relative to an image's preferred base (pure). +pub const fn rva(static_va: u64, preferred_base: u64) -> u64 { + static_va - preferred_base +} +/// Read and classify a site without writing (`None` = not readable yet). Used to +/// decide multi-site patches (e.g. apply a gate pair only when both are original). +pub fn read_state(mem: &M, addr: usize, orig: &[u8], patch: &[u8]) -> Option { + let n = patch.len(); + let mut cur = [0u8; MAX_PATCH_LEN]; + if !mem.read(addr, &mut cur[..n]) { + return None; + } + Some(classify(&cur[..n], orig, patch)) +} + +/// Live in-process address of an image-relative site given the module's runtime base. +pub const fn live_addr(module_base: usize, rva: u64) -> usize { + module_base + rva as usize +} + +/// Lowercase, unseparated hex for diagnostics (matches the autopatch SKIP line). +pub fn hex(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push(char::from_digit((b >> 4) as u32, 16).unwrap()); + s.push(char::from_digit((b & 0xf) as u32, 16).unwrap()); + } + s +} + +// ─── In-process Windows memory (runtime only; not exercised by host tests) ────── + +/// In-process implementation of [`Mem`] over this (FIFA17.exe) address space. +pub struct WinMem; + +impl Mem for WinMem { + fn read(&self, addr: usize, buf: &mut [u8]) -> bool { + unsafe { guarded_read(addr, buf) } + } + fn write(&mut self, addr: usize, data: &[u8]) -> bool { + unsafe { protected_write(addr, data) } + } +} + +/// Resolve a loaded module's runtime base by name, or `None` if not loaded. +pub unsafe fn module_base(name: *const u8) -> Option { + use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA; + let h = GetModuleHandleA(name); + if h.is_null() { + None + } else { + Some(h as usize) + } +} + +/// Read `buf.len()` bytes from `addr` only if the whole range is committed and +/// readable (VirtualQuery-guarded), so a wrong base/RVA can never fault. +unsafe fn guarded_read(addr: usize, buf: &mut [u8]) -> bool { + use windows_sys::Win32::System::Memory::{ + VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READ, + PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOACCESS, PAGE_READONLY, + PAGE_READWRITE, PAGE_WRITECOPY, + }; + if addr == 0 || buf.is_empty() { + return false; + } + let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); + let want = core::mem::size_of::(); + if VirtualQuery(addr as _, &mut mbi, want) != want { + return false; + } + if mbi.State != MEM_COMMIT { + return false; + } + let readable = PAGE_READONLY + | PAGE_READWRITE + | PAGE_WRITECOPY + | PAGE_EXECUTE_READ + | PAGE_EXECUTE_READWRITE + | PAGE_EXECUTE_WRITECOPY; + if mbi.Protect & readable == 0 || mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS) != 0 { + return false; + } + // The full range must fit inside this single committed region. + let region_end = (mbi.BaseAddress as usize).wrapping_add(mbi.RegionSize); + if addr.checked_add(buf.len()).is_none_or(|e| e > region_end) { + return false; + } + core::ptr::copy_nonoverlapping(addr as *const u8, buf.as_mut_ptr(), buf.len()); + true +} + +/// Make `[addr, addr+data.len())` writable, copy `data`, flush the instruction +/// cache, then restore the original protection. `false` if protection could not +/// be changed. Verification is the caller's re-read (see [`apply_checked`]). +unsafe fn protected_write(addr: usize, data: &[u8]) -> bool { + use windows_sys::Win32::System::Diagnostics::Debug::FlushInstructionCache; + use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE}; + use windows_sys::Win32::System::Threading::GetCurrentProcess; + if addr == 0 || data.is_empty() { + return false; + } + let mut old: u32 = 0; + if VirtualProtect(addr as _, data.len(), PAGE_EXECUTE_READWRITE, &mut old) == 0 { + return false; + } + core::ptr::copy_nonoverlapping(data.as_ptr(), addr as *mut u8, data.len()); + FlushInstructionCache(GetCurrentProcess(), addr as _, data.len()); + // Best-effort restore of the original page protection. + let mut restored: u32 = 0; + VirtualProtect(addr as _, data.len(), old, &mut restored); + true +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + /// Deterministic fake address space for the pure patch logic. + struct FakeMem { + cells: HashMap, + readable: bool, + writable: bool, + } + impl FakeMem { + fn with(addr: usize, bytes: &[u8]) -> Self { + let mut cells = HashMap::new(); + for (i, b) in bytes.iter().enumerate() { + cells.insert(addr + i, *b); + } + Self { + cells, + readable: true, + writable: true, + } + } + } + impl Mem for FakeMem { + fn read(&self, addr: usize, buf: &mut [u8]) -> bool { + if !self.readable { + return false; + } + for (i, slot) in buf.iter_mut().enumerate() { + match self.cells.get(&(addr + i)) { + Some(b) => *slot = *b, + None => return false, + } + } + true + } + fn write(&mut self, addr: usize, data: &[u8]) -> bool { + if !self.writable { + return false; + } + for (i, b) in data.iter().enumerate() { + self.cells.insert(addr + i, *b); + } + true + } + } + + const ORIG: [u8; 2] = [0x75, 0x0f]; + const PATCH: [u8; 2] = [0x7f, 0x0f]; + + #[test] + fn classify_recognises_all_three_states() { + assert_eq!(classify(&ORIG, &ORIG, &PATCH), PatchState::Original); + assert_eq!(classify(&PATCH, &ORIG, &PATCH), PatchState::AlreadyPatched); + assert_eq!(classify(&[0x12, 0x34], &ORIG, &PATCH), PatchState::Mismatch); + } + + #[test] + fn original_bytes_are_applied_and_verified() { + let mut m = FakeMem::with(0x1000, &ORIG); + assert_eq!( + apply_checked(&mut m, 0x1000, &ORIG, &PATCH), + ApplyOutcome::Applied + ); + // Memory now holds the replacement. + let mut got = [0u8; 2]; + assert!(m.read(0x1000, &mut got)); + assert_eq!(got, PATCH); + } + + #[test] + fn already_patched_is_idempotent_noop() { + let mut m = FakeMem::with(0x2000, &PATCH); + assert_eq!( + apply_checked(&mut m, 0x2000, &ORIG, &PATCH), + ApplyOutcome::AlreadyPatched + ); + } + + #[test] + fn mismatch_never_writes() { + let junk = [0xde, 0xad]; + let mut m = FakeMem::with(0x3000, &junk); + assert_eq!( + apply_checked(&mut m, 0x3000, &ORIG, &PATCH), + ApplyOutcome::Mismatch + ); + // Untouched. + let mut got = [0u8; 2]; + assert!(m.read(0x3000, &mut got)); + assert_eq!(got, junk); + } + + #[test] + fn unreadable_module_waits_without_crashing() { + let mut m = FakeMem::with(0x4000, &ORIG); + m.readable = false; + let out = apply_checked(&mut m, 0x4000, &ORIG, &PATCH); + assert_eq!(out, ApplyOutcome::NotReadable); + assert!(!out.is_patched()); + } + + #[test] + fn write_failure_is_reported_not_pretended() { + let mut m = FakeMem::with(0x5000, &ORIG); + m.writable = false; + assert_eq!( + apply_checked(&mut m, 0x5000, &ORIG, &PATCH), + ApplyOutcome::WriteFailed + ); + } + + #[test] + fn running_twice_does_not_corrupt() { + let mut m = FakeMem::with(0x6000, &ORIG); + assert_eq!( + apply_checked(&mut m, 0x6000, &ORIG, &PATCH), + ApplyOutcome::Applied + ); + // Second pass sees the replacement and is a no-op. + assert_eq!( + apply_checked(&mut m, 0x6000, &ORIG, &PATCH), + ApplyOutcome::AlreadyPatched + ); + let mut got = [0u8; 2]; + assert!(m.read(0x6000, &mut got)); + assert_eq!(got, PATCH); + } + + #[test] + fn rva_and_live_addr_relocate_across_bases() { + // GATE1 example: preferred 0x140000000, VA 0x146132548. + assert_eq!(rva(0x1_4613_2548, 0x1_4000_0000), 0x613_2548); + // Applied at the preferred base gives the static VA back. + assert_eq!(live_addr(0x1_4000_0000, 0x613_2548), 0x1_4613_2548); + // Applied at a relocated (ASLR) base tracks the base exactly. + assert_eq!(live_addr(0x2_0000_0000, 0x613_2548), 0x2_0613_2548); + } + + #[test] + fn hex_is_lowercase_unseparated() { + assert_eq!(hex(&[0x0f, 0x85, 0xde]), "0f85de"); + } +} diff --git a/openfut-hook/src/season_trace.rs b/openfut-hook/src/season_trace.rs index a717257..89f2ea6 100644 --- a/openfut-hook/src/season_trace.rs +++ b/openfut-hook/src/season_trace.rs @@ -12,7 +12,7 @@ //! flow. Targets are chosen so their copied prologues are position-independent //! (no rip-relative / rel32 in the copied bytes). -use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use core::sync::atomic::{AtomicUsize, Ordering}; use windows_sys::Win32::System::Diagnostics::Debug::FlushInstructionCache; use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA; @@ -28,8 +28,6 @@ use crate::sbc_trace::{ use crate::write_log; static REPORTS: AtomicUsize = AtomicUsize::new(0); -/// One-shot guard for the staging-only CACHE_PACKNAMES_FAILED -> SUCCESS bypass. -static BYPASS_DONE: AtomicBool = AtomicBool::new(false); unsafe fn rd_i32(addr: usize) -> Option { readable_range(addr, 4).then(|| core::ptr::read_volatile(addr as *const i32)) @@ -390,18 +388,6 @@ unsafe extern "system" fn final_completion_wrapper( "SEASONS_LOAD_CALLBACK: final kind={kind} result={shown:?} flag={flag:?} ctx={ctx:#x} cbref={cbref:#x}\n" )); } - // Guarded one-shot bypass (staging diagnostic only): rewrite the pack-names - // failure to SUCCESS so the offline-season load advances to - // LoadCurrentOfflineSeason. Fires only for the exact CACHE_PACKNAMES failure, - // once per process; verified by the error string before touching memory. - if flag == Some(0) - && errstr.contains("CACHE_PACKNAMES") - && readable_range(result, 1) - && !BYPASS_DONE.swap(true, Ordering::AcqRel) - { - core::ptr::write_volatile(result as *mut u8, 1u8); // take the SUCCESS branch - write_log("SEASONS_BYPASS: forced CACHE_PACKNAMES_FAILED -> SUCCESS (one-shot, staging)\n"); - } let t = FINAL_COMPLETION_TRAMP.load(Ordering::Acquire); if t == 0 { return 0;