From feaff0443fdac053ea7967f515b529d0d60b97b7 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 2 Jul 2026 16:59:27 -0700 Subject: [PATCH] hook: in-process RE instrumentation + LSX redirect + capture tooling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hook-side tooling for the LSX/Blaze reverse-engineering effort: - probe.rs (new, `probe` feature): passive logging detours on FIFA's online-flow functions via the unhook/rehook pattern (no trampoline/relocation, works on RIP-relative prologues). Deferred install waits for anadius64.dll to load, then logs enter/return for GoOnline + GetInternetConnectedState (anadius) and the OnlineStatusEvent/Login deserializers (FIFA23.exe). Revealed that our pushed LSX events reach FIFA and parse OK, while GoOnline never fires — localizing the online gate to FIFA's game-side event consumer. - connect_hook.rs: redirect FIFA's LSX connect :3216 → :3217 so it lands on the native openfut-bridge LSX server (slips past anadius's in-process :3216 intercept); gated off under the `capture_baseline` feature. - recv_hook.rs: boundary-safe trampolines + LSX peer filtering for the capture_baseline path (log anadius's real LSX frames when the redirect is off). Build the instrumented DLL with `--features probe` (or `--features capture_baseline` for the anadius-baseline capture). Both features are off by default. Co-Authored-By: Claude Fable 5 --- openfut-hook/Cargo.toml | 10 +++ openfut-hook/src/connect_hook.rs | 11 +++ openfut-hook/src/lib.rs | 21 ++++++ openfut-hook/src/probe.rs | 125 +++++++++++++++++++++++++++++++ openfut-hook/src/recv_hook.rs | 125 ++++++++++++++++++++----------- 5 files changed, 250 insertions(+), 42 deletions(-) create mode 100644 openfut-hook/src/probe.rs diff --git a/openfut-hook/Cargo.toml b/openfut-hook/Cargo.toml index fa73019..02812d9 100644 --- a/openfut-hook/Cargo.toml +++ b/openfut-hook/Cargo.toml @@ -6,6 +6,16 @@ edition = "2021" [lib] crate-type = ["cdylib"] +[features] +# Build with `--features capture_baseline` to DISABLE the LSX 3216→3217 redirect, +# so FIFA's LSX goes to anadius's in-process server (for capturing anadius's real +# responses). Default build keeps the redirect (LSX → our bridge). +capture_baseline = [] +# Build with `--features probe` to install passive logging detours on FIFA's +# in-process online-flow functions (GoOnline, GetInternetConnectedState, event +# deserializers). Writes PROBE lines to C:\openfut_hook.log for RE. See probe.rs. +probe = [] + [dependencies] windows-sys = { version = "0.59", features = [ "Win32_Foundation", diff --git a/openfut-hook/src/connect_hook.rs b/openfut-hook/src/connect_hook.rs index 35086c7..65ef5e0 100644 --- a/openfut-hook/src/connect_hook.rs +++ b/openfut-hook/src/connect_hook.rs @@ -9,6 +9,15 @@ const PORT_HTTPS_NBO: u16 = 0xBB01; // 443 big-endian const PORT_BRIDGE_NBO: u16 = 0xFB20; // 8443 big-endian const PORT_BLAZE_REDIRECTOR_NBO: u16 = 0x3927; // 10041 big-endian const PORT_BLAZE_MAIN_NBO: u16 = 0x8FA4; // 42127 big-endian +// EA App LSX. anadius handles :3216 in-process before it reaches the host TCP +// stack (keyed on port 3216 specifically), so redirecting FIFA's LSX connect to a +// *different* host port (:3217) slips past that interception and lands on the +// native openfut-bridge LSX server. This is the load-bearing redirect that routes +// LSX to our bridge; without it FIFA uses anadius's in-process emu instead. +#[allow(dead_code)] // unused when built with the `capture_baseline` feature +const PORT_LSX_NBO: u16 = 0x900C; // 3216 big-endian (EA App LSX) +#[allow(dead_code)] +const PORT_LSX_TARGET_NBO: u16 = 0x910C; // 3217 big-endian (bridge LSX target) const ADDR_LOOPBACK_NBO: u32 = 0x0100_007F; // 127.0.0.1 big-endian #[repr(C)] @@ -62,6 +71,8 @@ unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u8; 16], i32 let new_port_nbo = match sa.sin_port { PORT_HTTPS_NBO => PORT_BRIDGE_NBO, + #[cfg(not(feature = "capture_baseline"))] + PORT_LSX_NBO => PORT_LSX_TARGET_NBO, PORT_BLAZE_REDIRECTOR_NBO => PORT_BLAZE_REDIRECTOR_NBO, PORT_BLAZE_MAIN_NBO => PORT_BLAZE_MAIN_NBO, _ => return None, diff --git a/openfut-hook/src/lib.rs b/openfut-hook/src/lib.rs index 54fe334..1195a17 100644 --- a/openfut-hook/src/lib.rs +++ b/openfut-hook/src/lib.rs @@ -4,6 +4,10 @@ mod connectex_hook; mod hooks; mod iat; mod origin_spy; +#[cfg(feature = "probe")] +mod probe; +#[cfg(feature = "capture_baseline")] +mod recv_hook; mod ssl_patch; mod tls_bypass; @@ -61,8 +65,25 @@ unsafe fn install_hooks(module: HMODULE) { 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"); } + // 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. + // + // Except in the `capture_baseline` build: with the LSX redirect off, FIFA talks + // to anadius directly, and these hooks log anadius's real LSX request/response + // 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"); } + } macro_rules! hook_iat { ($dll:expr, $sym:expr, $setter:ident, $handler:expr, $ty:ty) => {{ diff --git a/openfut-hook/src/probe.rs b/openfut-hook/src/probe.rs new file mode 100644 index 0000000..210add5 --- /dev/null +++ b/openfut-hook/src/probe.rs @@ -0,0 +1,125 @@ +//! In-process RE probes: passive logging detours on FIFA's online-flow functions. +//! +//! Purpose: FIFA reacts to our pushed LSX events (OnlineStatusEvent/Login) but +//! never starts the GetAuthCode→Nucleus→Blaze chain, and that decision lives in +//! FIFA's game-side / in-process EbisuSDK logic that is invisible from the LSX +//! wire. These probes log when the key online-flow functions are called (and +//! their return values), so we can SEE where FIFA stalls after our events. +//! +//! Mechanism: the same unhook/rehook detour `connect_hook` uses — on entry we +//! restore the original bytes, log, call the real function, then re-install the +//! jump. This needs no trampoline/relocation, so it works even on prologues with +//! RIP-relative operands (e.g. GoOnline). Not thread-safe (a concurrent call +//! during the unhook window runs un-logged) but never corrupts the target — fine +//! for read-only RE. +//! +//! Signature assumption: each probed fn takes ≤4 integer args (Win64: rcx/rdx/ +//! r8/r9) and returns in rax. All targets here are SDK methods with few args. +use core::sync::atomic::{AtomicUsize, Ordering}; +use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA; +use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE}; + +struct Target { + /// DLL name (nul-terminated) or ignored when `main_exe` is true. + module: &'static [u8], + /// Offset from the module base (RVA). For main_exe, VA − 0x140000000. + rva: usize, + label: &'static str, + /// True → resolve against the main executable (FIFA23.exe) base. + main_exe: bool, +} + +/// Probe targets. Addresses recovered by RE (see docs/connection-gate-findings.md): +/// - anadius64.dll GetInternetConnectedState / GoOnline — FIFA's in-process online +/// decision points (anadius emulates EbisuSDK in-process). +/// - FIFA23.exe OnlineStatusEvent/Login deserializers — confirm our pushed events +/// actually reach FIFA's parser, and with what timing. +const TARGETS: &[Target] = &[ + Target { module: b"anadius64.dll\0", rva: 0x27790, label: "GetInternetConnectedState", main_exe: false }, + Target { module: b"anadius64.dll\0", rva: 0x2bb90, label: "GoOnline", main_exe: false }, + Target { module: b"\0", rva: 0x278a4d0, label: "OnlineStatus.deser", main_exe: true }, + Target { module: b"\0", rva: 0x2787a30, label: "Login.deser", main_exe: true }, +]; + +const N: usize = 4; // must equal TARGETS.len() + +static ADDRS: [AtomicUsize; N] = [const { AtomicUsize::new(0) }; N]; +static mut ORIG: [[u8; 14]; N] = [[0u8; 14]; N]; + +type ProbeFn = unsafe extern "system" fn(usize, usize, usize, usize) -> usize; +const PROBE_FNS: [ProbeFn; N] = [p0, p1, p2, p3]; + +unsafe fn write_jmp(addr: *mut u8, dest: u64) { + let mut old: u32 = 0; + VirtualProtect(addr as _, 14, PAGE_EXECUTE_READWRITE, &mut old); + // FF 25 00 00 00 00 JMP [rip+0] ; then absolute dest + addr.write(0xFF); + addr.add(1).write(0x25); + (addr.add(2) as *mut u32).write(0); + (addr.add(6) as *mut u64).write(dest); + VirtualProtect(addr as _, 14, old, &mut old); +} + +unsafe fn restore(slot: usize) { + let addr = ADDRS[slot].load(Ordering::Relaxed) as *mut u8; + let mut old: u32 = 0; + VirtualProtect(addr as _, 14, PAGE_EXECUTE_READWRITE, &mut old); + core::ptr::copy_nonoverlapping((&raw const ORIG[slot]) as *const u8, addr, 14); + VirtualProtect(addr as _, 14, old, &mut old); +} + +unsafe fn generic(slot: usize, a: usize, b: usize, c: usize, d: usize) -> usize { + let label = TARGETS[slot].label; + let addr = ADDRS[slot].load(Ordering::Relaxed) as *mut u8; + // Unhook, log entry, call the real function, re-hook, log return. + restore(slot); + crate::write_log(&format!( + "PROBE {label} enter rcx={a:#x} rdx={b:#x} r8={c:#x} r9={d:#x}\n" + )); + let f: ProbeFn = core::mem::transmute(addr); + let r = f(a, b, c, d); + write_jmp(addr, PROBE_FNS[slot] as u64); + crate::write_log(&format!("PROBE {label} ret={r:#x}\n")); + r +} + +unsafe extern "system" fn p0(a: usize, b: usize, c: usize, d: usize) -> usize { generic(0, a, b, c, d) } +unsafe extern "system" fn p1(a: usize, b: usize, c: usize, d: usize) -> usize { generic(1, a, b, c, d) } +unsafe extern "system" fn p2(a: usize, b: usize, c: usize, d: usize) -> usize { generic(2, a, b, c, d) } +unsafe extern "system" fn p3(a: usize, b: usize, c: usize, d: usize) -> usize { generic(3, a, b, c, d) } + +/// Spawn a background thread that waits for anadius64.dll to load, then installs +/// all probes. anadius may not be present when our DllMain runs, so we defer +/// off the loader lock and poll for it (up to ~30s) before installing. +pub fn install_probes_deferred() { + std::thread::spawn(|| unsafe { + for _ in 0..60 { + if !GetModuleHandleA(b"anadius64.dll\0".as_ptr()).is_null() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(500)); + } + install_probes(); + }); +} + +/// Install all probe detours. Modules must already be loaded (call late in +/// DllMain, after anadius64.dll is present). +pub unsafe fn install_probes() { + for (i, t) in TARGETS.iter().enumerate() { + let base = if t.main_exe { + GetModuleHandleA(core::ptr::null()) + } else { + GetModuleHandleA(t.module.as_ptr()) + }; + if base.is_null() { + crate::write_log(&format!("PROBE {}: module not loaded, skipped\n", t.label)); + continue; + } + let addr = (base as usize + t.rva) as *mut u8; + core::ptr::copy_nonoverlapping(addr, (&raw mut ORIG[i]) as *mut u8, 14); + ADDRS[i].store(addr as usize, Ordering::Relaxed); + write_jmp(addr, PROBE_FNS[i] as u64); + crate::write_log(&format!("PROBE {} installed @ {:#x}\n", t.label, addr as usize)); + } +} diff --git a/openfut-hook/src/recv_hook.rs b/openfut-hook/src/recv_hook.rs index 32b97e4..6c9015c 100644 --- a/openfut-hook/src/recv_hook.rs +++ b/openfut-hook/src/recv_hook.rs @@ -22,32 +22,41 @@ unsafe fn make_trampoline(orig: *mut u8, name: &str) -> Option { use windows_sys::Win32::System::Memory::{ VirtualAlloc, MEM_COMMIT, MEM_RESERVE, PAGE_EXECUTE_READWRITE, }; - // Log prologue so we can diagnose if trampolines misbehave - let bytes: [u8; 14] = core::array::from_fn(|i| *orig.add(i)); - let hex: String = bytes.iter().map(|b| format!("{b:02x} ")).collect(); + // Read enough prologue to walk instruction boundaries. + let probe: [u8; 24] = core::array::from_fn(|i| *orig.add(i)); + let hex: String = probe[..14].iter().map(|b| format!("{b:02x} ")).collect(); crate::write_log(&format!("recv_hook: {name} prologue {hex}\n")); - // Walk instruction boundaries to find relative branches. - // Byte-by-byte scanning mis-identifies immediate operands (e.g. `sub rsp, 0x70`) - // as jump opcodes, so we must parse properly. - if has_rip_relative_branch(&bytes) { - crate::write_log(&format!("recv_hook: {name} has relative branch in prologue, skipping trampoline\n")); - return None; + // Copy WHOLE instructions until we've covered >= 14 bytes (the size of the JMP + // patch), so the trampoline never splits an instruction. Copying a fixed 14 + // bytes lands mid-instruction on these prologues and crashes on execution. + let mut copy_len = 0usize; + while copy_len < 14 { + let (len, branch) = decode_instr_len(&probe[copy_len..]); + if len == 0 || branch { + crate::write_log(&format!( + "recv_hook: {name} unrelocatable prologue (len={len} branch={branch}), skipping\n" + )); + return None; + } + copy_len += len; } let mem = VirtualAlloc( - core::ptr::null_mut(), 32, + core::ptr::null_mut(), 64, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE, ); if mem.is_null() { crate::write_log("recv_hook: VirtualAlloc failed\n"); return None; } let t = mem as *mut u8; - core::ptr::copy_nonoverlapping(orig, t, 14); - // JMP [RIP+0] → orig+14 - let cont = (orig as u64) + 14; - t.add(14).write(0xFF); t.add(15).write(0x25); - (t.add(16) as *mut u32).write(0); - (t.add(20) as *mut u64).write(cont); + core::ptr::copy_nonoverlapping(orig, t, copy_len); + // JMP [RIP+0] → orig+copy_len (resume at the next whole instruction) + let cont = (orig as u64) + copy_len as u64; + t.add(copy_len).write(0xFF); + t.add(copy_len + 1).write(0x25); + (t.add(copy_len + 2) as *mut u32).write(0); + (t.add(copy_len + 6) as *mut u64).write(cont); + crate::write_log(&format!("recv_hook: {name} trampoline copy_len={copy_len}\n")); Some(t as usize) } @@ -151,46 +160,78 @@ unsafe fn get_fn(dll: &[u8], sym: &[u8]) -> Option<*mut u8> { static RECV_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0); -pub unsafe extern "system" fn hooked_recv(s: usize, buf: *mut u8, len: i32, flags: i32) -> i32 { - if crate::lsx::is_lsx(s) { - return crate::lsx::on_recv(s, buf, len); - } - let t = RECV_TRAMPOLINE.load(Ordering::Relaxed); - if t == 0 { return -1; } - let f: unsafe extern "system" fn(usize, *mut u8, i32, i32) -> i32 = core::mem::transmute(t); - f(s, buf, len, flags) +/// True if socket `s` is connected to the EA App LSX port (127.0.0.1:3216). +/// Used in capture mode to tap only the LSX conversation. +unsafe fn peer_is_lsx(s: usize) -> bool { + use windows_sys::Win32::Networking::WinSock::getpeername; + let mut sa = [0u8; 16]; + let mut sl: i32 = 16; + if getpeername(s, sa.as_mut_ptr() as *mut _, &mut sl) != 0 { return false; } + // sockaddr_in: sa_family (2 bytes) then sin_port (2 bytes, network order). + u16::from_be_bytes([sa[2], sa[3]]) == 3216 } +// IAT-hook approach (no inline trampoline — FIFA's `recv`/`send` prologues have +// instructions that straddle the 14-byte patch boundary, so an inline trampoline +// corrupts them and crashes. IAT hooking only swaps import-table pointers and +// never touches the function body). The real fns are resolved in lib.rs and set +// here; our hooks call them directly. +static REAL_RECV: AtomicUsize = AtomicUsize::new(0); +static REAL_SEND: AtomicUsize = AtomicUsize::new(0); + +pub fn set_real_recv(f: unsafe extern "system" fn(usize, *mut u8, i32, i32) -> i32) { + REAL_RECV.store(f as usize, Ordering::Relaxed); +} +pub fn set_real_send(f: unsafe extern "system" fn(usize, *const u8, i32, i32) -> i32) { + REAL_SEND.store(f as usize, Ordering::Relaxed); +} + +/// Inline-hook ws2_32!recv: build a boundary-safe trampoline (the "real" fn our +/// hook calls) and overwrite the entry with a JMP to `hooked_recv`. Inline hooks +/// catch calls from every module and dynamically-resolved calls, unlike IAT. pub unsafe fn install_recv_hook() -> bool { let ptr = match get_fn(b"ws2_32.dll\0", b"recv\0") { Some(p) => p, None => return false }; match make_trampoline(ptr, "recv") { - Some(t) => { RECV_TRAMPOLINE.store(t, Ordering::Relaxed); } - None => { crate::write_log("recv_hook: recv trampoline failed, hook skipped\n"); return false; } + Some(t) => REAL_RECV.store(t, Ordering::Relaxed), + None => return false, } write_jmp(ptr, hooked_recv as u64); true } -// ─── send ────────────────────────────────────────────────────────────────────── - -static SEND_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0); - -pub unsafe extern "system" fn hooked_send(s: usize, buf: *const u8, len: i32, flags: i32) -> i32 { - if crate::lsx::is_lsx(s) { - return crate::lsx::on_send(s, buf, len); - } - let t = SEND_TRAMPOLINE.load(Ordering::Relaxed); - if t == 0 { return -1; } - let f: unsafe extern "system" fn(usize, *const u8, i32, i32) -> i32 = core::mem::transmute(t); - f(s, buf, len, flags) -} - pub unsafe fn install_send_hook() -> bool { let ptr = match get_fn(b"ws2_32.dll\0", b"send\0") { Some(p) => p, None => return false }; match make_trampoline(ptr, "send") { - Some(t) => { SEND_TRAMPOLINE.store(t, Ordering::Relaxed); } - None => { crate::write_log("recv_hook: send trampoline failed, hook skipped\n"); return false; } + Some(t) => REAL_SEND.store(t, Ordering::Relaxed), + None => return false, } write_jmp(ptr, hooked_send as u64); true } + +pub unsafe extern "system" fn hooked_recv(s: usize, buf: *mut u8, len: i32, flags: i32) -> i32 { + let t = REAL_RECV.load(Ordering::Relaxed); + if t == 0 { return -1; } + let f: unsafe extern "system" fn(usize, *mut u8, i32, i32) -> i32 = core::mem::transmute(t); + // Pass through to anadius's real socket, then log what it sent back + // (anadius's LSX response — the ground truth we want to diff against). + let n = f(s, buf, len, flags); + if n > 0 && peer_is_lsx(s) { + let data = core::slice::from_raw_parts(buf, n as usize); + let text = core::str::from_utf8(data).unwrap_or("(binary)"); + crate::write_log(&format!("CAP recv<-anadius s={s} n={n}: {}\n", &text[..text.len().min(2400)])); + } + n +} + +pub unsafe extern "system" fn hooked_send(s: usize, buf: *const u8, len: i32, flags: i32) -> i32 { + if len > 0 && peer_is_lsx(s) { + let data = core::slice::from_raw_parts(buf, len as usize); + let text = core::str::from_utf8(data).unwrap_or("(binary)"); + crate::write_log(&format!("CAP send->anadius s={s} len={len}: {}\n", &text[..text.len().min(2400)])); + } + let t = REAL_SEND.load(Ordering::Relaxed); + if t == 0 { return -1; } + let f: unsafe extern "system" fn(usize, *const u8, i32, i32) -> i32 = core::mem::transmute(t); + f(s, buf, len, flags) +}