diff --git a/openfut-hook/Cargo.toml b/openfut-hook/Cargo.toml index 02812d9..fcf2b26 100644 --- a/openfut-hook/Cargo.toml +++ b/openfut-hook/Cargo.toml @@ -15,6 +15,10 @@ capture_baseline = [] # in-process online-flow functions (GoOnline, GetInternetConnectedState, event # deserializers). Writes PROBE lines to C:\openfut_hook.log for RE. See probe.rs. probe = [] +# Build with `--features fifa17` for the FIFA 17 injection path. DllMain runs ONLY +# the minimal FIFA-17-safe logic in fifa17.rs (prove injection, dump module map, +# patch DirtySDK/ProtoSSL cert-verify) and skips ALL the FIFA-23-specific hooking. +fifa17 = [] [dependencies] windows-sys = { version = "0.59", features = [ @@ -25,6 +29,9 @@ windows-sys = { version = "0.59", features = [ "Win32_Networking_WinSock", "Win32_Security_Cryptography", "Win32_System_Threading", + "Win32_System_Diagnostics_ToolHelp", + "Win32_System_Diagnostics_Debug", + "Win32_System_Kernel", ] } [profile.release] diff --git a/openfut-hook/build.rs b/openfut-hook/build.rs new file mode 100644 index 0000000..cd43c60 --- /dev/null +++ b/openfut-hook/build.rs @@ -0,0 +1,17 @@ +use std::env; +use std::path::PathBuf; + +fn main() { + println!("cargo:rerun-if-changed=version.def"); + + // The proxy's PE export surface is part of its runtime contract. Feed an + // explicit module-definition file to the MinGW linker instead of relying + // solely on Rust symbol export attributes and linker retention heuristics. + if env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") + && env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("gnu") + { + let definition = + PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("version.def"); + println!("cargo:rustc-link-arg={}", definition.display()); + } +} diff --git a/openfut-hook/src/fifa17.rs b/openfut-hook/src/fifa17.rs new file mode 100644 index 0000000..4119823 --- /dev/null +++ b/openfut-hook/src/fifa17.rs @@ -0,0 +1,108 @@ +//! FIFA 17 injection path (feature = "fifa17"). +//! +//! This is a *separate, minimal* entry point from the FIFA-23 `install_hooks`. +//! FIFA 17 is a different game with different in-memory structures, so we run NONE +//! of the FIFA-23 connect/LSX/origin_spy/dial logic here — that would at best +//! no-op and at worst crash. For now this proves the version.dll hijack actually +//! loads us into FIFA17.exe and dumps the module map, which we need to locate +//! DirtySDK/ProtoSSL's cert-verify function (the next milestone: patch it so the +//! secure Blaze redirector's TLS handshake succeeds against our bridge cert). +//! +//! Everything here is read-only except the (not-yet-enabled) cert-verify patch. + +use crate::write_log; +use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; +use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Module32FirstW, Module32NextW, MODULEENTRY32W, TH32CS_SNAPMODULE, + TH32CS_SNAPMODULE32, +}; +use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA; + +/// Read the SizeOfImage from a module's in-memory PE headers. +unsafe fn size_of_image(base: usize) -> u32 { + if base == 0 { + return 0; + } + // DOS header -> e_lfanew (i32 @ 0x3c) -> PE header. SizeOfImage is in the + // optional header at offset 0x50 from the PE signature (same for PE32/PE32+). + let e_lfanew = *((base + 0x3c) as *const i32); + let pe = base + e_lfanew as usize; + // sanity: 'PE\0\0' + if *(pe as *const u32) != 0x0000_4550 { + return 0; + } + *((pe + 24 + 0x38) as *const u32) // opt header +0x38 = SizeOfImage +} + +fn wide_to_string(w: &[u16]) -> String { + let end = w.iter().position(|&c| c == 0).unwrap_or(w.len()); + String::from_utf16_lossy(&w[..end]) +} + +/// Enumerate loaded modules (name, base, size) via ToolHelp and log them. +unsafe fn dump_modules() { + let snap = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, 0); + if snap == INVALID_HANDLE_VALUE { + write_log("fifa17: module snapshot FAILED\n"); + return; + } + let mut me: MODULEENTRY32W = core::mem::zeroed(); + me.dwSize = core::mem::size_of::() as u32; + if Module32FirstW(snap, &mut me) != 0 { + loop { + let name = wide_to_string(&me.szModule); + let base = me.modBaseAddr as usize; + let size = me.modBaseSize; + write_log(&format!( + "fifa17: module {name:<28} base={base:#018x} size={size:#x}\n" + )); + me.dwSize = core::mem::size_of::() as u32; + if Module32NextW(snap, &mut me) == 0 { + break; + } + } + } else { + write_log("fifa17: Module32FirstW FAILED\n"); + } + CloseHandle(snap); +} + +/// Worker that runs AFTER DllMain returns (loader lock released). ToolHelp and +/// other loader-touching calls are unsafe under the loader lock, so we defer them +/// to this thread. This is what fixed the "game exits right after DllMain" issue. +unsafe extern "system" fn worker(_: *mut core::ffi::c_void) -> u32 { + write_log("=== fifa17 hook: worker thread start ===\n"); + let main_base = GetModuleHandleA(core::ptr::null()) as usize; + let img = size_of_image(main_base); + write_log(&format!( + "fifa17: main exe base={main_base:#018x} SizeOfImage={img:#x}\n" + )); + dump_modules(); + write_log("fifa17: worker complete (injection healthy)\n"); + // SBC render intervention (inert unless OPENFUT_SBC_HOOK=1). Spawns its own deferred + // worker that waits for CardsDLL to load. See sbc_hook.rs / docs/sbc-hook-dll-spec.md. + crate::sbc_hook::install(); + // Passive transaction tracing has a separate kill switch from cache resolution. + // It currently fails closed until safe relocating trampolines are proven. + crate::sbc_trace::install(); + crate::sbc_request_trace::install(); + 0 +} + +/// Minimal FIFA-17 install. Keep DllMain itself trivial: only spawn a worker +/// thread and return immediately, so we never touch the loader lock from here. +pub unsafe fn install() { + use windows_sys::Win32::System::Threading::CreateThread; + write_log("=== fifa17 hook: DllMain ATTACH (spawning worker) ===\n"); + let h = CreateThread( + core::ptr::null(), + 0, + Some(worker), + core::ptr::null(), + 0, + core::ptr::null_mut(), + ); + if h == 0 as _ { + write_log("fifa17: CreateThread FAILED\n"); + } +} diff --git a/openfut-hook/src/lib.rs b/openfut-hook/src/lib.rs index fd65dc8..0c20865 100644 --- a/openfut-hook/src/lib.rs +++ b/openfut-hook/src/lib.rs @@ -2,6 +2,8 @@ mod config; mod connect_hook; mod connectex_hook; mod dial_notification; +#[cfg(feature = "fifa17")] +mod fifa17; mod hooks; mod iat; mod origin_spy; @@ -9,22 +11,32 @@ mod origin_spy; mod probe; #[cfg(feature = "capture_baseline")] mod recv_hook; +#[cfg(feature = "fifa17")] +mod sbc_hook; +#[cfg(feature = "fifa17")] +mod sbc_request_trace; +#[cfg(feature = "fifa17")] +mod sbc_trace; mod ssl_patch; mod tls_bypass; mod transport_watch; +mod version_proxy; use windows_sys::Win32::{ Foundation::{BOOL, HMODULE, TRUE}, - System::SystemServices::DLL_PROCESS_ATTACH, Networking::WinSock::ADDRINFOA, + System::SystemServices::DLL_PROCESS_ATTACH, }; pub(crate) fn write_log(msg: &str) { use std::io::Write; if let Ok(mut f) = std::fs::OpenOptions::new() - .create(true).append(true) + .create(true) + .append(true) .open(r"C:\openfut_hook.log") - { let _ = f.write_all(msg.as_bytes()); } + { + let _ = f.write_all(msg.as_bytes()); + } } /// Force the log to stable storage. `write_log` already opens+closes the file per line, @@ -34,18 +46,41 @@ pub(crate) fn write_log(msg: &str) { /// pre-call log line is guaranteed on disk if the call faults. #[allow(dead_code)] pub(crate) fn flush_log() { - if let Ok(f) = std::fs::OpenOptions::new().append(true).open(r"C:\openfut_hook.log") { + if let Ok(f) = std::fs::OpenOptions::new() + .append(true) + .open(r"C:\openfut_hook.log") + { let _ = f.sync_all(); } } #[no_mangle] pub unsafe extern "system" fn DllMain(module: HMODULE, reason: u32, _: *mut ()) -> BOOL { - if reason == DLL_PROCESS_ATTACH { install_hooks(module); } + if reason == DLL_PROCESS_ATTACH { + // VERSION forwarding must be ready before DllMain returns. Hook setup + // may be deferred, but a caller can use any proxy export immediately. + if version_proxy::resolve() { + install_hooks(module); + } + } TRUE } unsafe fn install_hooks(module: HMODULE) { + // FIFA 17 path: run ONLY the minimal, FIFA-17-safe logic and skip every + // FIFA-23-specific hook below (they assume FIFA 23's memory layout). + #[cfg(feature = "fifa17")] + { + let _ = module; + fifa17::install(); + return; + } + #[cfg(not(feature = "fifa17"))] + install_hooks_fifa23(module) +} + +#[cfg(not(feature = "fifa17"))] +unsafe fn install_hooks_fifa23(module: HMODULE) { write_log("openfut_hook: DllMain fired\n"); // Milestone-0 transport watch: arm (or note disarmed) from env once, up front, so // the getaddrinfo/connect/ConnectEx detours below can log Blaze-flavored activity. @@ -55,38 +90,68 @@ unsafe fn install_hooks(module: HMODULE) { let ga = iat::resolve(b"ws2_32.dll\0", b"getaddrinfo\0"); if !ga.is_null() { - let f: unsafe extern "system" fn(*const u8,*const u8,*const ADDRINFOA,*mut *mut ADDRINFOA)->i32 - = std::mem::transmute(ga); + let f: unsafe extern "system" fn( + *const u8, + *const u8, + *const ADDRINFOA, + *mut *mut ADDRINFOA, + ) -> i32 = std::mem::transmute(ga); hooks::set_real(f); let n = iat::patch_iat(ga, hooks::hooked_getaddrinfo as *const ()); - let m = iat::patch_iat_in(b"EAWebKit.dll\0", ga, hooks::hooked_getaddrinfo as *const ()); + let m = iat::patch_iat_in( + b"EAWebKit.dll\0", + ga, + hooks::hooked_getaddrinfo as *const (), + ); write_log(&format!("openfut_hook: getaddrinfo IAT patched {n}+{m}\n")); } - if ssl_patch::patch_main_exe_cert_verify() { write_log("ssl: main exe cert-verify patched\n"); } - else { write_log("ssl: main exe cert-verify NOT FOUND\n"); } - if ssl_patch::patch_eawebkit_cert_verify() { write_log("ssl: EAWebKit cert-verify patched\n"); } - else { write_log("ssl: EAWebKit cert-verify deferred\n"); } + if ssl_patch::patch_main_exe_cert_verify() { + write_log("ssl: main exe cert-verify patched\n"); + } else { + write_log("ssl: main exe cert-verify NOT FOUND\n"); + } + if ssl_patch::patch_eawebkit_cert_verify() { + write_log("ssl: EAWebKit cert-verify patched\n"); + } else { + write_log("ssl: EAWebKit cert-verify deferred\n"); + } - if connect_hook::install_inline_connect_hook() { write_log("connect: inline-hooked\n"); } - else { write_log("connect: hook FAILED\n"); } + if connect_hook::install_inline_connect_hook() { + write_log("connect: inline-hooked\n"); + } else { + write_log("connect: hook FAILED\n"); + } let wp = iat::resolve(b"ws2_32.dll\0", b"WSAConnect\0"); if !wp.is_null() { - let f: unsafe extern "system" fn(usize,*const u8,i32,*const(),*const(),*const(),*const())->i32 - = std::mem::transmute(wp); + let f: unsafe extern "system" fn( + usize, + *const u8, + i32, + *const (), + *const (), + *const (), + *const (), + ) -> i32 = std::mem::transmute(wp); connect_hook::set_real_wsa_connect(f); iat::patch_iat(wp, connect_hook::hooked_wsa_connect as *const ()); write_log("connect: WSAConnect IAT patched\n"); } - if connectex_hook::install_wsaioctl_hook() { write_log("connectex: WSAIoctl inline-hooked\n"); } - else { write_log("connectex: WSAIoctl hook FAILED\n"); } + if connectex_hook::install_wsaioctl_hook() { + write_log("connectex: WSAIoctl inline-hooked\n"); + } else { + write_log("connectex: WSAIoctl hook FAILED\n"); + } // RE instrumentation: passive logging detours on FIFA's in-process online-flow // functions (GoOnline, GetInternetConnectedState, event deserializers) to see // where FIFA stalls after our pushed LSX events. Deferred until anadius loads. #[cfg(feature = "probe")] - { probe::install_probes_deferred(); write_log("probe: deferred install scheduled\n"); } + { + probe::install_probes_deferred(); + write_log("probe: deferred install scheduled\n"); + } // recv/send hooks removed — LSX is now handled by the native openfut-bridge // LSX server (port 3216), so in-process interception is no longer needed. @@ -96,10 +161,16 @@ unsafe fn install_hooks(module: HMODULE) { // frames (pass-through, no emulation) so we can diff them against our bridge. #[cfg(feature = "capture_baseline")] { - if recv_hook::install_recv_hook() { write_log("CAP: recv inline-hooked\n"); } - else { write_log("CAP: recv hook FAILED\n"); } - if recv_hook::install_send_hook() { write_log("CAP: send inline-hooked\n"); } - else { write_log("CAP: send hook FAILED\n"); } + if recv_hook::install_recv_hook() { + write_log("CAP: recv inline-hooked\n"); + } else { + write_log("CAP: recv hook FAILED\n"); + } + if recv_hook::install_send_hook() { + write_log("CAP: send inline-hooked\n"); + } else { + write_log("CAP: send hook FAILED\n"); + } } macro_rules! hook_iat { @@ -110,31 +181,63 @@ unsafe fn install_hooks(module: HMODULE) { origin_spy::$setter(f); iat::patch_iat(ptr, $handler as *const ()); "ok" - } else { "miss" } + } else { + "miss" + } }}; } - let ra = hook_iat!(b"advapi32.dll\0", b"RegQueryValueExA\0", set_real_reg_a, + let ra = hook_iat!( + b"advapi32.dll\0", + b"RegQueryValueExA\0", + set_real_reg_a, origin_spy::hooked_reg_query_a, - unsafe extern "system" fn(isize,*const u8,*mut u32,*mut u32,*mut u8,*mut u32)->i32); - let rw = hook_iat!(b"advapi32.dll\0", b"RegQueryValueExW\0", set_real_reg_w, + unsafe extern "system" fn(isize, *const u8, *mut u32, *mut u32, *mut u8, *mut u32) -> i32 + ); + let rw = hook_iat!( + b"advapi32.dll\0", + b"RegQueryValueExW\0", + set_real_reg_w, origin_spy::hooked_reg_query_w, - unsafe extern "system" fn(isize,*const u16,*mut u32,*mut u32,*mut u8,*mut u32)->i32); - let ma = hook_iat!(b"kernel32.dll\0", b"OpenMutexA\0", set_real_mutex_a, + unsafe extern "system" fn(isize, *const u16, *mut u32, *mut u32, *mut u8, *mut u32) -> i32 + ); + let ma = hook_iat!( + b"kernel32.dll\0", + b"OpenMutexA\0", + set_real_mutex_a, origin_spy::hooked_open_mutex_a, - unsafe extern "system" fn(u32,i32,*const u8)->isize); - let mw = hook_iat!(b"kernel32.dll\0", b"OpenMutexW\0", set_real_mutex_w, + unsafe extern "system" fn(u32, i32, *const u8) -> isize + ); + let mw = hook_iat!( + b"kernel32.dll\0", + b"OpenMutexW\0", + set_real_mutex_w, origin_spy::hooked_open_mutex_w, - unsafe extern "system" fn(u32,i32,*const u16)->isize); - write_log(&format!("origin_spy: RegA={ra} RegW={rw} MutexA={ma} MutexW={mw}\n")); + unsafe extern "system" fn(u32, i32, *const u16) -> isize + ); + write_log(&format!( + "origin_spy: RegA={ra} RegW={rw} MutexA={ma} MutexW={mw}\n" + )); let cv = iat::resolve(b"crypt32.dll\0", b"CertVerifyCertificateChainPolicy\0"); if !cv.is_null() { - let f: unsafe extern "system" fn(*const u8,*const(),*const(),*mut u32)->BOOL - = std::mem::transmute(cv); + let f: unsafe extern "system" fn(*const u8, *const (), *const (), *mut u32) -> BOOL = + std::mem::transmute(cv); tls_bypass::set_real(f); iat::patch_iat(cv, tls_bypass::hooked_cert_verify_chain_policy as *const ()); - iat::patch_iat_in(b"EAWebKit.dll\0", cv, tls_bypass::hooked_cert_verify_chain_policy as *const ()); - iat::patch_iat_in(b"winhttp.dll\0", cv, tls_bypass::hooked_cert_verify_chain_policy as *const ()); - iat::patch_iat_in(b"wininet.dll\0", cv, tls_bypass::hooked_cert_verify_chain_policy as *const ()); + iat::patch_iat_in( + b"EAWebKit.dll\0", + cv, + tls_bypass::hooked_cert_verify_chain_policy as *const (), + ); + iat::patch_iat_in( + b"winhttp.dll\0", + cv, + tls_bypass::hooked_cert_verify_chain_policy as *const (), + ); + iat::patch_iat_in( + b"wininet.dll\0", + cv, + tls_bypass::hooked_cert_verify_chain_policy as *const (), + ); } } diff --git a/openfut-hook/src/sbc_hook.rs b/openfut-hook/src/sbc_hook.rs new file mode 100644 index 0000000..410b7d5 --- /dev/null +++ b/openfut-hook/src/sbc_hook.rs @@ -0,0 +1,632 @@ +//! FIFA 17 SBC render intervention (feature = "fifa17"). +//! +//! Makes the FUT **SBC menu render real data** from inside the process. Full spec +//! (all addresses, RVA math, call order, crash risks, staged test plan): +//! fifa17-recon/docs/sbc-hook-dll-spec.md +//! +//! Everything here is **inert by default** and gated by env vars, so shipping the DLL +//! with this module compiled in changes nothing unless a var is set: +//! OPENFUT_SBC_HOOK=1 -> arm the deferred worker (resolve + log; READ-ONLY) +//! OPENFUT_SBC_ARM_ONLY=1 -> Tier-0 negative control: write BYTE[B+0x28]=1 (renders EMPTY) +//! OPENFUT_SBC_POPULATE=1 -> legacy Tier-1 gate: BLOCKED (logs corrected trace gap, returns) +//! +//! CardsDLL_Win64_retail.dll is loaded lazily (only on entering Ultimate Team), so we +//! defer off the loader lock and poll for it — the same shape as +//! `probe::install_probes_deferred` polling for anadius64.dll. +//! +//! ── Address model (static VAs; PE image base 0x180000000) ──────────────────────── +//! All values below are RVAs (VA_static - 0x180000000); live = cards_base + rva. +//! See the spec for the verified disassembly behind each one. + +use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA; +use windows_sys::Win32::System::Memory::{ + VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READWRITE, + PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOACCESS, PAGE_READWRITE, PAGE_WRITECOPY, +}; + +// ── RVAs (verified byte-exact against /tmp/fut/cardsdll.dll this pass) ──────────── +const IMAGE_BASE: usize = 0x180000000; + +/// FNV prologue used as the slide-proof control (must match the on-disk PE bytes). +const CTRL_RVA: usize = 0x180d00; // VA 0x180180d00 +const CTRL_BYTES: &[u8] = &[ + 0x48, 0x83, 0xec, 0x28, 0x48, 0x85, 0xc9, 0x74, 0x50, 0x45, 0x33, 0xc0, +]; + +const A_SLOT_RVA: usize = 0x2e6398; // *(0x1802e6398) = A (FUT root singleton) +const A_VTABLE_RVA: usize = 0x21c2a0; +const B_OFF: usize = 0x1f9d8; // B = A + 0x1f9d8 (SBC request/ready TTL cache) +const B_VTABLE_RVA: usize = 0x1fae70; +const B_READY_OFF: usize = 0x28; // B+0x28 ready byte (the isValid gate) +const B_COLL_OFF: usize = 0x08; // B+0x08 collection ptr (MUST stay 0 — see spec §4/C5) +const M_CACHE_OFF: usize = 0x20a68; // M = *(A + 0x20a68) (render source; per-session heap) +const M_COUNT_OFF: usize = 0x50; // WORD[M+0x50] category count +const B_DTOR_RVA: usize = 0x63040; +const B_ISVALID_RVA: usize = 0x65d40; +const B_CLEAR_RVA: usize = 0x65d20; +const B_READY_EXPECTED_BEFORE_ARM: u8 = 0; + +#[allow(dead_code)] +const AVT_M_GETTER: usize = 0x9b0; // A.vtable[+0x9b0] = 0x18011b7d0 (M lazy getter) +#[allow(dead_code)] +const AVT_B_GETTER: usize = 0x4e8; // A.vtable[+0x4e8] = 0x18011c1f0 (B getter thunk) + +// Callable RVAs (for the Tier-1 populate sequence — see spec §6/§8). Kept for +// reference/wiring; not invoked while Tier-1 is blocked. +#[allow(dead_code)] +mod rva { + pub const M_LAZY_GETTER: usize = 0x11b7d0; + pub const ISVALID: usize = 0x65d40; + pub const DESER_SBS_SETS: usize = 0x17b2b0; + pub const SAX_CTX_INIT: usize = 0x1c63e0; + pub const REGISTRY_GETTER: usize = 0xd7170; + pub const MANAGER_GETTER: usize = 0x9c80; + pub const CLEAR_M: usize = 0x15f3a0; + pub const CAT_CTOR: usize = 0x159da0; + pub const CAT_DESER: usize = 0x17ab80; + pub const CAT_FINALIZE: usize = 0x160e50; + pub const APPEND: usize = 0x15a770; + pub const CAT_DTOR: usize = 0x1105d0; + pub const IDX_REBUILD_1: usize = 0x160e00; + pub const IDX_REBUILD_2: usize = 0x160f30; + pub const IDX_REBUILD_3: usize = 0x161020; + pub const REFRESH_DISPATCH: usize = 0x1a4a70; // Scaleform events 0x756c-0x7574 +} + +static ARMED: AtomicBool = AtomicBool::new(false); +static ARM_ONLY: AtomicBool = AtomicBool::new(false); +static POPULATE: AtomicBool = AtomicBool::new(false); +static DONE: AtomicBool = AtomicBool::new(false); +static CARDS_BASE: AtomicUsize = AtomicUsize::new(0); +static STATE: AtomicUsize = AtomicUsize::new(RuntimeState::Disabled as usize); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(usize)] +enum RuntimeState { + Disabled, + Resolved, + Intercepted, + Parsed, + Validated, + Committed, + Failed, +} + +fn valid_transition(from: RuntimeState, to: RuntimeState) -> bool { + matches!( + (from, to), + (RuntimeState::Disabled, RuntimeState::Resolved) + | (RuntimeState::Resolved, RuntimeState::Intercepted) + | (RuntimeState::Intercepted, RuntimeState::Parsed) + | (RuntimeState::Parsed, RuntimeState::Validated) + // Resolve-only/Tier-0 validates without installing an interceptor. + | (RuntimeState::Resolved, RuntimeState::Validated) + | (RuntimeState::Validated, RuntimeState::Committed) + | (_, RuntimeState::Failed) + ) +} + +fn transition(from: RuntimeState, to: RuntimeState) -> bool { + valid_transition(from, to) + && STATE + .compare_exchange( + from as usize, + to as usize, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ValidationError { + AddressOverflow, + AUnreadable, + AVtableMismatch, + AGetterMismatch, + BVtableMismatch, + BDtorMismatch, + BIsValidMismatch, + BClearMismatch, + MSlotUnreadable, + ReadyByteUnexpected, + CollectionUnreadable, + CollectionNotNull, + ReadyByteNotWritable, +} + +#[derive(Clone, Copy, Debug)] +struct RuntimeSnapshot { + a: usize, + a_vtable: usize, + a_b_getter: usize, + b: usize, + b_vtable: usize, + b_dtor: usize, + b_isvalid: usize, + b_clear: usize, + b_ready: u8, + b_coll: usize, + m: usize, +} + +fn expected_va(base: usize, rva: usize) -> Result { + base.checked_add(rva) + .ok_or(ValidationError::AddressOverflow) +} + +fn validate_snapshot(base: usize, s: &RuntimeSnapshot) -> Result<(), ValidationError> { + if s.a == 0 + || s.b + != s.a + .checked_add(B_OFF) + .ok_or(ValidationError::AddressOverflow)? + { + return Err(ValidationError::AUnreadable); + } + if s.a_vtable != expected_va(base, A_VTABLE_RVA)? { + return Err(ValidationError::AVtableMismatch); + } + if s.a_b_getter != expected_va(base, 0x11c1f0)? { + return Err(ValidationError::AGetterMismatch); + } + if s.b_vtable != expected_va(base, B_VTABLE_RVA)? { + return Err(ValidationError::BVtableMismatch); + } + if s.b_dtor != expected_va(base, B_DTOR_RVA)? { + return Err(ValidationError::BDtorMismatch); + } + if s.b_isvalid != expected_va(base, B_ISVALID_RVA)? { + return Err(ValidationError::BIsValidMismatch); + } + if s.b_clear != expected_va(base, B_CLEAR_RVA)? { + return Err(ValidationError::BClearMismatch); + } + if s.b_ready != B_READY_EXPECTED_BEFORE_ARM { + return Err(ValidationError::ReadyByteUnexpected); + } + if s.b_coll != 0 { + return Err(ValidationError::CollectionNotNull); + } + let _ = s.m; // The guarded snapshot read proves the M slot itself is readable. + Ok(()) +} + +/// Fault-safe pointer read (mirrors `probe::read_ptr`): returns None unless `ptr` lands +/// in a committed, readable page and the full 8 bytes fit inside the region. +unsafe fn read_ptr(ptr: usize) -> Option { + if ptr < 0x10000 || ptr & 7 != 0 { + return None; + } + let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); + let n = VirtualQuery( + ptr as _, + &mut mbi, + core::mem::size_of::(), + ); + if n == 0 || mbi.State != MEM_COMMIT { + return None; + } + if mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 { + return None; + } + if ptr + 8 > mbi.BaseAddress as usize + mbi.RegionSize { + return None; + } + Some(core::ptr::read_volatile(ptr as *const usize)) +} + +/// Guarded byte read. +unsafe fn read_u8(ptr: usize) -> Option { + if ptr < 0x10000 { + return None; + } + let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); + let n = VirtualQuery( + ptr as _, + &mut mbi, + core::mem::size_of::(), + ); + if n == 0 || mbi.State != MEM_COMMIT || mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 { + return None; + } + if ptr + 1 > mbi.BaseAddress as usize + mbi.RegionSize { + return None; + } + Some(core::ptr::read_volatile(ptr as *const u8)) +} + +/// A Tier-0 write is allowed only when the complete byte lies in a committed, +/// non-guarded region whose current protection explicitly permits writes. +unsafe fn writable_u8(ptr: usize) -> bool { + if ptr < 0x10000 { + return false; + } + let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); + let n = VirtualQuery( + ptr as _, + &mut mbi, + core::mem::size_of::(), + ); + if n == 0 || mbi.State != MEM_COMMIT || mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 { + return false; + } + let protection = mbi.Protect & 0xff; + let writable = matches!( + protection, + PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY + ); + writable + && ptr + .checked_add(1) + .is_some_and(|end| end <= (mbi.BaseAddress as usize).saturating_add(mbi.RegionSize)) +} + +/// Guarded 16-bit read (M category count is a WORD). +unsafe fn read_u16(ptr: usize) -> Option { + let lo = read_u8(ptr)? as u16; + let hi = read_u8(ptr + 1)? as u16; + Some(lo | (hi << 8)) +} + +/// Resolve CardsDLL's runtime base, or 0. Tries the exact loaded name; the ToolHelp +/// fallback (name-contains "CardsDLL") lives in the spec — add it if EA ever renames. +unsafe fn resolve_cards_base() -> usize { + let h = GetModuleHandleA(b"CardsDLL_Win64_retail.dll\0".as_ptr()); + if !h.is_null() { + return h as usize; + } + // Also try the short form some tooling reports. + let h2 = GetModuleHandleA(b"CardsDLL.dll\0".as_ptr()); + if !h2.is_null() { + return h2 as usize; + } + 0 +} + +#[inline] +fn va(base: usize, rva: usize) -> usize { + base + rva +} + +/// Prove the module didn't move: the FNV control prologue must match the on-disk PE. +unsafe fn control_matches(base: usize) -> bool { + let p = va(base, CTRL_RVA); + for (i, &want) in CTRL_BYTES.iter().enumerate() { + match read_u8(p + i) { + Some(got) if got == want => {} + _ => return false, + } + } + true +} + +/// Take one guarded identity snapshot. A failure to read any identity-bearing field is +/// distinct from a value mismatch and aborts before mutation. +unsafe fn runtime_snapshot(base: usize) -> Result { + let a_slot = expected_va(base, A_SLOT_RVA)?; + let a = read_ptr(a_slot) + .filter(|&value| value != 0) + .ok_or(ValidationError::AUnreadable)?; + let a_vtable = read_ptr(a).ok_or(ValidationError::AVtableMismatch)?; + let a_b_getter = read_ptr( + a_vtable + .checked_add(AVT_B_GETTER) + .ok_or(ValidationError::AddressOverflow)?, + ) + .ok_or(ValidationError::AGetterMismatch)?; + let b = a + .checked_add(B_OFF) + .ok_or(ValidationError::AddressOverflow)?; + let b_vtable = read_ptr(b).ok_or(ValidationError::BVtableMismatch)?; + let b_dtor = read_ptr(b_vtable).ok_or(ValidationError::BDtorMismatch)?; + let b_isvalid = read_ptr( + b_vtable + .checked_add(8) + .ok_or(ValidationError::AddressOverflow)?, + ) + .ok_or(ValidationError::BIsValidMismatch)?; + let b_clear = read_ptr( + b_vtable + .checked_add(16) + .ok_or(ValidationError::AddressOverflow)?, + ) + .ok_or(ValidationError::BClearMismatch)?; + let b_ready = read_u8( + b.checked_add(B_READY_OFF) + .ok_or(ValidationError::AddressOverflow)?, + ) + .ok_or(ValidationError::ReadyByteUnexpected)?; + let b_coll = read_ptr( + b.checked_add(B_COLL_OFF) + .ok_or(ValidationError::AddressOverflow)?, + ) + .ok_or(ValidationError::CollectionUnreadable)?; + let m = read_ptr( + a.checked_add(M_CACHE_OFF) + .ok_or(ValidationError::AddressOverflow)?, + ) + .ok_or(ValidationError::MSlotUnreadable)?; + Ok(RuntimeSnapshot { + a, + a_vtable, + a_b_getter, + b, + b_vtable, + b_dtor, + b_isvalid, + b_clear, + b_ready, + b_coll, + m, + }) +} + +fn set_failed(error: ValidationError) { + STATE.store(RuntimeState::Failed as usize, Ordering::Release); + crate::write_log(&format!( + "SBC_HOOK: runtime validation FAILED: {error:?} -- no write\n" + )); +} + +/// Public entry: called from `fifa17::install`. Spawns the deferred worker if +/// OPENFUT_SBC_HOOK=1; otherwise logs "disabled" and returns (fully inert). +pub fn install() { + let armed = std::env::var("OPENFUT_SBC_HOOK") + .map(|v| v == "1") + .unwrap_or(false); + ARMED.store(armed, Ordering::Relaxed); + if !armed { + STATE.store(RuntimeState::Disabled as usize, Ordering::Relaxed); + crate::write_log("SBC_HOOK: disabled (set OPENFUT_SBC_HOOK=1 to enable)\n"); + return; + } + ARM_ONLY.store( + std::env::var("OPENFUT_SBC_ARM_ONLY") + .map(|v| v == "1") + .unwrap_or(false), + Ordering::Relaxed, + ); + POPULATE.store( + std::env::var("OPENFUT_SBC_POPULATE") + .map(|v| v == "1") + .unwrap_or(false), + Ordering::Relaxed, + ); + crate::write_log("SBC_HOOK: ARMED (deferred worker spawning)\n"); + std::thread::spawn(|| unsafe { worker() }); +} + +/// Deferred worker: waits (up to ~5 min) for CardsDLL to load — it only appears when +/// the user enters Ultimate Team — then runs the resolve/log (+ optional Tier-0 arm) +/// exactly once. +unsafe fn worker() { + let mut base = 0usize; + for _ in 0..600u32 { + base = resolve_cards_base(); + if base != 0 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(500)); + } + if base == 0 { + crate::write_log("SBC_HOOK: CardsDLL_Win64_retail.dll never loaded — giving up\n"); + return; + } + CARDS_BASE.store(base, Ordering::Relaxed); + let slide = base.wrapping_sub(IMAGE_BASE); + let ctrl_ok = control_matches(base); + crate::write_log(&format!( + "SBC_HOOK: CardsDLL base={base:#x} slide={slide:#x} CONTROL={}\n", + if ctrl_ok { "OK" } else { "MISMATCH-ABORT" } + )); + if !ctrl_ok { + STATE.store(RuntimeState::Failed as usize, Ordering::Release); + return; // module map moved -> offsets untrustworthy (spec §1) + } + + if !transition(RuntimeState::Disabled, RuntimeState::Resolved) { + crate::write_log("SBC_HOOK: invalid state transition to Resolved -- no write\n"); + STATE.store(RuntimeState::Failed as usize, Ordering::Release); + return; + } + + // Resolve and validate A -> B, M. The vtable method checks make it substantially + // harder for a coincidental heap pointer to pass after a binary/layout mismatch. + let snapshot = match runtime_snapshot(base).and_then(|snapshot| { + validate_snapshot(base, &snapshot)?; + Ok(snapshot) + }) { + Ok(snapshot) => snapshot, + Err(error) => { + set_failed(error); + return; + } + }; + if !transition(RuntimeState::Resolved, RuntimeState::Validated) { + crate::write_log("SBC_HOOK: invalid state transition to Validated -- no write\n"); + STATE.store(RuntimeState::Failed as usize, Ordering::Release); + return; + } + let a = snapshot.a; + let b = snapshot.b; + let m = snapshot.m; + let m_count = (m != 0).then(|| read_u16(m + M_COUNT_OFF)).flatten(); + crate::write_log(&format!( + "SBC_HOOK: A={a:#x} B={b:#x} B+0x28(ready)={:?} B+0x08(coll)={:?} M=*(A+0x20a68)={:?} WORD[M+0x50]={:?}\n", + Some(snapshot.b_ready), opt_hex(Some(snapshot.b_coll)), opt_hex(Some(m)), m_count, + )); + + // Tier-0 — arm-only negative control. Write ONLY BYTE[B+0x28]=1; leave B+0x08=0 so + // isValid takes the short-circuit (spec §4). Renders the menu EMPTY (M null/empty) — + // this is the baseline, NOT the fix. One-shot. + if ARM_ONLY.load(Ordering::Relaxed) { + if DONE.swap(true, Ordering::Relaxed) { + return; + } + // Re-snapshot immediately before mutation to reduce the time-of-check/time-of-use + // window. In particular, the exact patch byte must still be 0 and B+0x08 null. + let write_snapshot = match runtime_snapshot(base).and_then(|snapshot| { + validate_snapshot(base, &snapshot)?; + if !writable_u8(snapshot.b + B_READY_OFF) { + return Err(ValidationError::ReadyByteNotWritable); + } + Ok(snapshot) + }) { + Ok(snapshot) => snapshot, + Err(error) => { + set_failed(error); + return; + } + }; + crate::write_log(&format!( + "SBC_HOOK: Tier-0 arm-only -> writing BYTE[{:#x}]=1 (expect EMPTY render, no modal)\n", + write_snapshot.b + B_READY_OFF + )); + core::ptr::write_volatile((write_snapshot.b + B_READY_OFF) as *mut u8, 1u8); + match read_u8(write_snapshot.b + B_READY_OFF) { + Some(1) if transition(RuntimeState::Validated, RuntimeState::Committed) => {} + _ => { + set_failed(ValidationError::ReadyByteUnexpected); + return; + } + } + crate::write_log( + "SBC_HOOK: Tier-0 arm-only DONE (open the SBC menu; ~2 placeholder tiles expected)\n", + ); + return; + } + + // Legacy Tier-1 gate — deliberately blocked. The fresh live exchange proves FIFA + // already owns a real response and SAX reader for /sbs/sets. The next milestone is + // passive tracing of the native response-to-deserializer dispatch, not construction + // of a reader. Cold-calling with a fabricated reader would CLEAR M and/or segfault. + if POPULATE.load(Ordering::Relaxed) { + crate::write_log( + "SBC_HOOK: legacy Tier-1 populate is BLOCKED — capture the genuine response \ + and reader at the native dispatch boundary first (see client-hook plan M3/M4). \ + No deser call made; fabricated readers can clear M or crash.\n", + ); + } +} + +fn opt_hex(o: Option) -> String { + match o { + Some(v) => format!("{v:#x}"), + None => "".to_string(), + } +} + +/// Legacy Tier-1 scaffold. **Never call this with a fabricated reader.** The intended +/// implementation is now a guarded synchronous dispatch repair that borrows the genuine +/// response and reader from the real HTTP transaction on its native thread. +/// +/// Sequence once `reader` (a primed SAX reader over canned sbs/sets JSON) exists: +/// let base = CARDS_BASE.load(Relaxed); +/// let deser: unsafe extern "system" fn(*mut u8, *mut u8) -> bool = +/// transmute(va(base, rva::DESER_SBS_SETS)); +/// deser(core::ptr::null_mut(), reader); // self-locates mgr, clears+appends+finalizes+commits M +/// // then Tier-0 arm: BYTE[B+0x28]=1, leave B+0x08=0 +/// // then refresh so 0x1800b5eda re-reads WORD[M+0x50] +#[allow(dead_code)] +unsafe fn populate_m(_reader: *mut u8) { + // Intentionally unimplemented: the passive trace must prove the response/reader + // ownership and exact virtual-dispatch boundary before any parser call is enabled. + unreachable!( + "populate_m requires a proven native dispatch contract; see client-hook plan M3/M4" + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn valid_snapshot(base: usize) -> RuntimeSnapshot { + let a = 0x1000_0000usize; + RuntimeSnapshot { + a, + a_vtable: base + A_VTABLE_RVA, + a_b_getter: base + 0x11c1f0, + b: a + B_OFF, + b_vtable: base + B_VTABLE_RVA, + b_dtor: base + B_DTOR_RVA, + b_isvalid: base + B_ISVALID_RVA, + b_clear: base + B_CLEAR_RVA, + b_ready: B_READY_EXPECTED_BEFORE_ARM, + b_coll: 0, + m: 0, + } + } + + #[test] + fn accepts_exact_runtime_identity_with_null_uninitialized_m() { + let base = 0x7fff_0000_0000usize; + assert_eq!(validate_snapshot(base, &valid_snapshot(base)), Ok(())); + } + + #[test] + fn rejects_wrong_a_or_b_class_identity() { + let base = 0x7fff_0000_0000usize; + let mut snapshot = valid_snapshot(base); + snapshot.a_vtable += 8; + assert_eq!( + validate_snapshot(base, &snapshot), + Err(ValidationError::AVtableMismatch) + ); + + let mut snapshot = valid_snapshot(base); + snapshot.b_vtable += 8; + assert_eq!( + validate_snapshot(base, &snapshot), + Err(ValidationError::BVtableMismatch) + ); + } + + #[test] + fn rejects_changed_patch_byte_or_live_collection() { + let base = 0x7fff_0000_0000usize; + let mut snapshot = valid_snapshot(base); + snapshot.b_ready = 1; + assert_eq!( + validate_snapshot(base, &snapshot), + Err(ValidationError::ReadyByteUnexpected) + ); + + let mut snapshot = valid_snapshot(base); + snapshot.b_coll = 0x1234_0000; + assert_eq!( + validate_snapshot(base, &snapshot), + Err(ValidationError::CollectionNotNull) + ); + } + + #[test] + fn state_machine_is_forward_only_and_fail_closed() { + assert!(valid_transition( + RuntimeState::Disabled, + RuntimeState::Resolved + )); + assert!(valid_transition( + RuntimeState::Resolved, + RuntimeState::Validated + )); + assert!(valid_transition( + RuntimeState::Validated, + RuntimeState::Committed + )); + assert!(valid_transition(RuntimeState::Parsed, RuntimeState::Failed)); + assert!(!valid_transition( + RuntimeState::Validated, + RuntimeState::Resolved + )); + assert!(!valid_transition( + RuntimeState::Failed, + RuntimeState::Resolved + )); + assert!(!valid_transition( + RuntimeState::Resolved, + RuntimeState::Committed + )); + } +} diff --git a/openfut-hook/src/sbc_request_trace.rs b/openfut-hook/src/sbc_request_trace.rs new file mode 100644 index 0000000..fe8691f --- /dev/null +++ b/openfut-hook/src/sbc_request_trace.rs @@ -0,0 +1,420 @@ +//! Optional category-request callback tracing through its class-unique vtable. +//! +//! Unlike the entry trampolines, these probes atomically replace two aligned +//! pointer slots. The branchy callback dispatcher at 0x180154830 is never patched. + +use core::ffi::c_void; +use core::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +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::{ + VirtualProtect, VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_GUARD, PAGE_NOACCESS, + PAGE_READWRITE, +}; +use windows_sys::Win32::System::Threading::GetCurrentThreadId; + +const REQUEST_VTABLE_RVA: usize = 0x22e5c0; +const SLOT_88: usize = 0x88; +const SLOT_90: usize = 0x90; +const ORIGINAL_88_RVA: usize = 0x1631e0; +const ORIGINAL_90_RVA: usize = 0x154830; +const ORIGINAL_88_SIGNATURE: [u8; 16] = [ + 0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x74, 0x24, 0x10, 0x57, 0x48, 0x83, 0xec, 0x20, 0x48, +]; +const ORIGINAL_90_SIGNATURE: [u8; 16] = [ + 0x4c, 0x8b, 0x81, 0x90, 0x00, 0x00, 0x00, 0x4d, 0x85, 0xc0, 0x74, 0x0a, 0x48, 0x81, 0xc1, 0x90, +]; + +type Callback88 = unsafe extern "system" fn(*mut c_void, *mut c_void); +type Callback90 = unsafe extern "system" fn(*mut c_void, *mut c_void); + +static ENABLED: AtomicBool = AtomicBool::new(false); +static INSTALLED: AtomicBool = AtomicBool::new(false); +static ORIGINAL_88: AtomicUsize = AtomicUsize::new(0); +static ORIGINAL_90: AtomicUsize = AtomicUsize::new(0); +static ENTER_88: AtomicU64 = AtomicU64::new(0); +static EXIT_88: AtomicU64 = AtomicU64::new(0); +static ENTER_90: AtomicU64 = AtomicU64::new(0); +static EXIT_90: AtomicU64 = AtomicU64::new(0); +static LAST_REQUEST_88: AtomicUsize = AtomicUsize::new(0); +static LAST_ARGUMENT_88: AtomicUsize = AtomicUsize::new(0); +static LAST_THREAD_88: AtomicUsize = AtomicUsize::new(0); +static LAST_REQUEST_90: AtomicUsize = AtomicUsize::new(0); +static LAST_ARGUMENT_90: AtomicUsize = AtomicUsize::new(0); +static LAST_THREAD_90: AtomicUsize = AtomicUsize::new(0); +static CALLBACK_90: AtomicUsize = AtomicUsize::new(0); +static CALLBACK_98: AtomicUsize = AtomicUsize::new(0); +static CALLBACK_A0: AtomicUsize = AtomicUsize::new(0); +static CALLBACK_A8: AtomicUsize = AtomicUsize::new(0); +static SELECTED_90: AtomicUsize = AtomicUsize::new(0); +static OWNER_88: AtomicUsize = AtomicUsize::new(0); +static OWNER_VTABLE_88: AtomicUsize = AtomicUsize::new(0); +static CONSUMER_88: AtomicUsize = AtomicUsize::new(0); +static RESPONSE_VTABLE_88: AtomicUsize = AtomicUsize::new(0); +static OWNER_SLOT_BEFORE_88: AtomicUsize = AtomicUsize::new(0); +static OWNER_SLOT_AFTER_88: AtomicUsize = AtomicUsize::new(0); + +fn enabled(value: Option<&str>) -> bool { + matches!(value, Some("1")) +} + +fn checked_va(base: usize, rva: usize) -> Option { + base.checked_add(rva) +} + +fn image_range_covered(size: usize, rva: usize, length: usize) -> bool { + rva.checked_add(length) + .map(|end| end <= size) + .unwrap_or(false) +} + +unsafe fn readable_range(address: usize, length: usize) -> bool { + let Some(end) = address.checked_add(length) else { + return false; + }; + let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); + VirtualQuery( + address as _, + &mut mbi, + core::mem::size_of::(), + ) != 0 + && mbi.State == MEM_COMMIT + && mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS) == 0 + && end <= mbi.BaseAddress as usize + mbi.RegionSize +} + +unsafe fn range_in_image_allocation(base: usize, address: usize, length: usize) -> bool { + let Some(end) = address.checked_add(length) else { + return false; + }; + let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); + VirtualQuery( + address as _, + &mut mbi, + core::mem::size_of::(), + ) != 0 + && mbi.AllocationBase as usize == base + && mbi.State == MEM_COMMIT + && mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS) == 0 + && end <= mbi.BaseAddress as usize + mbi.RegionSize +} + +unsafe fn image_size(base: usize) -> Option { + if !readable_range(base, 0x1000) || *(base as *const u16) != 0x5a4d { + return None; + } + let pe_offset = *((base + 0x3c) as *const u32) as usize; + if pe_offset > 0xf00 { + return None; + } + let pe = base.checked_add(pe_offset)?; + if *(pe as *const u32) != 0x0000_4550 { + return None; + } + let size_field = pe.checked_add(24 + 0x38)?; + Some(*(size_field as *const u32) as usize) +} + +unsafe fn signature_matches(address: usize, expected: &[u8]) -> bool { + readable_range(address, expected.len()) + && core::slice::from_raw_parts(address as *const u8, expected.len()) == expected +} + +unsafe fn guarded_ptr(address: usize) -> usize { + if address & 7 == 0 && readable_range(address, 8) { + core::ptr::read_volatile(address as *const usize) + } else { + 0 + } +} + +unsafe fn field_ptr(object: usize, offset: usize) -> usize { + object + .checked_add(offset) + .map(|address| guarded_ptr(address)) + .unwrap_or(0) +} + +unsafe extern "system" fn wrapper_88(request: *mut c_void, argument: *mut c_void) { + ENTER_88.fetch_add(1, Ordering::Relaxed); + LAST_REQUEST_88.store(request as usize, Ordering::Relaxed); + LAST_ARGUMENT_88.store(argument as usize, Ordering::Relaxed); + LAST_THREAD_88.store(GetCurrentThreadId() as usize, Ordering::Relaxed); + let request_address = request as usize; + let argument_address = argument as usize; + let owner = field_ptr(request_address, 8); + let owner_vtable = guarded_ptr(owner); + let consumer = field_ptr(owner_vtable, 0x18); + let owner_slot_before = guarded_ptr(argument_address); + let response_vtable = guarded_ptr(owner_slot_before); + OWNER_88.store(owner, Ordering::Relaxed); + OWNER_VTABLE_88.store(owner_vtable, Ordering::Relaxed); + CONSUMER_88.store(consumer, Ordering::Relaxed); + RESPONSE_VTABLE_88.store(response_vtable, Ordering::Relaxed); + OWNER_SLOT_BEFORE_88.store(owner_slot_before, Ordering::Relaxed); + let original: Callback88 = core::mem::transmute(ORIGINAL_88.load(Ordering::Acquire)); + original(request, argument); + OWNER_SLOT_AFTER_88.store(guarded_ptr(argument_address), Ordering::Relaxed); + EXIT_88.fetch_add(1, Ordering::Release); +} + +unsafe extern "system" fn wrapper_90(request: *mut c_void, argument: *mut c_void) { + ENTER_90.fetch_add(1, Ordering::Relaxed); + LAST_REQUEST_90.store(request as usize, Ordering::Relaxed); + LAST_ARGUMENT_90.store(argument as usize, Ordering::Relaxed); + LAST_THREAD_90.store(GetCurrentThreadId() as usize, Ordering::Relaxed); + let request_address = request as usize; + let callback_90 = field_ptr(request_address, 0x90); + let callback_98 = field_ptr(request_address, 0x98); + let callback_a0 = field_ptr(request_address, 0xa0); + let callback_a8 = field_ptr(request_address, 0xa8); + CALLBACK_90.store(callback_90, Ordering::Relaxed); + CALLBACK_98.store(callback_98, Ordering::Relaxed); + CALLBACK_A0.store(callback_a0, Ordering::Relaxed); + CALLBACK_A8.store(callback_a8, Ordering::Relaxed); + SELECTED_90.store( + if callback_90 != 0 { + callback_90 + } else { + callback_a0 + }, + Ordering::Relaxed, + ); + let original: Callback90 = core::mem::transmute(ORIGINAL_90.load(Ordering::Acquire)); + original(request, argument); + EXIT_90.fetch_add(1, Ordering::Release); +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SwapOutcome { + Installed, + CleanFailure, + DegradedFirstSlotActive, + DegradedProtection, +} + +unsafe fn install_slots(base: usize) -> SwapOutcome { + let Some(vtable) = checked_va(base, REQUEST_VTABLE_RVA) else { + return SwapOutcome::CleanFailure; + }; + let Some(original_88) = checked_va(base, ORIGINAL_88_RVA) else { + return SwapOutcome::CleanFailure; + }; + let Some(original_90) = checked_va(base, ORIGINAL_90_RVA) else { + return SwapOutcome::CleanFailure; + }; + let Some(slot_88) = checked_va(vtable, SLOT_88) else { + return SwapOutcome::CleanFailure; + }; + let Some(slot_90) = checked_va(vtable, SLOT_90) else { + return SwapOutcome::CleanFailure; + }; + let Some(size) = image_size(base) else { + return SwapOutcome::CleanFailure; + }; + if !image_range_covered(size, REQUEST_VTABLE_RVA, SLOT_90 + 8) + || !image_range_covered(size, ORIGINAL_88_RVA, ORIGINAL_88_SIGNATURE.len()) + || !image_range_covered(size, ORIGINAL_90_RVA, ORIGINAL_90_SIGNATURE.len()) + || slot_88 & 7 != 0 + || slot_90 & 7 != 0 + || !crate::sbc_trace::validate_cards_build(base) + || !range_in_image_allocation(base, vtable, SLOT_90 + 8) + || !range_in_image_allocation(base, original_88, ORIGINAL_88_SIGNATURE.len()) + || !range_in_image_allocation(base, original_90, ORIGINAL_90_SIGNATURE.len()) + || !signature_matches(original_88, &ORIGINAL_88_SIGNATURE) + || !signature_matches(original_90, &ORIGINAL_90_SIGNATURE) + || (slot_88 as *const AtomicUsize) + .as_ref() + .unwrap() + .load(Ordering::Acquire) + != original_88 + || (slot_90 as *const AtomicUsize) + .as_ref() + .unwrap() + .load(Ordering::Acquire) + != original_90 + { + return SwapOutcome::CleanFailure; + } + + let mut pinned = core::ptr::null_mut(); + if GetModuleHandleExA( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN, + vtable as *const u8, + &mut pinned, + ) == 0 + || pinned as usize != base + { + return SwapOutcome::CleanFailure; + } + ORIGINAL_88.store(original_88, Ordering::Release); + ORIGINAL_90.store(original_90, Ordering::Release); + + // Both slots share the same vtable page. Keep it writable only across the two + // compare/exchanges and possible rollback. + let mut old = 0u32; + if VirtualProtect(slot_88 as _, 16, PAGE_READWRITE, &mut old) == 0 { + return SwapOutcome::CleanFailure; + } + let atom_88 = &*(slot_88 as *const AtomicUsize); + let atom_90 = &*(slot_90 as *const AtomicUsize); + let first = atom_88.compare_exchange( + original_88, + wrapper_88 as *const () as usize, + Ordering::AcqRel, + Ordering::Acquire, + ); + let outcome = if first.is_err() { + SwapOutcome::CleanFailure + } else if atom_90 + .compare_exchange( + original_90, + wrapper_90 as *const () as usize, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + { + SwapOutcome::Installed + } else if atom_88 + .compare_exchange( + wrapper_88 as *const () as usize, + original_88, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + { + SwapOutcome::CleanFailure + } else { + SwapOutcome::DegradedFirstSlotActive + }; + let mut ignored = 0u32; + if VirtualProtect(slot_88 as _, 16, old, &mut ignored) == 0 { + return if outcome == SwapOutcome::DegradedFirstSlotActive { + outcome + } else { + SwapOutcome::DegradedProtection + }; + } + outcome +} + +unsafe fn worker() { + for _ in 0..700u32 { + if crate::sbc_trace::code_patch_installers_ready() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(500)); + } + if !crate::sbc_trace::code_patch_installers_ready() { + crate::write_log("SBC_REQUEST_TRACE: code-patch readiness timeout; inactive\n"); + return; + } + let mut base = 0usize; + for _ in 0..600u32 { + base = GetModuleHandleA(b"CardsDLL_Win64_retail.dll\0".as_ptr()) as usize; + if base != 0 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(500)); + } + match if base == 0 { SwapOutcome::CleanFailure } else { install_slots(base) } { + SwapOutcome::Installed => { + INSTALLED.store(true, Ordering::Release); + crate::write_log("SBC_REQUEST_TRACE: category request vtable slots +0x88/+0x90 installed\n"); + let mut seen_88 = 0u64; + let mut seen_90 = 0u64; + let mut reports = 0u8; + while reports < 32 { + std::thread::sleep(std::time::Duration::from_millis(250)); + let count_88 = ENTER_88.load(Ordering::Acquire); + let count_90 = ENTER_90.load(Ordering::Acquire); + if count_88 != seen_88 || count_90 != seen_90 { + crate::write_log(&format!( + "SBC_REQUEST_TRACE: +88 entry={} exit={} req={:#x} arg={:#x} tid={} owner={:#x} ovt={:#x} consumer={:#x} rvt={:#x} slot={:#x}->{:#x}; +90 entry={} exit={} req={:#x} arg={:#x} tid={} cb90={:#x} cb98={:#x} cba0={:#x} cba8={:#x} selected={:#x}\n", + count_88, + EXIT_88.load(Ordering::Acquire), + LAST_REQUEST_88.load(Ordering::Relaxed), + LAST_ARGUMENT_88.load(Ordering::Relaxed), + LAST_THREAD_88.load(Ordering::Relaxed), + OWNER_88.load(Ordering::Relaxed), + OWNER_VTABLE_88.load(Ordering::Relaxed), + CONSUMER_88.load(Ordering::Relaxed), + RESPONSE_VTABLE_88.load(Ordering::Relaxed), + OWNER_SLOT_BEFORE_88.load(Ordering::Relaxed), + OWNER_SLOT_AFTER_88.load(Ordering::Relaxed), + count_90, + EXIT_90.load(Ordering::Acquire), + LAST_REQUEST_90.load(Ordering::Relaxed), + LAST_ARGUMENT_90.load(Ordering::Relaxed), + LAST_THREAD_90.load(Ordering::Relaxed), + CALLBACK_90.load(Ordering::Relaxed), + CALLBACK_98.load(Ordering::Relaxed), + CALLBACK_A0.load(Ordering::Relaxed), + CALLBACK_A8.load(Ordering::Relaxed), + SELECTED_90.load(Ordering::Relaxed), + )); + seen_88 = count_88; + seen_90 = count_90; + reports += 1; + } + } + crate::write_log("SBC_REQUEST_TRACE: report cap reached; vtable probes remain passive\n"); + } + SwapOutcome::CleanFailure => crate::write_log("SBC_REQUEST_TRACE: clean install failure; inactive\n"), + SwapOutcome::DegradedFirstSlotActive => crate::write_log( + "SBC_REQUEST_TRACE: DEGRADED slot +0x88 may remain active; terminate game now\n", + ), + SwapOutcome::DegradedProtection => crate::write_log( + "SBC_REQUEST_TRACE: DEGRADED vtable page protection restore failed; terminate game now\n", + ), + } +} + +pub(crate) fn install() { + let armed = enabled(std::env::var("OPENFUT_SBC_REQUEST_TRACE").ok().as_deref()); + ENABLED.store(armed, Ordering::Release); + if !armed { + crate::write_log("SBC_REQUEST_TRACE: disabled\n"); + return; + } + crate::write_log("SBC_REQUEST_TRACE: requested; deferred install starting\n"); + std::thread::spawn(|| unsafe { worker() }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn gate_is_exact() { + assert!(!enabled(None)); + assert!(!enabled(Some("true"))); + assert!(enabled(Some("1"))); + } + + #[test] + fn slots_are_aligned_and_class_local() { + assert_eq!((REQUEST_VTABLE_RVA + SLOT_88) & 7, 0); + assert_eq!((REQUEST_VTABLE_RVA + SLOT_90) & 7, 0); + assert_eq!(SLOT_90 - SLOT_88, 8); + } + + #[test] + fn image_coverage_is_checked_and_overflow_safe() { + assert!(image_range_covered( + 0x230000, + REQUEST_VTABLE_RVA, + SLOT_90 + 8 + )); + assert!(!image_range_covered( + REQUEST_VTABLE_RVA + SLOT_90, + REQUEST_VTABLE_RVA, + SLOT_90 + 8 + )); + assert!(!image_range_covered(usize::MAX, usize::MAX, 8)); + } +} diff --git a/openfut-hook/src/sbc_trace.rs b/openfut-hook/src/sbc_trace.rs new file mode 100644 index 0000000..cd389d2 --- /dev/null +++ b/openfut-hook/src/sbc_trace.rs @@ -0,0 +1,1050 @@ +//! Passive, behavior-preserving trace hooks for FIFA 17's SBC category response. + +use core::ffi::c_void; +use core::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use windows_sys::Win32::Foundation::{ + CloseHandle, GetLastError, ERROR_NO_MORE_FILES, HANDLE, INVALID_HANDLE_VALUE, +}; +use windows_sys::Win32::System::Diagnostics::Debug::{ + FlushInstructionCache, GetThreadContext, CONTEXT, CONTEXT_CONTROL_AMD64, +}; +use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Thread32First, Thread32Next, TH32CS_SNAPTHREAD, THREADENTRY32, +}; +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::{ + VirtualAlloc, VirtualFree, VirtualProtect, VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, + MEM_RELEASE, MEM_RESERVE, PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY, + PAGE_GUARD, PAGE_NOACCESS, PAGE_READWRITE, +}; +use windows_sys::Win32::System::Threading::{ + GetCurrentProcess, GetCurrentProcessId, GetCurrentThreadId, OpenThread, ResumeThread, + SuspendThread, THREAD_GET_CONTEXT, THREAD_SUSPEND_RESUME, +}; + +pub(crate) const CATEGORY_FACTORY_RVA: usize = 0x17aa10; +pub(crate) const CATEGORY_DESERIALIZER_RVA: usize = 0x17b2b0; +const COPY_LEN: usize = 19; +const ABS_JUMP_LEN: usize = 14; +const TRAMPOLINE_LEN: usize = COPY_LEN + ABS_JUMP_LEN; +const NOTIFIER_RVA: usize = 0x17aa80; +const NOTIFIER_COPY_LEN: usize = 15; +const NOTIFIER_SIGNATURE: [u8; 32] = [ + 0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x74, 0x24, 0x10, 0x57, 0x48, 0x83, 0xec, 0x20, 0x48, + 0x8b, 0x59, 0x58, 0x48, 0x8b, 0x71, 0x60, 0x33, 0xff, 0x48, 0x2b, 0xf3, 0xc6, 0x81, 0x88, 0x00, +]; +const MAX_PEERS: usize = 512; +const MAX_QUIESCE_PASSES: usize = 8; +const CONTROL_RVA: usize = 0x180d00; +const CONTROL_SIGNATURE: [u8; 12] = [ + 0x48, 0x83, 0xec, 0x28, 0x48, 0x85, 0xc9, 0x74, 0x50, 0x45, 0x33, 0xc0, +]; + +const FACTORY_SIGNATURE: [u8; 32] = [ + 0x48, 0x89, 0x4c, 0x24, 0x08, 0x53, 0x48, 0x83, 0xec, 0x30, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe, + 0xff, 0xff, 0xff, 0x33, 0xc0, 0x89, 0x44, 0x24, 0x48, 0x48, 0x8d, 0x44, 0x24, 0x48, 0x48, 0x89, +]; +const DESERIALIZER_SIGNATURE: [u8; 32] = [ + 0x48, 0x8b, 0xc4, 0x55, 0x41, 0x54, 0x41, 0x55, 0x41, 0x56, 0x41, 0x57, 0x48, 0x8d, 0xa8, 0xe8, + 0xfd, 0xff, 0xff, 0x48, 0x81, 0xec, 0xf0, 0x02, 0x00, 0x00, 0x48, 0xc7, 0x45, 0x98, 0xfe, 0xff, +]; + +type FactoryFn = unsafe extern "system" fn(*mut c_void) -> *mut c_void; +type DeserializerFn = unsafe extern "system" fn(*mut c_void, *mut c_void) -> u8; + +static ENABLED: AtomicBool = AtomicBool::new(false); +static STATE: AtomicUsize = AtomicUsize::new(TraceState::Disabled as usize); +static FACTORY_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0); +static DESERIALIZER_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0); +static FACTORY_ENTRIES: AtomicU64 = AtomicU64::new(0); +static FACTORY_EXITS: AtomicU64 = AtomicU64::new(0); +static FACTORY_LAST_THIS: AtomicUsize = AtomicUsize::new(0); +static FACTORY_LAST_RESULT: AtomicUsize = AtomicUsize::new(0); +static FACTORY_LAST_THREAD: AtomicUsize = AtomicUsize::new(0); +static DESERIALIZER_ENTRIES: AtomicU64 = AtomicU64::new(0); +static DESERIALIZER_EXITS: AtomicU64 = AtomicU64::new(0); +static DESERIALIZER_LAST_THIS: AtomicUsize = AtomicUsize::new(0); +static DESERIALIZER_LAST_READER: AtomicUsize = AtomicUsize::new(0); +static DESERIALIZER_LAST_RESULT: AtomicBool = AtomicBool::new(false); +static DESERIALIZER_LAST_THREAD: AtomicUsize = AtomicUsize::new(0); +static TRACE_BASE: AtomicUsize = AtomicUsize::new(0); +static DESERIALIZER_EXIT_M: AtomicUsize = AtomicUsize::new(0); +static DESERIALIZER_EXIT_COUNT: AtomicUsize = AtomicUsize::new(0); +static DESERIALIZER_EXIT_B_READY: AtomicUsize = AtomicUsize::new(usize::MAX); +static NOTIFIER_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0); +static NOTIFIER_ENTRIES: AtomicU64 = AtomicU64::new(0); +static NOTIFIER_EXITS: AtomicU64 = AtomicU64::new(0); +static NOTIFIER_CTX: AtomicUsize = AtomicUsize::new(0); +static NOTIFIER_BYTE_BEFORE: AtomicUsize = AtomicUsize::new(usize::MAX); +static NOTIFIER_BYTE_AFTER: AtomicUsize = AtomicUsize::new(usize::MAX); +static NOTIFIER_BEGIN: AtomicUsize = AtomicUsize::new(0); +static NOTIFIER_END: AtomicUsize = AtomicUsize::new(0); +static NOTIFIER_COUNT: AtomicUsize = AtomicUsize::new(usize::MAX); +static PATCH_INSTALLER_BUSY: AtomicBool = AtomicBool::new(false); +static CODE_PATCH_PENDING: AtomicUsize = AtomicUsize::new(0); + +struct PatchInstallerGate; + +impl Drop for PatchInstallerGate { + fn drop(&mut self) { + PATCH_INSTALLER_BUSY.store(false, Ordering::Release); + } +} + +fn acquire_patch_installer_gate() -> Option { + for _ in 0..200 { + if PATCH_INSTALLER_BUSY + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + return Some(PatchInstallerGate); + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + None +} + +struct CodeInstallerPending; + +impl Drop for CodeInstallerPending { + fn drop(&mut self) { + CODE_PATCH_PENDING.fetch_sub(1, Ordering::AcqRel); + } +} + +pub(crate) fn code_patch_installers_ready() -> bool { + CODE_PATCH_PENDING.load(Ordering::Acquire) == 0 +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(usize)] +enum TraceState { + Disabled, + Requested, + Installed, + Failed, + DegradedHookActive, + DegradedProcessState, + DegradedHookAndProcess, +} + +fn env_enabled(value: Option<&str>) -> bool { + matches!(value, Some("1")) +} + +fn target_va(base: usize, rva: usize) -> Option { + base.checked_add(rva) +} + +fn absolute_jump(destination: usize) -> [u8; ABS_JUMP_LEN] { + let mut jump = [0u8; ABS_JUMP_LEN]; + jump[..6].copy_from_slice(&[0xff, 0x25, 0, 0, 0, 0]); + jump[6..].copy_from_slice(&(destination as u64).to_le_bytes()); + jump +} + +fn instruction_pointer_in_span(rip: usize, target: usize) -> bool { + target + .checked_add(COPY_LEN) + .map(|end| rip >= target && rip < end) + .unwrap_or(true) +} + +struct SuspendedPeers { + handles: [HANDLE; MAX_PEERS], + tids: [u32; MAX_PEERS], + count: usize, +} + +impl SuspendedPeers { + fn empty() -> Self { + Self { + handles: [core::ptr::null_mut(); MAX_PEERS], + tids: [0; MAX_PEERS], + count: 0, + } + } + + fn contains_tid(&self, tid: u32) -> bool { + self.tids[..self.count].contains(&tid) + } + + unsafe fn resume_all(&mut self) -> bool { + let mut all_resumed = true; + for index in (0..self.count).rev() { + let handle = self.handles[index]; + if handle.is_null() { + continue; + } + if ResumeThread(handle) == u32::MAX { + all_resumed = false; + continue; + } + CloseHandle(handle); + self.handles[index] = core::ptr::null_mut(); + } + all_resumed + } +} + +impl Drop for SuspendedPeers { + fn drop(&mut self) { + // Emergency retry only. A handle whose thread still cannot be resumed is + // intentionally leaked rather than closed while its thread is suspended. + unsafe { + let _ = self.resume_all(); + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum QuiesceFailure { + Acquire, + Resume, +} + +/// Stop and inspect every peer thread before touching either entry point. Any +/// incomplete enumeration/access/context operation fails the transaction closed. +unsafe fn suspend_peers( + factory: usize, + deserializer: usize, +) -> Result { + let process_id = GetCurrentProcessId(); + let current_thread_id = GetCurrentThreadId(); + let mut peers = SuspendedPeers::empty(); + for _ in 0..MAX_QUIESCE_PASSES { + // Enumerate into fixed stack storage before suspending anything found in + // this pass. Later passes close the thread-creation race to a fixed point. + let mut candidates = [0u32; MAX_PEERS]; + let mut candidate_count = 0usize; + let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0); + if snapshot == INVALID_HANDLE_VALUE { + break; + } + let mut entry: THREADENTRY32 = core::mem::zeroed(); + entry.dwSize = core::mem::size_of::() as u32; + let mut available = Thread32First(snapshot, &mut entry) != 0; + if !available { + CloseHandle(snapshot); + break; + } + let mut enumeration_ok = true; + while available { + let tid = entry.th32ThreadID; + if entry.th32OwnerProcessID == process_id + && tid != current_thread_id + && !peers.contains_tid(tid) + { + if peers.count + candidate_count >= MAX_PEERS { + enumeration_ok = false; + break; + } + if !candidates[..candidate_count].contains(&tid) { + candidates[candidate_count] = tid; + candidate_count += 1; + } + } + entry.dwSize = core::mem::size_of::() as u32; + available = Thread32Next(snapshot, &mut entry) != 0; + if !available && GetLastError() != ERROR_NO_MORE_FILES { + enumeration_ok = false; + } + } + CloseHandle(snapshot); + if !enumeration_ok { + return Err(if peers.resume_all() { + QuiesceFailure::Acquire + } else { + QuiesceFailure::Resume + }); + } + if candidate_count == 0 { + return Ok(peers); + } + for &tid in &candidates[..candidate_count] { + let handle = OpenThread(THREAD_SUSPEND_RESUME | THREAD_GET_CONTEXT, 0, tid); + if handle.is_null() || SuspendThread(handle) == u32::MAX { + if !handle.is_null() { + CloseHandle(handle); + } + return Err(if peers.resume_all() { + QuiesceFailure::Acquire + } else { + QuiesceFailure::Resume + }); + } + peers.handles[peers.count] = handle; + peers.tids[peers.count] = tid; + peers.count += 1; + let mut context: CONTEXT = core::mem::zeroed(); + context.ContextFlags = CONTEXT_CONTROL_AMD64; + if GetThreadContext(handle, &mut context) == 0 + || instruction_pointer_in_span(context.Rip as usize, factory) + || instruction_pointer_in_span(context.Rip as usize, deserializer) + { + return Err(if peers.resume_all() { + QuiesceFailure::Acquire + } else { + QuiesceFailure::Resume + }); + } + } + } + Err(if peers.resume_all() { + QuiesceFailure::Acquire + } else { + QuiesceFailure::Resume + }) +} + +unsafe fn executable_range(address: usize, length: usize) -> bool { + let Some(end) = address.checked_add(length) else { + return false; + }; + let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); + if VirtualQuery( + address as _, + &mut mbi, + core::mem::size_of::(), + ) == 0 + || mbi.State != MEM_COMMIT + || mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS) != 0 + { + return false; + } + let protection = mbi.Protect & 0xff; + matches!( + protection, + PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY + ) && end <= mbi.BaseAddress as usize + mbi.RegionSize +} + +unsafe fn executable_range_in_image(base: usize, address: usize, length: usize) -> bool { + let Some(end) = address.checked_add(length) else { + return false; + }; + let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); + if VirtualQuery( + address as _, + &mut mbi, + core::mem::size_of::(), + ) == 0 + { + return false; + } + executable_range(address, length) + && mbi.AllocationBase as usize == base + && end <= mbi.BaseAddress as usize + mbi.RegionSize +} + +unsafe fn readable_range(address: usize, length: usize) -> bool { + let Some(end) = address.checked_add(length) else { + return false; + }; + let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); + VirtualQuery( + address as _, + &mut mbi, + core::mem::size_of::(), + ) != 0 + && mbi.State == MEM_COMMIT + && mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS) == 0 + && end <= mbi.BaseAddress as usize + mbi.RegionSize +} + +unsafe fn guarded_usize(address: usize) -> Option { + (address & 7 == 0 && readable_range(address, 8)) + .then(|| core::ptr::read_volatile(address as *const usize)) +} + +unsafe fn guarded_u16(address: usize) -> Option { + readable_range(address, 2).then(|| core::ptr::read_volatile(address as *const u16)) +} + +unsafe fn guarded_u8(address: usize) -> Option { + readable_range(address, 1).then(|| core::ptr::read_volatile(address as *const u8)) +} + +unsafe fn valid_cards_image(base: usize) -> bool { + let Some(control) = base.checked_add(CONTROL_RVA) else { + return false; + }; + if !readable_range(base, 0x1000) || !executable_range(control, CONTROL_SIGNATURE.len()) { + return false; + } + if *(base as *const u16) != 0x5a4d { + return false; + } + let pe_off = *((base + 0x3c) as *const u32) as usize; + if pe_off > 0xf00 { + return false; + } + let Some(pe) = base.checked_add(pe_off) else { + return false; + }; + if *(pe as *const u32) != 0x0000_4550 { + return false; + } + let Some(size_field) = pe.checked_add(24 + 0x38) else { + return false; + }; + let size = *(size_field as *const u32) as usize; + let required = CATEGORY_DESERIALIZER_RVA + DESERIALIZER_SIGNATURE.len(); + size > required + && core::slice::from_raw_parts(control as *const u8, CONTROL_SIGNATURE.len()) + == CONTROL_SIGNATURE +} + +pub(crate) unsafe fn validate_cards_build(base: usize) -> bool { + valid_cards_image(base) +} + +unsafe fn signature_matches(target: usize, signature: &[u8; 32]) -> bool { + core::slice::from_raw_parts(target as *const u8, signature.len()) == signature +} + +unsafe fn allocate_trampoline(target: usize, copy_len: usize) -> Option { + let trampoline_len = copy_len.checked_add(ABS_JUMP_LEN)?; + let memory = VirtualAlloc( + core::ptr::null(), + trampoline_len, + MEM_COMMIT | MEM_RESERVE, + PAGE_READWRITE, + ) as usize; + if memory == 0 { + return None; + } + core::ptr::copy_nonoverlapping(target as *const u8, memory as *mut u8, copy_len); + let Some(resume) = target.checked_add(copy_len) else { + VirtualFree(memory as _, 0, MEM_RELEASE); + return None; + }; + let jump = absolute_jump(resume); + core::ptr::copy_nonoverlapping(jump.as_ptr(), (memory + copy_len) as *mut u8, ABS_JUMP_LEN); + let mut old = 0u32; + if VirtualProtect(memory as _, trampoline_len, PAGE_EXECUTE_READ, &mut old) == 0 + || FlushInstructionCache(GetCurrentProcess(), memory as _, trampoline_len) == 0 + { + VirtualFree(memory as _, 0, MEM_RELEASE); + return None; + } + Some(memory) +} + +unsafe fn write_entry( + target: usize, + destination: usize, + original: &[u8; COPY_LEN], + published: &mut bool, +) -> Result<(), bool> { + let mut patch = [0x90u8; COPY_LEN]; + patch[..ABS_JUMP_LEN].copy_from_slice(&absolute_jump(destination)); + let mut old = 0u32; + if VirtualProtect(target as _, COPY_LEN, PAGE_EXECUTE_READWRITE, &mut old) == 0 { + return Err(true); + } + // From this point onward another processor may observe the detour, even if a + // later cache/protection operation fails and rollback restores original bytes. + *published = true; + core::ptr::copy_nonoverlapping(patch.as_ptr(), target as *mut u8, COPY_LEN); + let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, COPY_LEN) != 0; + let mut ignored = 0u32; + let restored = VirtualProtect(target as _, COPY_LEN, old, &mut ignored) != 0; + if flushed && restored { + Ok(()) + } else { + // `true` means the caller may safely free the trampoline. A false value + // means rollback itself failed, so executable backing must be retained. + Err(restore_entry(target, original)) + } +} + +unsafe fn restore_entry(target: usize, original: &[u8; COPY_LEN]) -> bool { + let mut old = 0u32; + if VirtualProtect(target as _, COPY_LEN, PAGE_EXECUTE_READWRITE, &mut old) == 0 { + return false; + } + core::ptr::copy_nonoverlapping(original.as_ptr(), target as *mut u8, COPY_LEN); + let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, COPY_LEN) != 0; + let mut ignored = 0u32; + let restored = VirtualProtect(target as _, COPY_LEN, old, &mut ignored) != 0; + flushed && restored +} + +unsafe fn restore_notifier_entry(target: usize) -> bool { + let original: [u8; NOTIFIER_COPY_LEN] = + NOTIFIER_SIGNATURE[..NOTIFIER_COPY_LEN].try_into().unwrap(); + let mut old = 0u32; + if VirtualProtect( + target as _, + NOTIFIER_COPY_LEN, + PAGE_EXECUTE_READWRITE, + &mut old, + ) == 0 + { + return false; + } + core::ptr::copy_nonoverlapping(original.as_ptr(), target as *mut u8, NOTIFIER_COPY_LEN); + let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, NOTIFIER_COPY_LEN) != 0; + let mut ignored = 0u32; + flushed && VirtualProtect(target as _, NOTIFIER_COPY_LEN, old, &mut ignored) != 0 +} + +unsafe fn write_notifier_entry( + target: usize, + destination: usize, + published: &mut bool, +) -> Result<(), bool> { + let mut patch = [0x90u8; NOTIFIER_COPY_LEN]; + patch[..ABS_JUMP_LEN].copy_from_slice(&absolute_jump(destination)); + let mut old = 0u32; + if VirtualProtect( + target as _, + NOTIFIER_COPY_LEN, + PAGE_EXECUTE_READWRITE, + &mut old, + ) == 0 + { + return Err(true); + } + *published = true; + core::ptr::copy_nonoverlapping(patch.as_ptr(), target as *mut u8, NOTIFIER_COPY_LEN); + let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, NOTIFIER_COPY_LEN) != 0; + let mut ignored = 0u32; + if flushed && VirtualProtect(target as _, NOTIFIER_COPY_LEN, old, &mut ignored) != 0 { + Ok(()) + } else { + Err(restore_notifier_entry(target)) + } +} + +unsafe extern "system" fn notifier_wrapper(ctx: *mut c_void) { + NOTIFIER_ENTRIES.fetch_add(1, Ordering::Relaxed); + let address = ctx as usize; + NOTIFIER_CTX.store(address, Ordering::Relaxed); + NOTIFIER_BYTE_BEFORE.store( + address + .checked_add(0x88) + .and_then(|p| guarded_u8(p)) + .map(usize::from) + .unwrap_or(usize::MAX), + Ordering::Relaxed, + ); + let begin = address + .checked_add(0x58) + .and_then(|p| guarded_usize(p)) + .unwrap_or(0); + let end = address + .checked_add(0x60) + .and_then(|p| guarded_usize(p)) + .unwrap_or(0); + let count = if end >= begin && (end - begin) & 7 == 0 { + (end - begin) / 8 + } else { + usize::MAX + }; + NOTIFIER_BEGIN.store(begin, Ordering::Relaxed); + NOTIFIER_END.store(end, Ordering::Relaxed); + NOTIFIER_COUNT.store(count, Ordering::Relaxed); + let original: unsafe extern "system" fn(*mut c_void) = + core::mem::transmute(NOTIFIER_TRAMPOLINE.load(Ordering::Acquire)); + original(ctx); + NOTIFIER_BYTE_AFTER.store( + address + .checked_add(0x88) + .and_then(|p| guarded_u8(p)) + .map(usize::from) + .unwrap_or(usize::MAX), + Ordering::Relaxed, + ); + NOTIFIER_EXITS.fetch_add(1, Ordering::Release); +} + +unsafe extern "system" fn factory_wrapper(this: *mut c_void) -> *mut c_void { + FACTORY_ENTRIES.fetch_add(1, Ordering::Relaxed); + FACTORY_LAST_THREAD.store(GetCurrentThreadId() as usize, Ordering::Relaxed); + FACTORY_LAST_THIS.store(this as usize, Ordering::Relaxed); + let address = FACTORY_TRAMPOLINE.load(Ordering::Acquire); + let original: FactoryFn = core::mem::transmute(address); + let result = original(this); + FACTORY_LAST_RESULT.store(result as usize, Ordering::Release); + FACTORY_EXITS.fetch_add(1, Ordering::Release); + result +} + +unsafe extern "system" fn deserializer_wrapper(this: *mut c_void, reader: *mut c_void) -> u8 { + DESERIALIZER_ENTRIES.fetch_add(1, Ordering::Relaxed); + DESERIALIZER_LAST_THREAD.store(GetCurrentThreadId() as usize, Ordering::Relaxed); + DESERIALIZER_LAST_THIS.store(this as usize, Ordering::Relaxed); + DESERIALIZER_LAST_READER.store(reader as usize, Ordering::Relaxed); + let address = DESERIALIZER_TRAMPOLINE.load(Ordering::Acquire); + let original: DeserializerFn = core::mem::transmute(address); + let result = original(this, reader); + DESERIALIZER_LAST_RESULT.store(result != 0, Ordering::Release); + let base = TRACE_BASE.load(Ordering::Acquire); + let a = base + .checked_add(0x2e6398) + .and_then(|slot| guarded_usize(slot)) + .unwrap_or(0); + let m = a + .checked_add(0x20a68) + .and_then(|slot| guarded_usize(slot)) + .unwrap_or(0); + let count = m + .checked_add(0x50) + .and_then(|slot| guarded_u16(slot)) + .map(usize::from) + .unwrap_or(usize::MAX); + let ready = a + .checked_add(0x1f9d8 + 0x28) + .and_then(|slot| guarded_u8(slot)) + .map(usize::from) + .unwrap_or(usize::MAX); + DESERIALIZER_EXIT_M.store(m, Ordering::Relaxed); + DESERIALIZER_EXIT_COUNT.store(count, Ordering::Relaxed); + DESERIALIZER_EXIT_B_READY.store(ready, Ordering::Relaxed); + DESERIALIZER_EXITS.fetch_add(1, Ordering::Release); + result +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum InstallOutcome { + Installed, + PrePatchCleanFailure, + PostPatchCleanFailure, + DegradedHookActive, + DegradedProcessState, + DegradedHookAndProcess, +} + +fn may_free_trampolines(outcome: InstallOutcome) -> bool { + outcome == InstallOutcome::PrePatchCleanFailure +} + +unsafe fn install_pair(base: usize) -> InstallOutcome { + let Some(factory) = target_va(base, CATEGORY_FACTORY_RVA) else { + return InstallOutcome::PrePatchCleanFailure; + }; + let Some(deserializer) = target_va(base, CATEGORY_DESERIALIZER_RVA) else { + return InstallOutcome::PrePatchCleanFailure; + }; + TRACE_BASE.store(base, Ordering::Release); + // Preparation phase: validate the PE/ranges/signatures and allocate executable + // backing before suspending any peer. + if !valid_cards_image(base) + || !executable_range(factory, FACTORY_SIGNATURE.len()) + || !executable_range(deserializer, DESERIALIZER_SIGNATURE.len()) + || !signature_matches(factory, &FACTORY_SIGNATURE) + || !signature_matches(deserializer, &DESERIALIZER_SIGNATURE) + { + return InstallOutcome::PrePatchCleanFailure; + } + // Pin before quiescence so the image cannot unload for the lifetime of any + // published trampoline. This loader operation is intentionally outside the + // suspended phase. + let mut pinned = core::ptr::null_mut(); + if GetModuleHandleExA( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN, + factory as *const u8, + &mut pinned, + ) == 0 + || pinned as usize != base + { + return InstallOutcome::PrePatchCleanFailure; + } + let factory_original: [u8; COPY_LEN] = FACTORY_SIGNATURE[..COPY_LEN].try_into().unwrap(); + let deserializer_original: [u8; COPY_LEN] = + DESERIALIZER_SIGNATURE[..COPY_LEN].try_into().unwrap(); + let Some(factory_trampoline) = allocate_trampoline(factory, COPY_LEN) else { + return InstallOutcome::PrePatchCleanFailure; + }; + let Some(deserializer_trampoline) = allocate_trampoline(deserializer, COPY_LEN) else { + VirtualFree(factory_trampoline as _, 0, MEM_RELEASE); + return InstallOutcome::PrePatchCleanFailure; + }; + FACTORY_TRAMPOLINE.store(factory_trampoline, Ordering::Release); + DESERIALIZER_TRAMPOLINE.store(deserializer_trampoline, Ordering::Release); + + let Some(_installer_gate) = acquire_patch_installer_gate() else { + VirtualFree(factory_trampoline as _, 0, MEM_RELEASE); + VirtualFree(deserializer_trampoline as _, 0, MEM_RELEASE); + FACTORY_TRAMPOLINE.store(0, Ordering::Release); + DESERIALIZER_TRAMPOLINE.store(0, Ordering::Release); + return InstallOutcome::PrePatchCleanFailure; + }; + + // Quiesced phase: fixed-capacity bookkeeping and Win32 code-page operations + // only. No Rust allocation, freeing, formatting, or file logging is permitted. + let mut peers = match suspend_peers(factory, deserializer) { + Ok(peers) => peers, + Err(failure) => { + return if failure == QuiesceFailure::Resume { + InstallOutcome::DegradedProcessState + } else { + VirtualFree(factory_trampoline as _, 0, MEM_RELEASE); + VirtualFree(deserializer_trampoline as _, 0, MEM_RELEASE); + FACTORY_TRAMPOLINE.store(0, Ordering::Release); + DESERIALIZER_TRAMPOLINE.store(0, Ordering::Release); + InstallOutcome::PrePatchCleanFailure + }; + } + }; + + // Final no-allocation TOCTOU gate, immediately before the first write. + // The image was pinned by address before quiescence. Avoid loader APIs here: + // a suspended peer may own the loader lock. Page and byte identity checks are + // sufficient to reject any target change immediately before publication. + let final_valid = valid_cards_image(base) + && executable_range(factory, FACTORY_SIGNATURE.len()) + && executable_range(deserializer, DESERIALIZER_SIGNATURE.len()) + && signature_matches(factory, &FACTORY_SIGNATURE) + && signature_matches(deserializer, &DESERIALIZER_SIGNATURE); + let mut published = false; + let transaction = if !final_valid { + InstallOutcome::PrePatchCleanFailure + } else if let Err(clean) = write_entry( + factory, + factory_wrapper as *const () as usize, + &factory_original, + &mut published, + ) { + if clean { + if published { + InstallOutcome::PostPatchCleanFailure + } else { + InstallOutcome::PrePatchCleanFailure + } + } else { + InstallOutcome::DegradedHookActive + } + } else if let Err(second_clean) = write_entry( + deserializer, + deserializer_wrapper as *const () as usize, + &deserializer_original, + &mut published, + ) { + let first_clean = restore_entry(factory, &factory_original); + if first_clean && second_clean { + InstallOutcome::PostPatchCleanFailure + } else { + InstallOutcome::DegradedHookActive + } + } else { + InstallOutcome::Installed + }; + + let resumed = peers.resume_all(); + let outcome = if resumed { + transaction + } else if transaction == InstallOutcome::DegradedHookActive + || transaction == InstallOutcome::Installed + { + InstallOutcome::DegradedHookAndProcess + } else { + InstallOutcome::DegradedProcessState + }; + // Post-resume phase: freeing is safe only when no hook can reference backing. + if may_free_trampolines(outcome) { + VirtualFree(factory_trampoline as _, 0, MEM_RELEASE); + VirtualFree(deserializer_trampoline as _, 0, MEM_RELEASE); + FACTORY_TRAMPOLINE.store(0, Ordering::Release); + DESERIALIZER_TRAMPOLINE.store(0, Ordering::Release); + } + outcome +} + +unsafe fn worker() { + let _pending = CodeInstallerPending; + let mut base = 0usize; + for _ in 0..600u32 { + base = GetModuleHandleA(b"CardsDLL_Win64_retail.dll\0".as_ptr()) as usize; + if base != 0 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(500)); + } + if base == 0 { + STATE.store(TraceState::Failed as usize, Ordering::Release); + crate::write_log("SBC_TRACE: CardsDLL unavailable; tracing inactive\n"); + return; + } + let outcome = install_pair(base); + drop(_pending); + match outcome { + InstallOutcome::Installed => { + STATE.store(TraceState::Installed as usize, Ordering::Release); + crate::write_log("SBC_TRACE: factory+deserializer passive hooks installed\n"); + } + InstallOutcome::PrePatchCleanFailure => { + STATE.store(TraceState::Failed as usize, Ordering::Release); + crate::write_log("SBC_TRACE: pre-patch clean failure; tracing inactive\n"); + return; + } + InstallOutcome::PostPatchCleanFailure => { + STATE.store(TraceState::Failed as usize, Ordering::Release); + crate::write_log("SBC_TRACE: post-publication rollback completed; trampolines retained for process lifetime; tracing inactive\n"); + return; + } + InstallOutcome::DegradedHookActive => { + STATE.store(TraceState::DegradedHookActive as usize, Ordering::Release); + crate::write_log("SBC_TRACE: DEGRADED HOOK MAY BE ACTIVE; trampolines retained; terminate game now\n"); + return; + } + InstallOutcome::DegradedProcessState => { + STATE.store(TraceState::DegradedProcessState as usize, Ordering::Release); + crate::write_log("SBC_TRACE: DEGRADED THREAD RESUME FAILURE; terminate game now\n"); + return; + } + InstallOutcome::DegradedHookAndProcess => { + STATE.store( + TraceState::DegradedHookAndProcess as usize, + Ordering::Release, + ); + crate::write_log( + "SBC_TRACE: DEGRADED HOOK ACTIVE AND THREAD RESUME FAILURE; terminate game now\n", + ); + return; + } + } + + let mut seen_factory = 0u64; + let mut seen_deserializer = 0u64; + let mut reports = 0u8; + while reports < 32 { + std::thread::sleep(std::time::Duration::from_millis(250)); + let factory_count = FACTORY_ENTRIES.load(Ordering::Acquire); + let deserializer_count = DESERIALIZER_ENTRIES.load(Ordering::Acquire); + if factory_count != seen_factory || deserializer_count != seen_deserializer { + crate::write_log(&format!( + "SBC_TRACE: factory entry={} exit={} tid={} this={:#x} result={:#x}; deser entry={} exit={} tid={} this={:#x} reader={:#x} result={} M={:#x} count={} Bready={}\n", + factory_count, + FACTORY_EXITS.load(Ordering::Acquire), + FACTORY_LAST_THREAD.load(Ordering::Relaxed), + FACTORY_LAST_THIS.load(Ordering::Relaxed), + FACTORY_LAST_RESULT.load(Ordering::Acquire), + deserializer_count, + DESERIALIZER_EXITS.load(Ordering::Acquire), + DESERIALIZER_LAST_THREAD.load(Ordering::Relaxed), + DESERIALIZER_LAST_THIS.load(Ordering::Relaxed), + DESERIALIZER_LAST_READER.load(Ordering::Relaxed), + DESERIALIZER_LAST_RESULT.load(Ordering::Acquire), + DESERIALIZER_EXIT_M.load(Ordering::Relaxed), + DESERIALIZER_EXIT_COUNT.load(Ordering::Relaxed), + DESERIALIZER_EXIT_B_READY.load(Ordering::Relaxed), + )); + seen_factory = factory_count; + seen_deserializer = deserializer_count; + reports += 1; + } + } + crate::write_log("SBC_TRACE: report cap reached; hooks remain passive\n"); +} + +unsafe fn notifier_worker() { + let _pending = CodeInstallerPending; + let mut base = 0usize; + for _ in 0..600u32 { + base = GetModuleHandleA(b"CardsDLL_Win64_retail.dll\0".as_ptr()) as usize; + if base != 0 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(500)); + } + let Some(target) = target_va(base, NOTIFIER_RVA) else { + crate::write_log("SBC_NOTIFIER_TRACE: target resolution failed; inactive\n"); + return; + }; + if base == 0 + || !valid_cards_image(base) + || !executable_range_in_image(base, target, NOTIFIER_SIGNATURE.len()) + || !signature_matches(target, &NOTIFIER_SIGNATURE) + { + crate::write_log("SBC_NOTIFIER_TRACE: PE/signature validation failed; inactive\n"); + return; + } + let mut pinned = core::ptr::null_mut(); + if GetModuleHandleExA( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN, + target as *const u8, + &mut pinned, + ) == 0 + || pinned as usize != base + { + crate::write_log("SBC_NOTIFIER_TRACE: module pin failed; inactive\n"); + return; + } + let Some(trampoline) = allocate_trampoline(target, NOTIFIER_COPY_LEN) else { + crate::write_log("SBC_NOTIFIER_TRACE: trampoline allocation failed; inactive\n"); + return; + }; + NOTIFIER_TRAMPOLINE.store(trampoline, Ordering::Release); + let Some(_installer_gate) = acquire_patch_installer_gate() else { + VirtualFree(trampoline as _, 0, MEM_RELEASE); + NOTIFIER_TRAMPOLINE.store(0, Ordering::Release); + crate::write_log("SBC_NOTIFIER_TRACE: installer gate timeout; inactive\n"); + return; + }; + let mut peers = match suspend_peers(target, target) { + Ok(peers) => peers, + Err(_) => { + // A failed resume can leave peers suspended, so conservatively retain + // executable backing and require termination. + crate::write_log("SBC_NOTIFIER_TRACE: quiescence failed; trampoline retained; terminate game if unresponsive\n"); + return; + } + }; + let final_valid = pinned as usize == base + && valid_cards_image(base) + && executable_range_in_image(base, target, NOTIFIER_SIGNATURE.len()) + && signature_matches(target, &NOTIFIER_SIGNATURE); + let mut published = false; + let installed = final_valid + && write_notifier_entry( + target, + notifier_wrapper as *const () as usize, + &mut published, + ) + .is_ok(); + let resumed = peers.resume_all(); + drop(_installer_gate); + drop(_pending); + if !installed || !resumed { + if !published && resumed { + VirtualFree(trampoline as _, 0, MEM_RELEASE); + NOTIFIER_TRAMPOLINE.store(0, Ordering::Release); + crate::write_log("SBC_NOTIFIER_TRACE: clean install failure; inactive\n"); + } else { + crate::write_log("SBC_NOTIFIER_TRACE: DEGRADED hook/thread state; backing retained; terminate game now\n"); + } + return; + } + crate::write_log("SBC_NOTIFIER_TRACE: category success notifier hook installed\n"); + let mut seen = 0u64; + let mut reports = 0u8; + while reports < 32 { + std::thread::sleep(std::time::Duration::from_millis(250)); + let entries = NOTIFIER_ENTRIES.load(Ordering::Acquire); + if entries != seen { + crate::write_log(&format!( + "SBC_NOTIFIER_TRACE: entry={} exit={} ctx={:#x} byte88={}->{} handlers={:#x}..{:#x} count={}\n", + entries, + NOTIFIER_EXITS.load(Ordering::Acquire), + NOTIFIER_CTX.load(Ordering::Relaxed), + NOTIFIER_BYTE_BEFORE.load(Ordering::Relaxed), + NOTIFIER_BYTE_AFTER.load(Ordering::Relaxed), + NOTIFIER_BEGIN.load(Ordering::Relaxed), + NOTIFIER_END.load(Ordering::Relaxed), + NOTIFIER_COUNT.load(Ordering::Relaxed), + )); + seen = entries; + reports += 1; + } + } + crate::write_log("SBC_NOTIFIER_TRACE: report cap reached; hook remains passive\n"); +} + +fn install_notifier(enabled: bool) { + if enabled { + crate::write_log("SBC_NOTIFIER_TRACE: requested; deferred install starting\n"); + std::thread::spawn(|| unsafe { notifier_worker() }); + } else { + crate::write_log("SBC_NOTIFIER_TRACE: disabled\n"); + } +} + +pub(crate) fn install() { + let enabled = env_enabled(std::env::var("OPENFUT_SBC_TRACE").ok().as_deref()); + let notifier_enabled = env_enabled(std::env::var("OPENFUT_SBC_NOTIFIER_TRACE").ok().as_deref()); + CODE_PATCH_PENDING.store( + enabled as usize + notifier_enabled as usize, + Ordering::Release, + ); + install_notifier(notifier_enabled); + ENABLED.store(enabled, Ordering::Release); + if !enabled { + STATE.store(TraceState::Disabled as usize, Ordering::Release); + crate::write_log("SBC_TRACE: disabled (set OPENFUT_SBC_TRACE=1 to enable)\n"); + return; + } + STATE.store(TraceState::Requested as usize, Ordering::Release); + crate::write_log("SBC_TRACE: requested; deferred signature validation starting\n"); + std::thread::spawn(|| unsafe { worker() }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn trace_gate_is_exact_and_default_off() { + assert!(!env_enabled(None)); + assert!(!env_enabled(Some("0"))); + assert!(!env_enabled(Some("true"))); + assert!(env_enabled(Some("1"))); + } + + #[test] + fn target_resolution_checks_overflow() { + assert_eq!(target_va(0x1000, CATEGORY_FACTORY_RVA), Some(0x17ba10)); + assert_eq!(target_va(usize::MAX, CATEGORY_FACTORY_RVA), None); + } + + #[test] + fn absolute_jump_has_indirect_rip_encoding_and_exact_destination() { + let jump = absolute_jump(0x1234_5678_9abc_def0); + assert_eq!(&jump[..6], &[0xff, 0x25, 0, 0, 0, 0]); + assert_eq!( + u64::from_le_bytes(jump[6..].try_into().unwrap()), + 0x1234_5678_9abc_def0 + ); + } + + #[test] + fn relocation_spans_end_on_proven_instruction_boundaries() { + assert_eq!( + &FACTORY_SIGNATURE[..COPY_LEN], + &[ + 0x48, 0x89, 0x4c, 0x24, 0x08, 0x53, 0x48, 0x83, 0xec, 0x30, 0x48, 0xc7, 0x44, 0x24, + 0x20, 0xfe, 0xff, 0xff, 0xff, + ] + ); + assert_eq!( + &NOTIFIER_SIGNATURE[..NOTIFIER_COPY_LEN], + &[ + 0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x74, 0x24, 0x10, 0x57, 0x48, 0x83, 0xec, + 0x20 + ] + ); + assert_eq!( + &DESERIALIZER_SIGNATURE[..COPY_LEN], + &[ + 0x48, 0x8b, 0xc4, 0x55, 0x41, 0x54, 0x41, 0x55, 0x41, 0x56, 0x41, 0x57, 0x48, 0x8d, + 0xa8, 0xe8, 0xfd, 0xff, 0xff, + ] + ); + } + + #[test] + fn instruction_span_is_half_open_and_overflow_fails_closed() { + assert!(instruction_pointer_in_span(0x1000, 0x1000)); + assert!(instruction_pointer_in_span(0x1012, 0x1000)); + assert!(!instruction_pointer_in_span(0x1013, 0x1000)); + assert!(!instruction_pointer_in_span(0x0fff, 0x1000)); + assert!(instruction_pointer_in_span(usize::MAX, usize::MAX)); + } + + #[test] + fn only_clean_failure_allows_trampoline_free() { + assert!(may_free_trampolines(InstallOutcome::PrePatchCleanFailure)); + assert!(!may_free_trampolines(InstallOutcome::PostPatchCleanFailure)); + assert!(!may_free_trampolines(InstallOutcome::Installed)); + assert!(!may_free_trampolines(InstallOutcome::DegradedHookActive)); + assert!(!may_free_trampolines(InstallOutcome::DegradedProcessState)); + assert!(!may_free_trampolines( + InstallOutcome::DegradedHookAndProcess + )); + } +} diff --git a/openfut-hook/src/version_proxy.rs b/openfut-hook/src/version_proxy.rs new file mode 100644 index 0000000..74c594a --- /dev/null +++ b/openfut-hook/src/version_proxy.rs @@ -0,0 +1,117 @@ +//! Transparent forwarding for the system `version.dll` API. +//! +//! The hook is deployed under the `version.dll` filename, so every VERSION API +//! import must continue to behave exactly as it would without OpenFUT. Resolve +//! the genuine system DLL once during process attach, then tail-jump from each +//! exported stub. A tail jump preserves the caller's complete Windows x64 ABI +//! state, including stack arguments whose signatures differ between exports. + +use std::sync::atomic::{AtomicUsize, Ordering}; + +use windows_sys::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryW}; + +const EXPORT_COUNT: usize = 16; + +/// Keep this list in the same order as the generated stubs below. +const EXPORTS: [&[u8]; EXPORT_COUNT] = [ + b"GetFileVersionInfoA\0", + b"GetFileVersionInfoExA\0", + b"GetFileVersionInfoExW\0", + b"GetFileVersionInfoSizeA\0", + b"GetFileVersionInfoSizeExA\0", + b"GetFileVersionInfoSizeExW\0", + b"GetFileVersionInfoSizeW\0", + b"GetFileVersionInfoW\0", + b"VerFindFileA\0", + b"VerFindFileW\0", + b"VerInstallFileA\0", + b"VerInstallFileW\0", + b"VerLanguageNameA\0", + b"VerLanguageNameW\0", + b"VerQueryValueA\0", + b"VerQueryValueW\0", +]; + +/// Addresses in the genuine system DLL. Atomic storage gives the assembly +/// stubs stable, directly addressable pointer-sized slots without `static mut`. +static REAL: [AtomicUsize; EXPORT_COUNT] = [const { AtomicUsize::new(0) }; EXPORT_COUNT]; + +macro_rules! proxy_stub { + ($index:literal, $name:ident) => { + #[unsafe(no_mangle)] + #[unsafe(naked)] + pub unsafe extern "system" fn $name() { + core::arch::naked_asm!( + "jmp qword ptr [rip + {base} + {offset}]", + base = sym REAL, + offset = const $index * size_of::(), + ); + } + }; +} + +proxy_stub!(0, GetFileVersionInfoA); +proxy_stub!(1, GetFileVersionInfoExA); +proxy_stub!(2, GetFileVersionInfoExW); +proxy_stub!(3, GetFileVersionInfoSizeA); +proxy_stub!(4, GetFileVersionInfoSizeExA); +proxy_stub!(5, GetFileVersionInfoSizeExW); +proxy_stub!(6, GetFileVersionInfoSizeW); +proxy_stub!(7, GetFileVersionInfoW); +proxy_stub!(8, VerFindFileA); +proxy_stub!(9, VerFindFileW); +proxy_stub!(10, VerInstallFileA); +proxy_stub!(11, VerInstallFileW); +proxy_stub!(12, VerLanguageNameA); +proxy_stub!(13, VerLanguageNameW); +proxy_stub!(14, VerQueryValueA); +proxy_stub!(15, VerQueryValueW); + +/// Resolve forwarding targets before returning from `DLL_PROCESS_ATTACH`. +/// Calls into our exports may happen as soon as the loader releases its lock, +/// so deferring this operation to the hook worker would create a race. +pub(crate) unsafe fn resolve() -> bool { + // Loading by absolute path prevents this proxy from recursively loading + // itself. Proton/Wine exposes the Windows system directory at this path. + let path: Vec = "C:\\Windows\\System32\\version.dll\0" + .encode_utf16() + .collect(); + let module = LoadLibraryW(path.as_ptr()); + if module.is_null() { + crate::write_log("version_proxy: FATAL: system version.dll load failed\n"); + return false; + } + + let mut missing = 0; + for (slot, name) in REAL.iter().zip(EXPORTS) { + let address = GetProcAddress(module, name.as_ptr()).map_or(0, |proc| proc as usize); + slot.store(address, Ordering::Release); + if address == 0 { + missing += 1; + } + } + + if missing == 0 { + crate::write_log("version_proxy: forwarded all 16 exports\n"); + true + } else { + crate::write_log(&format!( + "version_proxy: FATAL: {missing}/16 system exports missing\n" + )); + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn export_table_is_complete_and_nul_terminated() { + assert_eq!(EXPORTS.len(), EXPORT_COUNT); + assert!(EXPORTS.iter().all(|name| name.last() == Some(&0))); + assert!(EXPORTS + .iter() + .all(|name| !name[..name.len() - 1].contains(&0))); + } +} diff --git a/openfut-hook/version.def b/openfut-hook/version.def new file mode 100644 index 0000000..5f2ae7f --- /dev/null +++ b/openfut-hook/version.def @@ -0,0 +1,18 @@ +LIBRARY version +EXPORTS + GetFileVersionInfoA + GetFileVersionInfoExA + GetFileVersionInfoExW + GetFileVersionInfoSizeA + GetFileVersionInfoSizeExA + GetFileVersionInfoSizeExW + GetFileVersionInfoSizeW + GetFileVersionInfoW + VerFindFileA + VerFindFileW + VerInstallFileA + VerInstallFileW + VerLanguageNameA + VerLanguageNameW + VerQueryValueA + VerQueryValueW