From 9aecc658ad3b4055591c089fb72cbf779ebad8b3 Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 19 Aug 2026 15:20:47 +0000 Subject: [PATCH] hook(fifa17): guarded store-entry category clamp (promoted) The FUT store flashes a Browse-Packs overview on first open: the store screen ctor leaves screen+0x290 (CATEGORY_ID) at 0, and the resolver FUN_1800147f0 treats 0 as list-all, so the first render draws the group overview before the movie posts a tab ordinal. Detour the store render FUN_18007dab0 (RVA 0x7dab0): when the incoming category is 0, substitute the first present group ordinal (1) so the first frame lands on a real tab. Provably crash-safe: it writes 1 only after FUN_180014420(_, 1) (the resolver's own ordinal->group lookup, whose first arg is dead) returns non-NULL, which is exactly the resolver's non-crash precondition; the positive-invalid NULL deref at 0x14882 is thus unreachable. No group yet -> category left 0 -> Browse, still safe. Promoted like the SBC dispatch: build-armed (CLAMP_PROMOTED), no env. Signature-gated on both the detoured render and the called lookup, image-validated, installed under thread suspension, fail-closed. Only the overview flash is addressed; the empty-My-Packs entry dialog is movie-side (packed .apt) and out of CardsDLL reach (see Vault Store Resolver Guard 2026-08-19). fmt/clippy -D warnings clean both feature sets, 26 hook tests pass, x86_64-pc-windows-gnu release builds. --- openfut-hook/src/fifa17.rs | 1 + openfut-hook/src/lib.rs | 2 + openfut-hook/src/store_entry.rs | 372 ++++++++++++++++++++++++++++++++ 3 files changed, 375 insertions(+) create mode 100644 openfut-hook/src/store_entry.rs diff --git a/openfut-hook/src/fifa17.rs b/openfut-hook/src/fifa17.rs index 312eeb3..e47e28f 100644 --- a/openfut-hook/src/fifa17.rs +++ b/openfut-hook/src/fifa17.rs @@ -86,6 +86,7 @@ unsafe extern "system" fn worker(_: *mut core::ffi::c_void) -> u32 { crate::sbc_trace::install(); crate::sbc_dispatch::install(); crate::sbc_request_trace::install(); + crate::store_entry::install(); 0 } diff --git a/openfut-hook/src/lib.rs b/openfut-hook/src/lib.rs index 156278f..aab1f16 100644 --- a/openfut-hook/src/lib.rs +++ b/openfut-hook/src/lib.rs @@ -29,6 +29,8 @@ mod sbc_request_trace; #[cfg(feature = "fifa17")] mod sbc_trace; mod ssl_patch; +#[cfg(feature = "fifa17")] +mod store_entry; mod tls_bypass; mod transport_watch; mod version_proxy; diff --git a/openfut-hook/src/store_entry.rs b/openfut-hook/src/store_entry.rs new file mode 100644 index 0000000..85cd5cf --- /dev/null +++ b/openfut-hook/src/store_entry.rs @@ -0,0 +1,372 @@ +//! Guarded FIFA 17 store-entry category clamp. +//! +//! # What this repairs +//! +//! Opening the FUT store shows a one-frame "Browse Packs" overview (all group +//! tiles) before it settles on a tabbed category. The overview is the store +//! screen's constructor default: `screen+0x290` (the CATEGORY_ID the renderer +//! resolves) starts at `0`, and the category resolver `FUN_1800147f0` treats a +//! `0` category as "list every group" (`FUN_180014610`, the Browse path). Only +//! after the Scaleform movie posts a real tab ordinal does the screen re-render +//! on a tab — hence the visible flash on first open. +//! +//! This hook removes that flash by making the FIRST render already land on a +//! real tab: it detours the store render `FUN_18007dab0` and, when the incoming +//! category is `0`, substitutes the first present group ordinal (`1`). +//! +//! # Why it cannot crash the client +//! +//! The resolver crashes (`0x180014882`, `[NULL+0x48]`) only when it resolves a +//! POSITIVE ordinal that `FUN_180014420` (ordinal->group lookup) cannot find and +//! returns NULL for. The existing autopatch guard (`JG` at `0x180014858`) already +//! routes `category <= 0` to the safe Browse path, but does NOT cover a positive +//! ordinal that misses. So this hook substitutes `1` ONLY after calling the exact +//! same lookup the resolver uses — `FUN_180014420(_, 1)` — and confirming it +//! returns non-NULL. That is the resolver's own non-crash precondition, so by +//! construction the substituted category can never reach the NULL deref. When no +//! group exists yet, the category is left untouched (`0` -> Browse, still safe). +//! `FUN_180014420` ignores its first argument (it fetches the group list from a +//! process singleton), which the render itself dereferences on entry, so calling +//! it here is exactly as safe as the render's own first action. +//! +//! # Promotion +//! +//! Like the SBC dispatch repair, this is a PROMOTED feature: armed by the build, +//! never by an environment variable (see [`CLAMP_PROMOTED`]). Safety is the +//! runtime signature/evidence gate, not a flag. Rollback is a `version.dll` file +//! swap, not an env kill-switch. + +use core::ffi::c_void; +use core::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use windows_sys::Win32::System::Diagnostics::Debug::FlushInstructionCache; +use windows_sys::Win32::System::LibraryLoader::{ + GetModuleHandleA, GetModuleHandleExA, GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, + GET_MODULE_HANDLE_EX_FLAG_PIN, +}; +use windows_sys::Win32::System::Memory::{ + VirtualFree, VirtualProtect, MEM_RELEASE, PAGE_EXECUTE_READWRITE, +}; +use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentThreadId}; + +/// Store render `FUN_18007dab0`: reads `screen+0x290` and drives the resolver. +const RENDER_RVA: usize = 0x7dab0; +/// Ordinal->group lookup `FUN_180014420`: returns the group for a 1-based ordinal +/// or NULL when absent. Its first argument is dead (list comes from a singleton). +const LOOKUP_RVA: usize = 0x14420; +/// `screen + CATEGORY_OFFSET` holds the CATEGORY_ID the renderer resolves. +const CATEGORY_OFFSET: usize = 0x290; +/// The first present group's ordinal. `FUN_180014420` numbers present groups from +/// 1, so `1` is always the first present group — the natural default landing tab. +const FIRST_ORDINAL: u32 = 1; +/// Whole-instruction prologue length relocated into the render trampoline; also the +/// number of bytes overwritten by the entry detour. `push rdi; sub rsp,0x40; +/// movq [rsp+0x30],-2` = 2 + 4 + 9 = 15, a clean boundary that covers the 14-byte +/// absolute jump. +const COPY_LEN: usize = 15; +const ABS_JUMP_LEN: usize = 14; + +/// First 15 bytes of `FUN_18007dab0` on the pinned CardsDLL. Verified before the +/// detour is written and again under thread suspension. +const RENDER_SIGNATURE: [u8; COPY_LEN] = [ + 0x40, 0x57, 0x48, 0x83, 0xec, 0x40, 0x48, 0xc7, 0x44, 0x24, 0x30, 0xfe, 0xff, 0xff, 0xff, +]; +/// First 15 bytes of `FUN_180014420`. Validated before we ever call it, so the clamp +/// only invokes the genuine lookup on the exact build it was reversed against. +const LOOKUP_SIGNATURE: [u8; 15] = [ + 0x40, 0x57, 0x48, 0x83, 0xec, 0x30, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, 0xff, 0xff, 0xff, +]; + +type RenderFn = unsafe extern "system" fn(*mut c_void) -> *mut c_void; +type LookupFn = unsafe extern "system" fn(*mut c_void, u32) -> *mut c_void; + +/// The store-entry clamp is PROMOTED: armed by the build, never by an environment +/// variable, so every launch path (Steam, the launcher, a bare `umu-run`) behaves +/// identically. Promotion does not weaken any check — the signature gate, the image +/// validation, the thread quiesce, and the resolver-backed non-NULL precondition all +/// remain in the runtime evidence path. +pub(crate) const CLAMP_PROMOTED: bool = true; + +/// Compile-time contract: the clamp stays build-armed. Regressing this to an env gate +/// would silently restore the overview flash on a normal launch, so it must be a +/// deliberate, visible change here rather than a missing variable at runtime. +const _: () = assert!(CLAMP_PROMOTED); + +static CLAMP_ENABLED: AtomicBool = AtomicBool::new(false); +static RENDER_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0); +static STORE_BASE: AtomicUsize = AtomicUsize::new(0); +static RENDER_ENTRIES: AtomicU64 = AtomicU64::new(0); +static CLAMPS_APPLIED: AtomicU64 = AtomicU64::new(0); +static CLAMP_LAST_THREAD: AtomicUsize = AtomicUsize::new(0); + +/// Pure clamp decision, isolated for host tests. Returns the category the renderer +/// should resolve: substitute the first present ordinal only for the overview +/// default (`0`) and only when that ordinal actually resolves to a group; otherwise +/// leave the incoming category untouched. +fn clamp_category(current: i32, first_group_present: bool) -> i32 { + if current == 0 && first_group_present { + FIRST_ORDINAL as i32 + } else { + current + } +} + +unsafe fn guarded_i32(address: usize) -> Option { + crate::sbc_trace::readable_range(address, 4) + .then(|| core::ptr::read_volatile(address as *const i32)) +} + +unsafe fn restore_entry(target: usize, original: &[u8; N]) -> bool { + let mut old = 0u32; + if VirtualProtect(target as _, N, PAGE_EXECUTE_READWRITE, &mut old) == 0 { + return false; + } + core::ptr::copy_nonoverlapping(original.as_ptr(), target as *mut u8, N); + let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, N) != 0; + let mut ignored = 0u32; + flushed && VirtualProtect(target as _, N, old, &mut ignored) != 0 +} + +unsafe fn write_entry( + target: usize, + destination: usize, + original: &[u8; N], +) -> Result<(), bool> { + let mut patch = [0x90u8; N]; + patch[..ABS_JUMP_LEN].copy_from_slice(&crate::sbc_trace::absolute_jump(destination)); + let mut old = 0u32; + if VirtualProtect(target as _, N, PAGE_EXECUTE_READWRITE, &mut old) == 0 { + return Err(true); + } + core::ptr::copy_nonoverlapping(patch.as_ptr(), target as *mut u8, N); + let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, N) != 0; + let mut ignored = 0u32; + if flushed && VirtualProtect(target as _, N, old, &mut ignored) != 0 { + Ok(()) + } else { + Err(restore_entry(target, original)) + } +} + +/// Detour target for `FUN_18007dab0`. Runs on the native render thread. Before the +/// original renders, clamp an overview-default category to the first present group so +/// the first frame already shows a tab. Any guard miss leaves the category untouched +/// and simply tail-calls the original. +unsafe extern "system" fn store_render_wrapper(screen: *mut c_void) -> *mut c_void { + RENDER_ENTRIES.fetch_add(1, Ordering::Relaxed); + if CLAMP_ENABLED.load(Ordering::Acquire) { + let base = STORE_BASE.load(Ordering::Acquire); + if base != 0 && !screen.is_null() && crate::sbc_trace::validate_cards_build(base) { + let category_addr = (screen as usize).wrapping_add(CATEGORY_OFFSET); + if let Some(0) = guarded_i32(category_addr) { + if let Some(lookup) = base.checked_add(LOOKUP_RVA) { + // The lookup's first argument is dead; pass null. `1` is the first + // present ordinal. Non-NULL means the resolver will find a group, + // so writing `1` cannot reach the NULL-deref crash path. + let lookup_fn: LookupFn = core::mem::transmute(lookup); + let group = lookup_fn(core::ptr::null_mut(), FIRST_ORDINAL); + let clamped = clamp_category(0, !group.is_null()); + if clamped != 0 { + core::ptr::write_volatile(category_addr as *mut i32, clamped); + CLAMPS_APPLIED.fetch_add(1, Ordering::Relaxed); + CLAMP_LAST_THREAD.store(GetCurrentThreadId() as usize, Ordering::Relaxed); + } + } + } + } + } + let original: RenderFn = core::mem::transmute(RENDER_TRAMPOLINE.load(Ordering::Acquire)); + original(screen) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum InstallOutcome { + Installed, + CleanFailure, + DegradedHookActive, + DegradedProcessState, + DegradedHookAndProcess, +} + +unsafe fn install_hook(base: usize) -> InstallOutcome { + let Some(render) = crate::sbc_trace::target_va(base, RENDER_RVA) else { + return InstallOutcome::CleanFailure; + }; + let Some(lookup) = crate::sbc_trace::target_va(base, LOOKUP_RVA) else { + return InstallOutcome::CleanFailure; + }; + // Fingerprint the image and BOTH functions: the one we detour and the one we + // call. A single mismatched byte aborts cleanly with no write and no call. + if !crate::sbc_trace::valid_cards_image(base) + || !crate::sbc_trace::executable_range_in_image(base, render, RENDER_SIGNATURE.len()) + || !crate::sbc_trace::executable_range_in_image(base, lookup, LOOKUP_SIGNATURE.len()) + || core::slice::from_raw_parts(render as *const u8, RENDER_SIGNATURE.len()) + != RENDER_SIGNATURE + || core::slice::from_raw_parts(lookup as *const u8, LOOKUP_SIGNATURE.len()) + != LOOKUP_SIGNATURE + { + return InstallOutcome::CleanFailure; + } + let mut pinned = core::ptr::null_mut(); + if GetModuleHandleExA( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN, + render as *const u8, + &mut pinned, + ) == 0 + || pinned as usize != base + { + return InstallOutcome::CleanFailure; + } + let Some(trampoline) = crate::sbc_trace::allocate_trampoline(render, COPY_LEN) else { + return InstallOutcome::CleanFailure; + }; + RENDER_TRAMPOLINE.store(trampoline, Ordering::Release); + STORE_BASE.store(base, Ordering::Release); + + let Some(_gate) = crate::sbc_trace::acquire_patch_installer_gate() else { + VirtualFree(trampoline as _, 0, MEM_RELEASE); + RENDER_TRAMPOLINE.store(0, Ordering::Release); + return InstallOutcome::CleanFailure; + }; + let mut peers = match crate::sbc_trace::suspend_peers(render, lookup) { + Ok(peers) => peers, + Err(crate::sbc_trace::QuiesceFailure::Acquire) => { + VirtualFree(trampoline as _, 0, MEM_RELEASE); + RENDER_TRAMPOLINE.store(0, Ordering::Release); + return InstallOutcome::CleanFailure; + } + Err(crate::sbc_trace::QuiesceFailure::Resume) => { + return InstallOutcome::DegradedProcessState; + } + }; + let final_valid = crate::sbc_trace::valid_cards_image(base) + && core::slice::from_raw_parts(render as *const u8, RENDER_SIGNATURE.len()) + == RENDER_SIGNATURE; + let transaction = if !final_valid { + InstallOutcome::CleanFailure + } else { + match write_entry( + render, + store_render_wrapper as *const () as usize, + &RENDER_SIGNATURE, + ) { + Ok(()) => InstallOutcome::Installed, + Err(true) => InstallOutcome::CleanFailure, + Err(false) => InstallOutcome::DegradedHookActive, + } + }; + let resumed = peers.resume_all(); + let outcome = if resumed { + transaction + } else if matches!( + transaction, + InstallOutcome::Installed | InstallOutcome::DegradedHookActive + ) { + InstallOutcome::DegradedHookAndProcess + } else { + InstallOutcome::DegradedProcessState + }; + if outcome == InstallOutcome::CleanFailure { + VirtualFree(trampoline as _, 0, MEM_RELEASE); + RENDER_TRAMPOLINE.store(0, Ordering::Release); + } + outcome +} + +unsafe fn worker() { + let _pending = crate::sbc_trace::CodeInstallerPending; + let mut base = 0usize; + for _ in 0..600u32 { + base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize; + if base != 0 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(500)); + } + let outcome = if base == 0 { + InstallOutcome::CleanFailure + } else { + install_hook(base) + }; + drop(_pending); + match outcome { + InstallOutcome::Installed => { + crate::write_log("STORE_ENTRY: render clamp installed (promoted)\n") + } + InstallOutcome::CleanFailure => { + crate::write_log("STORE_ENTRY: clean install failure; inactive\n"); + return; + } + InstallOutcome::DegradedHookActive => { + crate::write_log("STORE_ENTRY: DEGRADED hook may be active; terminate game now\n"); + return; + } + InstallOutcome::DegradedProcessState => { + crate::write_log("STORE_ENTRY: DEGRADED thread state; terminate game now\n"); + return; + } + InstallOutcome::DegradedHookAndProcess => { + crate::write_log("STORE_ENTRY: DEGRADED hook and thread state; terminate game now\n"); + return; + } + } + + let mut entries_seen = 0u64; + let mut reports = 0u8; + while reports < 64 { + std::thread::sleep(std::time::Duration::from_millis(250)); + let entries = RENDER_ENTRIES.load(Ordering::Acquire); + if entries != entries_seen { + crate::write_log(&format!( + "STORE_ENTRY: render entries={} clamps={} tid={}\n", + entries, + CLAMPS_APPLIED.load(Ordering::Acquire), + CLAMP_LAST_THREAD.load(Ordering::Relaxed), + )); + entries_seen = entries; + reports += 1; + } + } + crate::write_log("STORE_ENTRY: report cap reached; hook remains installed\n"); +} + +pub(crate) fn install() { + // Promoted: armed by the build. No environment variable participates. + CLAMP_ENABLED.store(CLAMP_PROMOTED, Ordering::Release); + crate::write_log("STORE_ENTRY: clamp ARMED (promoted); strict native signature gate\n"); + std::thread::spawn(|| unsafe { worker() }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn overview_default_clamps_to_first_ordinal_when_group_present() { + assert_eq!( + clamp_category(0, true), + 1, + "the ctor overview default (0) must land on the first present group" + ); + } + + #[test] + fn overview_default_left_alone_when_no_group_exists() { + // No group -> leaving 0 routes to the safe Browse path; substituting a + // positive ordinal here would be the exact positive-invalid crash. + assert_eq!(clamp_category(0, false), 0); + } + + #[test] + fn explicit_positive_category_is_never_touched() { + // A real tab selection must pass through unchanged, group present or not. + assert_eq!(clamp_category(3, true), 3); + assert_eq!(clamp_category(3, false), 3); + } + + #[test] + fn negative_category_is_left_to_the_existing_browse_guard() { + // The absent-mypacks -1 is handled by the JG guard (routes <=0 to Browse); + // this clamp deliberately only touches the 0 overview default. + assert_eq!(clamp_category(-1, true), -1); + } +}