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.
This commit is contained in:
funman300
2026-08-19 15:20:47 +00:00
parent af7a5948a7
commit 9aecc658ad
3 changed files with 375 additions and 0 deletions
+372
View File
@@ -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<i32> {
crate::sbc_trace::readable_range(address, 4)
.then(|| core::ptr::read_volatile(address as *const i32))
}
unsafe fn restore_entry<const N: usize>(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<const N: usize>(
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);
}
}