From 09ed26ba163bdfc28e313c5e5f811b9b1e0f754e Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 7 Aug 2026 12:03:21 -0700 Subject: [PATCH] wip: checkpoint FIFA 17 hook diagnostics --- openfut-hook/src/connect_hook.rs | 138 +++++++----- openfut-hook/src/connectex_hook.rs | 71 +++--- openfut-hook/src/hooks.rs | 20 +- openfut-hook/src/iat.rs | 19 +- openfut-hook/src/origin_spy.rs | 53 +++-- openfut-hook/src/probe.rs | 329 +++++++++++++++++++++------- openfut-hook/src/recv_hook.rs | 148 ++++++++++--- openfut-hook/src/ssl_patch.rs | 44 ++-- openfut-hook/src/tls_bypass.rs | 15 +- openfut-hook/src/transport_watch.rs | 4 +- src/process.rs | 3 +- 11 files changed, 596 insertions(+), 248 deletions(-) diff --git a/openfut-hook/src/connect_hook.rs b/openfut-hook/src/connect_hook.rs index 4dc3856..34639b7 100644 --- a/openfut-hook/src/connect_hook.rs +++ b/openfut-hook/src/connect_hook.rs @@ -5,27 +5,27 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::OnceLock; const AF_INET: u16 = 2; -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. +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) +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 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)] struct SockaddrIn { sin_family: u16, - sin_port: u16, - sin_addr: u32, - sin_zero: [u8; 8], + sin_port: u16, + sin_addr: u32, + sin_zero: [u8; 8], } const AF_INET6: u16 = 23; // Windows AF_INET6 value (we run under the Win ABI in Wine) @@ -34,10 +34,10 @@ const AF_INET6: u16 = 23; // Windows AF_INET6 value (we run under the Win ABI in /// address bytes in network order. 28 bytes total. #[repr(C)] struct SockaddrIn6 { - sin6_family: u16, - sin6_port: u16, + sin6_family: u16, + sin6_port: u16, sin6_flowinfo: u32, - sin6_addr: [u8; 16], + sin6_addr: [u8; 16], sin6_scope_id: u32, } @@ -46,8 +46,7 @@ struct SockaddrIn6 { /// existing IPv4 listener on :8443 — no separate IPv6 listener needed. The game's own /// EA dials already use v4-mapped addresses (`::ffff:x.x.x.x`), so its sockets are not /// `IPV6_V6ONLY` and will accept this target. -const V4MAPPED_LOOPBACK: [u8; 16] = - [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 127, 0, 0, 1]; +const V4MAPPED_LOOPBACK: [u8; 16] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 127, 0, 0, 1]; // Address of ws2_32!connect (set at hook installation) static CONNECT_ADDR: AtomicUsize = AtomicUsize::new(0); @@ -57,18 +56,26 @@ static mut ORIGINAL_BYTES: [u8; 14] = [0u8; 14]; // For WSAConnect IAT fallback type WsaConnectFn = unsafe extern "system" fn( - s: usize, name: *const u8, namelen: i32, - caller: *const (), callee: *const (), - sqos: *const (), gqos: *const ()) -> i32; + s: usize, + name: *const u8, + namelen: i32, + caller: *const (), + callee: *const (), + sqos: *const (), + gqos: *const (), +) -> i32; static REAL_WSA: OnceLock = OnceLock::new(); -pub fn set_real_wsa_connect(f: WsaConnectFn) { let _ = REAL_WSA.set(f); } +pub fn set_real_wsa_connect(f: WsaConnectFn) { + let _ = REAL_WSA.set(f); +} unsafe fn write_hook(target: *mut u8, dest: u64) { use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE}; let mut old: u32 = 0; VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old); // FF 25 00 00 00 00 JMP [rip+0] - target.write(0xFF); target.add(1).write(0x25); + target.write(0xFF); + target.add(1).write(0x25); (target.add(2) as *mut u32).write(0u32); (target.add(6) as *mut u64).write(dest); VirtualProtect(target as _, 14, old, &mut old); @@ -78,7 +85,7 @@ unsafe fn restore_original(target: *mut u8) { use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE}; let mut old: u32 = 0; VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old); - core::ptr::copy_nonoverlapping(ORIGINAL_BYTES.as_ptr(), target, 14); + core::ptr::copy_nonoverlapping(core::ptr::addr_of!(ORIGINAL_BYTES) as *const u8, target, 14); VirtualProtect(target as _, 14, old, &mut old); } @@ -103,26 +110,30 @@ pub(crate) unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u // SAFE: family is AF_INET and namelen >= 8 == the sockaddr_in fields we read. let sa = &*(name as *const SockaddrIn); let new_port_nbo = match sa.sin_port { - PORT_HTTPS_NBO => PORT_BRIDGE_NBO, + PORT_HTTPS_NBO => PORT_BRIDGE_NBO, #[cfg(not(feature = "capture_baseline"))] - PORT_LSX_NBO => PORT_LSX_TARGET_NBO, + 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, + PORT_BLAZE_MAIN_NBO => PORT_BLAZE_MAIN_NBO, + _ => return None, }; // sin_addr is network order; to_le_bytes gives memory order = the dotted // quad, so b[0].b[1].b[2].b[3] is correct (the old code printed it reversed). let o = sa.sin_addr.to_le_bytes(); crate::write_log(&format!( "connect_hook: v4 {}.{}.{}.{}:{} → 127.0.0.1:{}\n", - o[0], o[1], o[2], o[3], u16::from_be(sa.sin_port), + o[0], + o[1], + o[2], + o[3], + u16::from_be(sa.sin_port), u16::from_be(new_port_nbo) )); // SAFE: buf is 28 bytes, larger than the 16-byte sockaddr_in we write. let out = &mut *(buf.as_mut_ptr() as *mut SockaddrIn); out.sin_family = AF_INET; - out.sin_port = new_port_nbo; - out.sin_addr = ADDR_LOOPBACK_NBO; + out.sin_port = new_port_nbo; + out.sin_addr = ADDR_LOOPBACK_NBO; Some((buf, 16)) } AF_INET6 => { @@ -133,23 +144,27 @@ pub(crate) unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u let sa6 = &*(name as *const SockaddrIn6); // LSX is IPv4-only (anadius keys on it), so it is intentionally omitted here. let new_port_nbo = match sa6.sin6_port { - PORT_HTTPS_NBO => PORT_BRIDGE_NBO, + PORT_HTTPS_NBO => PORT_BRIDGE_NBO, PORT_BLAZE_REDIRECTOR_NBO => PORT_BLAZE_REDIRECTOR_NBO, - PORT_BLAZE_MAIN_NBO => PORT_BLAZE_MAIN_NBO, - _ => return None, + PORT_BLAZE_MAIN_NBO => PORT_BLAZE_MAIN_NBO, + _ => return None, }; let a = sa6.sin6_addr; crate::write_log(&format!( "connect_hook: v6 [{:02x}{:02x}:..:{:02x}{:02x}]:{} → ::ffff:127.0.0.1:{}\n", - a[0], a[1], a[14], a[15], u16::from_be(sa6.sin6_port), + a[0], + a[1], + a[14], + a[15], + u16::from_be(sa6.sin6_port), u16::from_be(new_port_nbo) )); // SAFE: buf is exactly 28 bytes == sizeof(sockaddr_in6). let out = &mut *(buf.as_mut_ptr() as *mut SockaddrIn6); - out.sin6_family = AF_INET6; - out.sin6_port = new_port_nbo; + out.sin6_family = AF_INET6; + out.sin6_port = new_port_nbo; out.sin6_flowinfo = 0; - out.sin6_addr = V4MAPPED_LOOPBACK; + out.sin6_addr = V4MAPPED_LOOPBACK; out.sin6_scope_id = 0; Some((buf, 28)) } @@ -174,7 +189,13 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen: use windows_sys::Win32::Networking::WinSock::{getsockopt, SOL_SOCKET, SO_TYPE}; let mut ty: i32 = -1; let mut len: i32 = 4; - getsockopt(s, SOL_SOCKET as i32, SO_TYPE, &mut ty as *mut i32 as *mut u8, &mut len); + getsockopt( + s, + SOL_SOCKET as i32, + SO_TYPE, + &mut ty as *mut i32 as *mut u8, + &mut len, + ); ty }; crate::write_log(&format!( @@ -190,11 +211,11 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen: let (call_name, call_len) = if let Some((buf, len)) = redirect_if_ea(name, namelen) { restore_original(addr); let r = { - let f: unsafe extern "system" fn(usize, *const u8, i32) -> i32 - = core::mem::transmute(addr); + let f: unsafe extern "system" fn(usize, *const u8, i32) -> i32 = + core::mem::transmute(addr); f(s, buf.as_ptr(), len) }; - write_hook(addr, hooked_connect as u64); + write_hook(addr, hooked_connect as *const () as u64); return r; } else { (name, namelen) @@ -202,18 +223,19 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen: restore_original(addr); let r = { - let f: unsafe extern "system" fn(usize, *const u8, i32) -> i32 - = core::mem::transmute(addr); + let f: unsafe extern "system" fn(usize, *const u8, i32) -> i32 = core::mem::transmute(addr); f(s, call_name, call_len) }; - write_hook(addr, hooked_connect as u64); + write_hook(addr, hooked_connect as *const () as u64); if namelen >= 8 { let sa = &*(call_name as *const SockaddrIn); if sa.sin_family == AF_INET { let err = if r != 0 { use windows_sys::Win32::Networking::WinSock::WSAGetLastError; WSAGetLastError() - } else { 0 }; + } else { + 0 + }; crate::write_log(&format!("connect_hook: result={r} wsa_err={err}\n")); } } @@ -221,9 +243,13 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen: } pub unsafe extern "system" fn hooked_wsa_connect( - s: usize, name: *const u8, namelen: i32, - caller: *const (), callee: *const (), - sqos: *const (), gqos: *const (), + s: usize, + name: *const u8, + namelen: i32, + caller: *const (), + callee: *const (), + sqos: *const (), + gqos: *const (), ) -> i32 { // Milestone-0 transport watch (self-gates on OPENFUT_TRANSPORT_WATCH). crate::transport_watch::note_connect("WSAConnect", name, namelen, s); @@ -240,17 +266,23 @@ pub unsafe fn install_inline_connect_hook() -> bool { use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress}; let ws2 = GetModuleHandleA(b"ws2_32.dll\0".as_ptr()); - if ws2.is_null() { return false; } + if ws2.is_null() { + return false; + } let connect_fn = match GetProcAddress(ws2, b"connect\0".as_ptr()) { Some(f) => f as *mut u8, None => return false, }; // Save original 14 bytes - core::ptr::copy_nonoverlapping(connect_fn, ORIGINAL_BYTES.as_mut_ptr(), 14); + core::ptr::copy_nonoverlapping( + connect_fn, + core::ptr::addr_of_mut!(ORIGINAL_BYTES) as *mut u8, + 14, + ); CONNECT_ADDR.store(connect_fn as usize, Ordering::Relaxed); // Overwrite first 14 bytes with absolute indirect JMP to our hook - write_hook(connect_fn, hooked_connect as u64); + write_hook(connect_fn, hooked_connect as *const () as u64); true } diff --git a/openfut-hook/src/connectex_hook.rs b/openfut-hook/src/connectex_hook.rs index 71a0070..fa3d20f 100644 --- a/openfut-hook/src/connectex_hook.rs +++ b/openfut-hook/src/connectex_hook.rs @@ -1,10 +1,10 @@ +use core::ffi::c_void; /// Intercepts ConnectEx (EA/DirtySDK's preferred async connect API). /// /// DirtySDK calls WSAIoctl(SIO_GET_EXTENSION_FUNCTION_POINTER, WSAID_CONNECTEX) once at /// startup to get a ConnectEx function pointer, bypassing all IAT hooks. We hook WSAIoctl /// inline so that when it returns a ConnectEx pointer we swap it for our own wrapper. use core::sync::atomic::{AtomicUsize, Ordering}; -use core::ffi::c_void; // Address rewriting (v4 + v6) is shared from connect_hook::redirect_if_ea, so the port // constants and sockaddr structs no longer live here. @@ -14,9 +14,7 @@ const SIO_GET_EXT_FN: u32 = 0xC8000006; // WSAID_CONNECTEX = {25A207B9-DDF3-4660-8EE9-76E58C74063E} const CONNECTEX_GUID: [u8; 16] = [ - 0xB9, 0x07, 0xA2, 0x25, - 0xF3, 0xDD, 0x60, 0x46, - 0x8E, 0xE9, 0x76, 0xE5, 0x8C, 0x74, 0x06, 0x3E, + 0xB9, 0x07, 0xA2, 0x25, 0xF3, 0xDD, 0x60, 0x46, 0x8E, 0xE9, 0x76, 0xE5, 0x8C, 0x74, 0x06, 0x3E, ]; // The real ConnectEx pointer, saved after WSAIoctl returns it @@ -53,7 +51,8 @@ unsafe fn write_hook(target: *mut u8, dest: u64) { use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE}; let mut old: u32 = 0; VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old); - target.write(0xFF); target.add(1).write(0x25); + target.write(0xFF); + target.add(1).write(0x25); (target.add(2) as *mut u32).write(0u32); (target.add(6) as *mut u64).write(dest); VirtualProtect(target as _, 14, old, &mut old); @@ -63,7 +62,7 @@ unsafe fn restore_wsaioctl(target: *mut u8) { use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE}; let mut old: u32 = 0; VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old); - core::ptr::copy_nonoverlapping(WSAIOCTL_ORIG.as_ptr(), target, 14); + core::ptr::copy_nonoverlapping(core::ptr::addr_of!(WSAIOCTL_ORIG) as *const u8, target, 14); VirtualProtect(target as _, 14, old, &mut old); } @@ -85,9 +84,25 @@ unsafe extern "system" fn hooked_connectex( // Share the one redirect implementation (v4 + v6) with connect_hook, so ConnectEx // dials get the same IPv6 handling as plain connect(). if let Some((buf, len)) = crate::connect_hook::redirect_if_ea(name, namelen) { - return real_fn(s, buf.as_ptr(), len, send_buf, send_data_len, bytes_sent, overlapped); + return real_fn( + s, + buf.as_ptr(), + len, + send_buf, + send_data_len, + bytes_sent, + overlapped, + ); } - real_fn(s, name, namelen, send_buf, send_data_len, bytes_sent, overlapped) + real_fn( + s, + name, + namelen, + send_buf, + send_data_len, + bytes_sent, + overlapped, + ) } /// Our WSAIoctl hook: when ConnectEx is requested, save the real pointer and return ours @@ -108,28 +123,28 @@ pub unsafe extern "system" fn hooked_wsaioctl( restore_wsaioctl(addr); let result = { let f: WsaIoctlFn = core::mem::transmute(addr); - f(s, code, in_buf, in_len, out_buf, out_len, bytes_ret, overlapped, completion) + f( + s, code, in_buf, in_len, out_buf, out_len, bytes_ret, overlapped, completion, + ) }; - write_hook(addr, hooked_wsaioctl as u64); + write_hook(addr, hooked_wsaioctl as *const () as u64); // If this was a ConnectEx request that succeeded, swap the pointer - if result == 0 - && code == SIO_GET_EXT_FN - && in_len == 16 - && !in_buf.is_null() - { + if result == 0 && code == SIO_GET_EXT_FN && in_len == 16 && !in_buf.is_null() { let guid = core::slice::from_raw_parts(in_buf as *const u8, 16); - if guid == CONNECTEX_GUID - && out_len >= 8 - && !out_buf.is_null() - { + if guid == CONNECTEX_GUID && out_len >= 8 && !out_buf.is_null() { let out_ptr = out_buf as *mut usize; let real_addr = *out_ptr; - if REAL_CONNECTEX.compare_exchange(0, real_addr, Ordering::Relaxed, Ordering::Relaxed).is_ok() { - crate::write_log(&format!("connectex_hook: intercepted ConnectEx @ {real_addr:#x}\n")); + if REAL_CONNECTEX + .compare_exchange(0, real_addr, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + crate::write_log(&format!( + "connectex_hook: intercepted ConnectEx @ {real_addr:#x}\n" + )); } // Return our hook instead - *out_ptr = hooked_connectex as usize; + *out_ptr = hooked_connectex as *const () as usize; } } result @@ -138,13 +153,19 @@ pub unsafe extern "system" fn hooked_wsaioctl( pub unsafe fn install_wsaioctl_hook() -> bool { use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress}; let ws2 = GetModuleHandleA(b"ws2_32.dll\0".as_ptr()); - if ws2.is_null() { return false; } + if ws2.is_null() { + return false; + } let fn_ptr = match GetProcAddress(ws2, b"WSAIoctl\0".as_ptr()) { Some(f) => f as *mut u8, None => return false, }; - core::ptr::copy_nonoverlapping(fn_ptr, WSAIOCTL_ORIG.as_mut_ptr(), 14); + core::ptr::copy_nonoverlapping( + fn_ptr, + core::ptr::addr_of_mut!(WSAIOCTL_ORIG) as *mut u8, + 14, + ); WSAIOCTL_ADDR.store(fn_ptr as usize, Ordering::Relaxed); - write_hook(fn_ptr, hooked_wsaioctl as u64); + write_hook(fn_ptr, hooked_wsaioctl as *const () as u64); true } diff --git a/openfut-hook/src/hooks.rs b/openfut-hook/src/hooks.rs index 30842d2..c9c028c 100644 --- a/openfut-hook/src/hooks.rs +++ b/openfut-hook/src/hooks.rs @@ -1,19 +1,15 @@ use std::{ ffi::CStr, sync::{ - OnceLock, atomic::{AtomicBool, Ordering}, + OnceLock, }, }; -use windows_sys::Win32::Networking::WinSock::{ADDRINFOA, getaddrinfo as sys_getaddrinfo}; +use windows_sys::Win32::Networking::WinSock::{getaddrinfo as sys_getaddrinfo, ADDRINFOA}; -type GetaddrinfoFn = unsafe extern "system" fn( - *const u8, - *const u8, - *const ADDRINFOA, - *mut *mut ADDRINFOA, -) -> i32; +type GetaddrinfoFn = + unsafe extern "system" fn(*const u8, *const u8, *const ADDRINFOA, *mut *mut ADDRINFOA) -> i32; static REAL: OnceLock = OnceLock::new(); static REDIRECT_IP: OnceLock> = OnceLock::new(); @@ -62,9 +58,13 @@ pub unsafe extern "system" fn hooked_getaddrinfo( if !CERT_PATCHED.load(Ordering::Relaxed) { if crate::ssl_patch::patch_eawebkit_cert_verify() { CERT_PATCHED.store(true, Ordering::Relaxed); - crate::write_log("openfut_hook: ProtoSSL cert-verify patched (lazy, from getaddrinfo)\n"); + crate::write_log( + "openfut_hook: ProtoSSL cert-verify patched (lazy, from getaddrinfo)\n", + ); } else { - crate::write_log("openfut_hook: ProtoSSL cert-verify patch FAILED in getaddrinfo\n"); + crate::write_log( + "openfut_hook: ProtoSSL cert-verify patch FAILED in getaddrinfo\n", + ); } } diff --git a/openfut-hook/src/iat.rs b/openfut-hook/src/iat.rs index 746f3e6..78f82e3 100644 --- a/openfut-hook/src/iat.rs +++ b/openfut-hook/src/iat.rs @@ -72,7 +72,11 @@ pub unsafe fn patch_iat(original_fn: *const (), hook_fn: *const ()) -> usize { } /// Patch the IAT of a specific already-loaded DLL (e.g. b"EAWebKit.dll\0"). -pub unsafe fn patch_iat_in(module_name: &[u8], original_fn: *const (), hook_fn: *const ()) -> usize { +pub unsafe fn patch_iat_in( + module_name: &[u8], + original_fn: *const (), + hook_fn: *const (), +) -> usize { let module = GetModuleHandleA(module_name.as_ptr()); if module.is_null() { return 0; @@ -80,11 +84,7 @@ pub unsafe fn patch_iat_in(module_name: &[u8], original_fn: *const (), hook_fn: patch_module(module, original_fn, hook_fn) } -unsafe fn patch_module( - module: HMODULE, - original_fn: *const (), - hook_fn: *const (), -) -> usize { +unsafe fn patch_module(module: HMODULE, original_fn: *const (), hook_fn: *const ()) -> usize { if module.is_null() { return 0; } @@ -117,7 +117,12 @@ unsafe fn patch_module( if val == original_fn as usize { let target = iat_slot.add(i) as *const std::ffi::c_void; let mut old: u32 = 0; - VirtualProtect(target, std::mem::size_of::(), PAGE_EXECUTE_READWRITE, &mut old); + VirtualProtect( + target, + std::mem::size_of::(), + PAGE_EXECUTE_READWRITE, + &mut old, + ); *iat_slot.add(i) = hook_fn as usize; VirtualProtect(target, std::mem::size_of::(), old, &mut old); count += 1; diff --git a/openfut-hook/src/origin_spy.rs b/openfut-hook/src/origin_spy.rs index fc0040c..8974945 100644 --- a/openfut-hook/src/origin_spy.rs +++ b/openfut-hook/src/origin_spy.rs @@ -27,28 +27,49 @@ static REAL_REG_W: OnceLock = OnceLock::new(); static REAL_MUTEX_A: OnceLock = OnceLock::new(); static REAL_MUTEX_W: OnceLock = OnceLock::new(); -pub fn set_real_reg_a(f: RegQueryValueExAFn) { let _ = REAL_REG_A.set(f); } -pub fn set_real_reg_w(f: RegQueryValueExWFn) { let _ = REAL_REG_W.set(f); } -pub fn set_real_mutex_a(f: OpenMutexAFn) { let _ = REAL_MUTEX_A.set(f); } -pub fn set_real_mutex_w(f: OpenMutexWFn) { let _ = REAL_MUTEX_W.set(f); } +pub fn set_real_reg_a(f: RegQueryValueExAFn) { + let _ = REAL_REG_A.set(f); +} +pub fn set_real_reg_w(f: RegQueryValueExWFn) { + let _ = REAL_REG_W.set(f); +} +pub fn set_real_mutex_a(f: OpenMutexAFn) { + let _ = REAL_MUTEX_A.set(f); +} +pub fn set_real_mutex_w(f: OpenMutexWFn) { + let _ = REAL_MUTEX_W.set(f); +} fn narrow_to_string(p: *const u8) -> String { - if p.is_null() { return "(null)".into(); } + if p.is_null() { + return "(null)".into(); + } let bytes = unsafe { std::ffi::CStr::from_ptr(p as *const i8) }; bytes.to_string_lossy().into_owned() } fn wide_to_string(p: *const u16) -> String { - if p.is_null() { return "(null)".into(); } + if p.is_null() { + return "(null)".into(); + } let mut len = 0usize; - unsafe { while *p.add(len) != 0 { len += 1; } } + unsafe { + while *p.add(len) != 0 { + len += 1; + } + } String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(p, len) }) } fn is_interesting(name: &str) -> bool { - name.contains("LSX") || name.contains("Origin") || name.contains("EAL") || - name.contains("Client") || name.contains("lsx") || name.contains("Port") || - name.contains("EA") || name.contains("Connection") + name.contains("LSX") + || name.contains("Origin") + || name.contains("EAL") + || name.contains("Client") + || name.contains("lsx") + || name.contains("Port") + || name.contains("EA") + || name.contains("Connection") } pub unsafe extern "system" fn hooked_reg_query_a( @@ -93,8 +114,10 @@ pub unsafe extern "system" fn hooked_open_mutex_a( let name = narrow_to_string(lpmutexname); let real = REAL_MUTEX_A.get().copied().unwrap(); let handle = real(dwdesiredaccess, binherithandle, lpmutexname); - crate::write_log(&format!("origin_spy: OpenMutexA({name}) → {}\n", - if handle == 0 { "NOT_FOUND" } else { "FOUND" })); + crate::write_log(&format!( + "origin_spy: OpenMutexA({name}) → {}\n", + if handle == 0 { "NOT_FOUND" } else { "FOUND" } + )); handle } @@ -106,7 +129,9 @@ pub unsafe extern "system" fn hooked_open_mutex_w( let name = wide_to_string(lpmutexname); let real = REAL_MUTEX_W.get().copied().unwrap(); let handle = real(dwdesiredaccess, binherithandle, lpmutexname); - crate::write_log(&format!("origin_spy: OpenMutexW({name}) → {}\n", - if handle == 0 { "NOT_FOUND" } else { "FOUND" })); + crate::write_log(&format!( + "origin_spy: OpenMutexW({name}) → {}\n", + if handle == 0 { "NOT_FOUND" } else { "FOUND" } + )); handle } diff --git a/openfut-hook/src/probe.rs b/openfut-hook/src/probe.rs index c80b937..49272e8 100644 --- a/openfut-hook/src/probe.rs +++ b/openfut-hook/src/probe.rs @@ -17,11 +17,11 @@ //! r8/r9) and returns in rax. All targets here are SDK methods with few args. use core::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}; use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA; -use windows_sys::Win32::System::Threading::GetCurrentThreadId; use windows_sys::Win32::System::Memory::{ VirtualProtect, VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, MEM_PRIVATE, PAGE_EXECUTE_READWRITE, PAGE_GUARD, PAGE_NOACCESS, }; +use windows_sys::Win32::System::Threading::GetCurrentThreadId; /// Fault-safe pointer read: returns None unless `ptr` lands in a committed, readable /// page (checked via VirtualQuery). Avoids crashing FIFA when we sample pointers that @@ -31,7 +31,11 @@ unsafe fn read_ptr(ptr: usize) -> Option { return None; } let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); - let n = VirtualQuery(ptr as _, &mut mbi, core::mem::size_of::()); + let n = VirtualQuery( + ptr as _, + &mut mbi, + core::mem::size_of::(), + ); if n == 0 || mbi.State != MEM_COMMIT { return None; } @@ -165,14 +169,18 @@ pub unsafe fn install_listener_probe() { // Arm the dial trigger from the env var, ONCE, at install (DLL-load) time. Default // disarmed: OPENFUT_DIAL_TRIGGER must be explicitly "1". Orthogonal to the pump/ctx // env vars. - let armed = std::env::var("OPENFUT_DIAL_TRIGGER").map(|v| v == "1").unwrap_or(false); + let armed = std::env::var("OPENFUT_DIAL_TRIGGER") + .map(|v| v == "1") + .unwrap_or(false); DIAL_ARMED.store(armed, Ordering::Relaxed); crate::write_log(&format!( "DIAL_TRIGGER: {} (env OPENFUT_DIAL_TRIGGER)\n", if armed { "ARMED" } else { "disarmed" } )); // Arm the (independent) connMgr enumeration from its own env var, once, at load. - let enum_armed = std::env::var("OPENFUT_CONNMGR_ENUM").map(|v| v == "1").unwrap_or(false); + let enum_armed = std::env::var("OPENFUT_CONNMGR_ENUM") + .map(|v| v == "1") + .unwrap_or(false); CONNMGR_ENUM_ARMED.store(enum_armed, Ordering::Relaxed); crate::write_log(&format!( "CONNMGR_ENUM: {} (env OPENFUT_CONNMGR_ENUM)\n", @@ -180,16 +188,25 @@ pub unsafe fn install_listener_probe() { )); // Arm the (independent) [element+0x40] container-writer watchpoint from its own // env var, once, at load. Orthogonal to DIAL_TRIGGER / CONNMGR_ENUM. - let elem_watch_armed = std::env::var("OPENFUT_ELEM_WATCH").map(|v| v == "1").unwrap_or(false); + let elem_watch_armed = std::env::var("OPENFUT_ELEM_WATCH") + .map(|v| v == "1") + .unwrap_or(false); ELEM_WATCH_ARMED.store(elem_watch_armed, Ordering::Relaxed); crate::write_log(&format!( "ELEM_WATCH: {} (env OPENFUT_ELEM_WATCH)\n", - if elem_watch_armed { "ARMED" } else { "disarmed" } + if elem_watch_armed { + "ARMED" + } else { + "disarmed" + } )); RESUME_ADDR = (base + 0x274d4e5) as u64; let target = (base + 0x274d4d7) as *mut u8; write_jmp(target, openfut_listener_stub as usize as u64); - crate::write_log(&format!("PROBE listener: dispatch site patched @ {:#x}\n", target as usize)); + crate::write_log(&format!( + "PROBE listener: dispatch site patched @ {:#x}\n", + target as usize + )); } // ─── dial trigger (sub-phase B) ────────────────────────────────────────────────── @@ -262,7 +279,9 @@ fn observe_completion() { let c = crate::dial_notification::completion_stub_call_count(); let last = LAST_COMPLETION_COUNT.swap(c, Ordering::Relaxed); if c != last { - crate::write_log(&format!("DIAL_TRIGGER: completion stub count changed {last} → {c}\n")); + crate::write_log(&format!( + "DIAL_TRIGGER: completion stub count changed {last} → {c}\n" + )); } } @@ -329,11 +348,17 @@ unsafe fn dial_trigger_tick() { // Step 5 — resolve connMgr (reuse the ctx-dump scan + tiebreaker). let Some(g) = read_ptr(base + 0xacd02c0).filter(|&x| x != 0) else { - log_skip(3, "DIAL_TRIGGER: connMgr resolution failed (G null) — skipping\n"); + log_skip( + 3, + "DIAL_TRIGGER: connMgr resolution failed (G null) — skipping\n", + ); return; }; let Some(m) = read_ptr(g + 0x360).filter(|&x| x != 0) else { - log_skip(3, "DIAL_TRIGGER: connMgr resolution failed (M null) — skipping\n"); + log_skip( + 3, + "DIAL_TRIGGER: connMgr resolution failed (M null) — skipping\n", + ); return; }; let expected_vtable = base + 0x80200b8; @@ -367,7 +392,9 @@ unsafe fn dial_trigger_tick() { if ctx == 0 || !(0x140000000..0x161000000).contains(&ctx_vt) { log_skip( 4, - &format!("DIAL_TRIGGER: ctx pointer implausible (ctx={ctx:#x} vt={ctx_vt:#x}) — skipping\n"), + &format!( + "DIAL_TRIGGER: ctx pointer implausible (ctx={ctx:#x} vt={ctx_vt:#x}) — skipping\n" + ), ); return; } @@ -557,7 +584,11 @@ unsafe fn connmgr_enum_tick() { crate::write_log(&format!( "CONNMGR_ENUM: [{i}] P={p:#x} vt={vtable:#x} vt[0]={vtable0:#x} ({}) \ [+0x18]={} [+0x20]={} [+0x30]={} [+0xc38]={}\n", - if info.vtable0_in_text { "in .text" } else { "NOT .text" }, + if info.vtable0_in_text { + "in .text" + } else { + "NOT .text" + }, h(info.field_18), h(info.field_20), h(info.field_30), @@ -658,7 +689,13 @@ unsafe fn read_u32(addr: usize) -> Option { fn fourcc4(v: u32) -> String { let b = v.to_le_bytes(); b.iter() - .map(|&c| if (0x20..0x7f).contains(&c) { c as char } else { '.' }) + .map(|&c| { + if (0x20..0x7f).contains(&c) { + c as char + } else { + '.' + } + }) .collect() } @@ -668,7 +705,10 @@ fn fourcc4(v: u32) -> String { /// (Refactoring `hex_dump` to take a prefix would touch the stable ctx-dump/enum probes /// for no real gain; a ~10-line duplicate is the lower-risk choice.) fn elem_hex_dump(label: &str, start_va: usize, data: &[u8]) { - let mut out = format!("ELEM_WATCH {label} @{start_va:#x} ({} bytes):\n", data.len()); + let mut out = format!( + "ELEM_WATCH {label} @{start_va:#x} ({} bytes):\n", + data.len() + ); for (row, chunk) in data.chunks(16).enumerate() { let mut hex = String::new(); let mut ascii = String::new(); @@ -677,7 +717,11 @@ fn elem_hex_dump(label: &str, start_va: usize, data: &[u8]) { if i == 7 { hex.push(' '); } - ascii.push(if (0x20..0x7f).contains(&b) { b as char } else { '.' }); + ascii.push(if (0x20..0x7f).contains(&b) { + b as char + } else { + '.' + }); } out.push_str(&format!(" +{:#05x} {:<50}|{ascii}|\n", row * 16, hex)); } @@ -752,7 +796,11 @@ unsafe fn elem_watch_tick() { // Step 6 — walk connMgr -> M -> ctx. (M here is re-read from [connMgr+8]; it should // equal the global M we scanned with.) let cm_m = read_ptr(conn_mgr + 8).unwrap_or(0); - let ctx = if cm_m != 0 { read_ptr(cm_m + 0x778).unwrap_or(0) } else { 0 }; + let ctx = if cm_m != 0 { + read_ptr(cm_m + 0x778).unwrap_or(0) + } else { + 0 + }; if cm_m == 0 || ctx == 0 { crate::write_log(&format!( "ELEM_WATCH: chain broke (connMgr={conn_mgr:#x} M={cm_m:#x} ctx={ctx:#x}) — watchpoint not armed\n" @@ -768,11 +816,22 @@ unsafe fn elem_watch_tick() { // target_index = [[M+0x7b0]+0x650] (u32; the dial read this with `mov edx,...`) let array_base = read_ptr(ctx + 0x1a8).unwrap_or(0); let sub_object = read_ptr(ctx + 0x20).unwrap_or(0); - let count = if sub_object != 0 { read_u32(sub_object + 0x51c) } else { None }; + let count = if sub_object != 0 { + read_u32(sub_object + 0x51c) + } else { + None + }; let m7b0 = read_ptr(cm_m + 0x7b0).unwrap_or(0); - let target_index = if m7b0 != 0 { read_u32(m7b0 + 0x650) } else { None }; + let target_index = if m7b0 != 0 { + read_u32(m7b0 + 0x650) + } else { + None + }; - let fmt_u = |o: Option| o.map(|v| v.to_string()).unwrap_or_else(|| "".to_string()); + let fmt_u = |o: Option| { + o.map(|v| v.to_string()) + .unwrap_or_else(|| "".to_string()) + }; crate::write_log(&format!( "ELEM_WATCH: SNAPSHOT ctx={ctx:#x} array_base={array_base:#x} sub_object={sub_object:#x} \ count={} [M+0x7b0]={m7b0:#x} target_index={}\n", @@ -786,7 +845,9 @@ unsafe fn elem_watch_tick() { return; }; if array_base == 0 { - crate::write_log("ELEM_WATCH: array_base null — element array not allocated; watchpoint not armed\n"); + crate::write_log( + "ELEM_WATCH: array_base null — element array not allocated; watchpoint not armed\n", + ); return; } if idx >= count { @@ -811,13 +872,19 @@ unsafe fn elem_watch_tick() { let interp = match begin { None => "unreadable", Some(0) => "container null-init (default-constructed empty vector — begin==end==0)", - Some(v) if v < 0x10000 => "container UNINITIALIZED (small non-pointer sentinel — this is the crash shape)", - Some(v) if read_bytes(v, 8).is_some() => "container appears INITIALIZED (begin is a readable heap pointer)", + Some(v) if v < 0x10000 => { + "container UNINITIALIZED (small non-pointer sentinel — this is the crash shape)" + } + Some(v) if read_bytes(v, 8).is_some() => { + "container appears INITIALIZED (begin is a readable heap pointer)" + } Some(_) => "container has a non-null but UNREADABLE begin (dangling / mid-construction?)", }; crate::write_log(&format!( "ELEM_WATCH: [elem+0x40]={} [elem+0x48]={end:#x} => {interp}\n", - begin.map(|v| format!("{v:#x}")).unwrap_or_else(|| "".to_string()), + begin + .map(|v| format!("{v:#x}")) + .unwrap_or_else(|| "".to_string()), )); // Step 10 — arm Phase 2: seed the baseline and spawn the poller. We watch regardless @@ -872,7 +939,9 @@ fn spawn_elem_watcher(base: usize, elem: usize) { elem_hex_dump("element (after change)", elem, &bytes); } } else if changes == 6 { - crate::write_log("ELEM_WATCH: (further changes suppressed; still tracking baseline)\n"); + crate::write_log( + "ELEM_WATCH: (further changes suppressed; still tracking baseline)\n", + ); } // Beyond 6, keep updating the baseline silently so distinct future changes // are still detected — we just stop spamming the log. @@ -890,11 +959,12 @@ fn spawn_elem_watcher(base: usize, elem: usize) { pub fn install_force_connect() { std::thread::spawn(|| unsafe { let base = GetModuleHandleA(core::ptr::null()); - if base.is_null() { return; } + if base.is_null() { + return; + } let base = base as usize; let x_slot = base + 0xacd02c0; - let rest: extern "system" fn() -> usize = - core::mem::transmute(base + 0x2861910); + let rest: extern "system" fn() -> usize = core::mem::transmute(base + 0x2861910); // Wait for the ctx chain to be valid (FIFA past bootstrap / online), up to ~5 min. let mut fired = 0; for i in 0..600u32 { @@ -905,17 +975,23 @@ pub fn install_force_connect() { .filter(|&m| m != 0) .and_then(|m| read_ptr(m + 0x778)) .filter(|&c| c != 0); - let Some(ctx) = ctx else { continue; }; + let Some(ctx) = ctx else { + continue; + }; // Give the game ~15s settled (ctx valid) before poking, then re-fire a few // times spaced out (the FUT-tick pump needs a moment to reach state 2). - if i < 30 { continue; } + if i < 30 { + continue; + } crate::write_log(&format!( "FORCE: calling nucleusConnectREST() (ctx={ctx:#x}) attempt {fired}\n" )); let r = rest(); crate::write_log(&format!("FORCE: nucleusConnectREST returned {r:#x}\n")); fired += 1; - if fired >= 6 { break; } + if fired >= 6 { + break; + } std::thread::sleep(std::time::Duration::from_millis(5000)); } crate::write_log("FORCE: done\n"); @@ -965,23 +1041,33 @@ pub fn install_force_netconn_pump() { // (an env var is fixed for the process lifetime). Unset or "0" => short-circuit: // log and return, so the pump is wired in but completely inert — a safe default // that can be flipped without a rebuild. - let enabled = std::env::var("OPENFUT_NETCONN_PUMP").map(|v| v == "1").unwrap_or(false); + let enabled = std::env::var("OPENFUT_NETCONN_PUMP") + .map(|v| v == "1") + .unwrap_or(false); if !enabled { crate::write_log("NETCONN_PUMP: disabled (set OPENFUT_NETCONN_PUMP=1 to enable)\n"); return; } let base = GetModuleHandleA(core::ptr::null()); - if base.is_null() { return; } + if base.is_null() { + return; + } let base = base as usize; let netconn_slot = base + 0x9fe5e50; // VA 0x149fe5e50 -> NetConn global (X) - // NetConnIdle core pump; takes no args (reads globals, sets its own rcx). Win64. + // NetConnIdle core pump; takes no args (reads globals, sets its own rcx). Win64. let pump: extern "system" fn() = core::mem::transmute(base + 0xf16a50); // Render a 4-char status code the way DirtySDK stores it (e.g. 0x2b6f6e6c="+onl"). let fourcc = |v: u32| -> String { [(v >> 24) as u8, (v >> 16) as u8, (v >> 8) as u8, v as u8] .iter() - .map(|&b| if (0x20..0x7f).contains(&b) { b as char } else { '.' }) + .map(|&b| { + if (0x20..0x7f).contains(&b) { + b as char + } else { + '.' + } + }) .collect() }; @@ -998,7 +1084,9 @@ pub fn install_force_netconn_pump() { let mut samples = 0u32; for _ in 0..18000u32 { std::thread::sleep(std::time::Duration::from_millis(100)); - let Some(nc) = read_ptr(netconn_slot).filter(|&x| x != 0) else { continue; }; + let Some(nc) = read_ptr(netconn_slot).filter(|&x| x != 0) else { + continue; + }; // Read the conn status dword at [nc+0x48] (8-aligned; read_ptr is guarded). let status = read_ptr(nc + 0x48).map(|w| w as u32).unwrap_or(0); // Drive the idle loop. Self-guards on 'open'; a no-op if not yet open. @@ -1067,7 +1155,9 @@ pub fn install_force_netconn_pump() { pub fn install_force_fut_tick() { std::thread::spawn(|| unsafe { let base = GetModuleHandleA(core::ptr::null()); - if base.is_null() { return; } + if base.is_null() { + return; + } let base = base as usize; let mgr_slot = base + 0xa199608; // VA 0x14a199608 -> FUT online manager ptr let tick: extern "system" fn(usize, usize) -> usize = @@ -1077,9 +1167,13 @@ pub fn install_force_fut_tick() { // ~15 min at 250ms. The tick is heavy (locks + sub-updates); don't spin at 100ms. for _ in 0..3600u32 { std::thread::sleep(std::time::Duration::from_millis(250)); - let Some(mgr) = read_ptr(mgr_slot).filter(|&m| m != 0) else { continue; }; + let Some(mgr) = read_ptr(mgr_slot).filter(|&m| m != 0) else { + continue; + }; // state @+0x1bb8 (low32) + latch byte @+0x1bbc share one 8-aligned qword. - let Some(w) = read_ptr(mgr + 0x1bb8) else { continue; }; + let Some(w) = read_ptr(mgr + 0x1bb8) else { + continue; + }; let state = w as u32; let latch = ((w >> 32) & 0xff) as u8; // Post the go-online request ONLY at state 0 (mimics event-0 delivery) to @@ -1146,7 +1240,11 @@ unsafe fn read_bytes(addr: usize, len: usize) -> Option> { return None; } let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); - let n = VirtualQuery(addr as _, &mut mbi, core::mem::size_of::()); + let n = VirtualQuery( + addr as _, + &mut mbi, + core::mem::size_of::(), + ); if n == 0 || mbi.State != MEM_COMMIT { return None; } @@ -1180,7 +1278,11 @@ fn hex_dump(label: &str, start_va: usize, data: &[u8]) { } // Printable ASCII stays; everything else shows as '.' so pointer bytes // don't corrupt the log line. - ascii.push(if (0x20..0x7f).contains(&b) { b as char } else { '.' }); + ascii.push(if (0x20..0x7f).contains(&b) { + b as char + } else { + '.' + }); } out.push_str(&format!(" +{:#05x} {:<50}|{ascii}|\n", row * 16, hex)); } @@ -1203,16 +1305,16 @@ fn hex_dump(label: &str, start_va: usize, data: &[u8]) { /// /// Returns every matching `P`. Read-only throughout. Also fills `stats` with /// (regions_scanned, bytes_scanned) so we can report the cost. -unsafe fn scan_conn_mgr( - m: usize, - expected_vtable: usize, - stats: &mut (u64, u64), -) -> Vec { +unsafe fn scan_conn_mgr(m: usize, expected_vtable: usize, stats: &mut (u64, u64)) -> Vec { let mut hits = Vec::new(); let mut addr: usize = 0x10000; // user space starts here; skip the null-guard page loop { let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); - let n = VirtualQuery(addr as _, &mut mbi, core::mem::size_of::()); + let n = VirtualQuery( + addr as _, + &mut mbi, + core::mem::size_of::(), + ); if n == 0 { break; // past the top of the user address space } @@ -1262,7 +1364,9 @@ unsafe fn scan_conn_mgr( /// env `OPENFUT_CTX_DUMP` — armed only when it equals "1" (unset/"0" = disabled). /// Read once at install time; if disarmed we don't even spawn the thread. pub fn install_ctx_dump() { - let armed = std::env::var("OPENFUT_CTX_DUMP").map(|v| v == "1").unwrap_or(false); + let armed = std::env::var("OPENFUT_CTX_DUMP") + .map(|v| v == "1") + .unwrap_or(false); if !armed { crate::write_log("CTXDUMP: disabled (set OPENFUT_CTX_DUMP=1 to arm)\n"); return; @@ -1436,14 +1540,54 @@ const TARGETS: &[Target] = &[ // Run 4: settle "connect state entered-but-stalled" vs "never entered". If the ctor // fires but nothing else, the connect states are created at init but never used; if // GetByIdx / any vtable step fires, the online subsystem is iterating them. - Target { module: b"\0", rva: 0x5078d20, label: "connectState.ctor", main_exe: true }, - Target { module: b"\0", rva: 0x4f46570, label: "ctrl.GetConnState", main_exe: true }, - Target { module: b"\0", rva: 0x507cd60, label: "connState.m_a8", main_exe: true }, - Target { module: b"\0", rva: 0x507cf90, label: "connState.m_b0", main_exe: true }, - Target { module: b"\0", rva: 0x507d660, label: "connState.tick_b8", main_exe: true }, - Target { module: b"\0", rva: 0x507d760, label: "connState.m_c0", main_exe: true }, - Target { module: b"\0", rva: 0x2861910, label: "nucleusConnectREST", main_exe: true }, - Target { module: b"\0", rva: 0x278a4d0, label: "OnlineStatus.deser", main_exe: true }, + Target { + module: b"\0", + rva: 0x5078d20, + label: "connectState.ctor", + main_exe: true, + }, + Target { + module: b"\0", + rva: 0x4f46570, + label: "ctrl.GetConnState", + main_exe: true, + }, + Target { + module: b"\0", + rva: 0x507cd60, + label: "connState.m_a8", + main_exe: true, + }, + Target { + module: b"\0", + rva: 0x507cf90, + label: "connState.m_b0", + main_exe: true, + }, + Target { + module: b"\0", + rva: 0x507d660, + label: "connState.tick_b8", + main_exe: true, + }, + Target { + module: b"\0", + rva: 0x507d760, + label: "connState.m_c0", + main_exe: true, + }, + Target { + module: b"\0", + rva: 0x2861910, + label: "nucleusConnectREST", + main_exe: true, + }, + Target { + module: b"\0", + rva: 0x278a4d0, + label: "OnlineStatus.deser", + main_exe: true, + }, ]; const N: usize = 8; // must equal TARGETS.len() @@ -1498,19 +1642,37 @@ unsafe fn generic(slot: usize, a: usize, b: usize, c: usize, d: usize) -> usize if log { crate::write_log(&format!("PROBE {label} #{n} ret={r:#x}\n")); } else if n == LOG_CAP { - crate::write_log(&format!("PROBE {label} (capped; still firing past {LOG_CAP})\n")); + crate::write_log(&format!( + "PROBE {label} (capped; still firing past {LOG_CAP})\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) } -unsafe extern "system" fn p4(a: usize, b: usize, c: usize, d: usize) -> usize { generic(4, a, b, c, d) } -unsafe extern "system" fn p5(a: usize, b: usize, c: usize, d: usize) -> usize { generic(5, a, b, c, d) } -unsafe extern "system" fn p6(a: usize, b: usize, c: usize, d: usize) -> usize { generic(6, a, b, c, d) } -unsafe extern "system" fn p7(a: usize, b: usize, c: usize, d: usize) -> usize { generic(7, a, b, c, d) } +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) +} +unsafe extern "system" fn p4(a: usize, b: usize, c: usize, d: usize) -> usize { + generic(4, a, b, c, d) +} +unsafe extern "system" fn p5(a: usize, b: usize, c: usize, d: usize) -> usize { + generic(5, a, b, c, d) +} +unsafe extern "system" fn p6(a: usize, b: usize, c: usize, d: usize) -> usize { + generic(6, a, b, c, d) +} +unsafe extern "system" fn p7(a: usize, b: usize, c: usize, d: usize) -> usize { + generic(7, 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 @@ -1526,23 +1688,23 @@ pub fn install_probes_deferred() { install_probes(); install_listener_probe(); install_state_sampler(); - install_ctx_dump(); // ENABLED 2026-07-03: READ-ONLY menu-time ctx dump (env - // OPENFUT_CTX_DUMP=1). Resolves connMgr and hex-dumps M + ctx. No game calls. - // install_force_connect(); // DISABLED 2026-07-03: re-enabling it CRASHED FIFA at - // ~15s (EXCEPTION_ACCESS_VIOLATION, RIP 0x15d5e8dd7 in FIFA's packed/anti-tamper - // region, all registers garbage). Once state 2 makes the Nucleus ctx live, - // nucleusConnectREST's `ctx->vtable[0x40]` send path runs into protected code that - // does not tolerate being called from our background thread. GetAuthCode must be - // triggered on the GAME thread (via a detour), not a bg-thread forcing call. - install_force_netconn_pump(); // RE-ENABLED 2026-07-03 (sub-phase B prereq): pump - // NetConn toward '+onl' and capture the pump thread id. Gated by env - // OPENFUT_NETCONN_PUMP=1 — completely inert unless set. This is the known-good - // pump path (never crashed); the FUT-tick pump below stays OFF (it crashes the VM). - // install_force_fut_tick(); // DISABLED 2026-07-03 for the ctx-dump build: it - // WRITES the go-online latch and drives FifaOnline toward state 2, which - // deterministically CRASHES the anti-tamper VM before the menu — so leaving it on - // would prevent this menu-time probe from ever observing. Re-enable only if we - // deliberately want the (crash-prone) state-2 path. + install_ctx_dump(); // ENABLED 2026-07-03: READ-ONLY menu-time ctx dump (env + // OPENFUT_CTX_DUMP=1). Resolves connMgr and hex-dumps M + ctx. No game calls. + // install_force_connect(); // DISABLED 2026-07-03: re-enabling it CRASHED FIFA at + // ~15s (EXCEPTION_ACCESS_VIOLATION, RIP 0x15d5e8dd7 in FIFA's packed/anti-tamper + // region, all registers garbage). Once state 2 makes the Nucleus ctx live, + // nucleusConnectREST's `ctx->vtable[0x40]` send path runs into protected code that + // does not tolerate being called from our background thread. GetAuthCode must be + // triggered on the GAME thread (via a detour), not a bg-thread forcing call. + install_force_netconn_pump(); // RE-ENABLED 2026-07-03 (sub-phase B prereq): pump + // NetConn toward '+onl' and capture the pump thread id. Gated by env + // OPENFUT_NETCONN_PUMP=1 — completely inert unless set. This is the known-good + // pump path (never crashed); the FUT-tick pump below stays OFF (it crashes the VM). + // install_force_fut_tick(); // DISABLED 2026-07-03 for the ctx-dump build: it + // WRITES the go-online latch and drives FifaOnline toward state 2, which + // deterministically CRASHES the anti-tamper VM before the menu — so leaving it on + // would prevent this menu-time probe from ever observing. Re-enable only if we + // deliberately want the (crash-prone) state-2 path. }); } @@ -1563,7 +1725,10 @@ pub unsafe fn install_probes() { 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)); + 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 6c9015c..5edd78b 100644 --- a/openfut-hook/src/recv_hook.rs +++ b/openfut-hook/src/recv_hook.rs @@ -12,7 +12,8 @@ unsafe fn write_jmp(target: *mut u8, dest: u64) { use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE}; let mut old: u32 = 0; VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old); - target.write(0xFF); target.add(1).write(0x25); + target.write(0xFF); + target.add(1).write(0x25); (target.add(2) as *mut u32).write(0); (target.add(6) as *mut u64).write(dest); VirtualProtect(target as _, 14, old, &mut old); @@ -43,10 +44,15 @@ unsafe fn make_trampoline(orig: *mut u8, name: &str) -> Option { } let mem = VirtualAlloc( - core::ptr::null_mut(), 64, - MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE, + 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; } + 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, copy_len); @@ -56,7 +62,9 @@ unsafe fn make_trampoline(orig: *mut u8, name: &str) -> Option { 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")); + crate::write_log(&format!( + "recv_hook: {name} trampoline copy_len={copy_len}\n" + )); Some(t as usize) } @@ -67,8 +75,12 @@ fn has_rip_relative_branch(bytes: &[u8]) -> bool { let mut pos = 0; while pos < bytes.len() { let (len, branch) = decode_instr_len(&bytes[pos..]); - if branch { return true; } - if len == 0 { break; } // unknown/truncated — stop safely + if branch { + return true; + } + if len == 0 { + break; + } // unknown/truncated — stop safely pos += len; } false @@ -78,9 +90,29 @@ fn modrm_extra(modrm: u8) -> usize { let md = (modrm >> 6) & 3; let rm = modrm & 7; match md { - 0 => if rm == 5 { 4 } else if rm == 4 { 1 } else { 0 }, - 1 => if rm == 4 { 2 } else { 1 }, - 2 => if rm == 4 { 5 } else { 4 }, + 0 => { + if rm == 5 { + 4 + } else if rm == 4 { + 1 + } else { + 0 + } + } + 1 => { + if rm == 4 { + 2 + } else { + 1 + } + } + 2 => { + if rm == 4 { + 5 + } else { + 4 + } + } _ => 0, } } @@ -88,16 +120,31 @@ fn modrm_extra(modrm: u8) -> usize { /// Returns (instruction_length_in_bytes, is_rip_relative_branch). /// Returns (0, false) for unknown/truncated. fn decode_instr_len(b: &[u8]) -> (usize, bool) { - if b.is_empty() { return (0, false); } + if b.is_empty() { + return (0, false); + } let mut i = 0; // Legacy prefixes while let Some(&p) = b.get(i) { - if matches!(p, 0x66 | 0x67 | 0xF0 | 0xF2 | 0xF3) { i += 1; } else { break; } + if matches!(p, 0x66 | 0x67 | 0xF0 | 0xF2 | 0xF3) { + i += 1; + } else { + break; + } } // REX prefix (40–4F) - if b.get(i).copied().map(|x| (0x40..=0x4F).contains(&x)).unwrap_or(false) { i += 1; } + if b.get(i) + .copied() + .map(|x| (0x40..=0x4F).contains(&x)) + .unwrap_or(false) + { + i += 1; + } - let op = match b.get(i) { Some(&x) => x, None => return (0, false) }; + let op = match b.get(i) { + Some(&x) => x, + None => return (0, false), + }; i += 1; match op { @@ -112,28 +159,45 @@ fn decode_instr_len(b: &[u8]) -> (usize, bool) { 0xE9 | 0xE8 => (i + 4, true), // 0F prefix 0x0F => { - let op2 = match b.get(i) { Some(&x) => x, None => return (0, false) }; + let op2 = match b.get(i) { + Some(&x) => x, + None => return (0, false), + }; i += 1; - if (0x80..=0x8F).contains(&op2) { return (i + 4, true); } // Jcc rel32 - // Most 0F XX: ModRM - let modrm = match b.get(i) { Some(&x) => x, None => return (0, false) }; + if (0x80..=0x8F).contains(&op2) { + return (i + 4, true); + } // Jcc rel32 + // Most 0F XX: ModRM + let modrm = match b.get(i) { + Some(&x) => x, + None => return (0, false), + }; (i + 1 + modrm_extra(modrm), false) } // Instructions with ModRM only (no immediate) - 0x85 | 0x87 | 0x88 | 0x89 | 0x8A | 0x8B | 0x8C | 0x8D | 0x8E | 0x8F | - 0x01 | 0x03 | 0x09 | 0x0B | 0x11 | 0x13 | 0x21 | 0x23 | 0x29 | 0x2B | - 0x31 | 0x33 | 0x39 | 0x3B | 0xD3 | 0xFF | 0xF7 => { - let modrm = match b.get(i) { Some(&x) => x, None => return (0, false) }; + 0x85 | 0x87 | 0x88 | 0x89 | 0x8A | 0x8B | 0x8C | 0x8D | 0x8E | 0x8F | 0x01 | 0x03 + | 0x09 | 0x0B | 0x11 | 0x13 | 0x21 | 0x23 | 0x29 | 0x2B | 0x31 | 0x33 | 0x39 | 0x3B + | 0xD3 | 0xFF | 0xF7 => { + let modrm = match b.get(i) { + Some(&x) => x, + None => return (0, false), + }; (i + 1 + modrm_extra(modrm), false) } // ModRM + imm8 0x6B | 0x80 | 0x83 | 0xC0 | 0xC1 | 0xC6 => { - let modrm = match b.get(i) { Some(&x) => x, None => return (0, false) }; + let modrm = match b.get(i) { + Some(&x) => x, + None => return (0, false), + }; (i + 1 + modrm_extra(modrm) + 1, false) } // ModRM + imm32 0x69 | 0x81 | 0xC7 => { - let modrm = match b.get(i) { Some(&x) => x, None => return (0, false) }; + let modrm = match b.get(i) { + Some(&x) => x, + None => return (0, false), + }; (i + 1 + modrm_extra(modrm) + 4, false) } // MOV reg, imm8/imm32 @@ -152,7 +216,9 @@ fn decode_instr_len(b: &[u8]) -> (usize, bool) { unsafe fn get_fn(dll: &[u8], sym: &[u8]) -> Option<*mut u8> { use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress}; let h = GetModuleHandleA(dll.as_ptr()); - if h.is_null() { return None; } + if h.is_null() { + return None; + } GetProcAddress(h, sym.as_ptr()).map(|f| f as *mut u8) } @@ -166,7 +232,9 @@ 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; } + 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 } @@ -190,7 +258,10 @@ pub fn set_real_send(f: unsafe extern "system" fn(usize, *const u8, i32, i32) -> /// 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 }; + 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) => REAL_RECV.store(t, Ordering::Relaxed), None => return false, @@ -200,7 +271,10 @@ pub unsafe fn install_recv_hook() -> bool { } 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 }; + 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) => REAL_SEND.store(t, Ordering::Relaxed), None => return false, @@ -211,7 +285,9 @@ pub unsafe fn install_send_hook() -> bool { 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; } + 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). @@ -219,7 +295,10 @@ pub unsafe extern "system" fn hooked_recv(s: usize, buf: *mut u8, len: i32, flag 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)])); + crate::write_log(&format!( + "CAP recv<-anadius s={s} n={n}: {}\n", + &text[..text.len().min(2400)] + )); } n } @@ -228,10 +307,15 @@ pub unsafe extern "system" fn hooked_send(s: usize, buf: *const u8, len: i32, fl 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)])); + 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; } + 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) } diff --git a/openfut-hook/src/ssl_patch.rs b/openfut-hook/src/ssl_patch.rs index 4923076..59c3e02 100644 --- a/openfut-hook/src/ssl_patch.rs +++ b/openfut-hook/src/ssl_patch.rs @@ -9,11 +9,9 @@ // server's certificate chain. Always returning 1 is equivalent to trusting all certs, // which is the behaviour we want for the local self-signed bridge certificate. -use windows_sys::Win32::{ - System::{ - LibraryLoader::GetModuleHandleA, - Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE}, - }, +use windows_sys::Win32::System::{ + LibraryLoader::GetModuleHandleA, + Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE}, }; // Unique 22-byte prologue of ProtoSSL's cert-verify function. @@ -21,24 +19,26 @@ use windows_sys::Win32::{ const PROLOGUE: &[u8] = &[ 0x44, 0x89, 0x44, 0x24, 0x18, // mov [rsp+0x18], r8d 0x48, 0x89, 0x54, 0x24, 0x10, // mov [rsp+0x10], rdx - 0x56, // push rsi - 0x57, // push rdi - 0x41, 0x55, // push r13 - 0x41, 0x56, // push r14 - 0x41, 0x57, // push r15 - 0x48, 0x83, 0xec, 0x30, // sub rsp, 0x30 + 0x56, // push rsi + 0x57, // push rdi + 0x41, 0x55, // push r13 + 0x41, 0x56, // push r14 + 0x41, 0x57, // push r15 + 0x48, 0x83, 0xec, 0x30, // sub rsp, 0x30 ]; // Return 0 (PROTOSSL_ERROR_NONE = success). ProtoSSL convention: 0 = ok, negative = error. // The function sets r15d = 0xFFFFFFFF (-1) for its own error returns, confirming 0 = success. const PATCH: &[u8] = &[ - 0x31, 0xc0, // xor eax, eax (eax = 0 = PROTOSSL_ERROR_NONE) - 0xc3, // ret - 0x90, 0x90, 0x90, // nop padding + 0x31, 0xc0, // xor eax, eax (eax = 0 = PROTOSSL_ERROR_NONE) + 0xc3, // ret + 0x90, 0x90, 0x90, // nop padding ]; fn patch_module(module: isize, scan_bytes: usize) -> bool { - if module == 0 { return false; } + if module == 0 { + return false; + } let base = module as usize; let image: &[u8] = unsafe { core::slice::from_raw_parts(base as *const u8, scan_bytes) }; let offset = match image.windows(PROLOGUE.len()).position(|w| w == PROLOGUE) { @@ -48,9 +48,19 @@ fn patch_module(module: isize, scan_bytes: usize) -> bool { let target = (base + offset) as *mut u8; let mut old_prot: u32 = 0; unsafe { - VirtualProtect(target as *const core::ffi::c_void, PATCH.len(), PAGE_EXECUTE_READWRITE, &mut old_prot); + VirtualProtect( + target as *const core::ffi::c_void, + PATCH.len(), + PAGE_EXECUTE_READWRITE, + &mut old_prot, + ); core::ptr::copy_nonoverlapping(PATCH.as_ptr(), target, PATCH.len()); - VirtualProtect(target as *const core::ffi::c_void, PATCH.len(), old_prot, &mut old_prot); + VirtualProtect( + target as *const core::ffi::c_void, + PATCH.len(), + old_prot, + &mut old_prot, + ); } true } diff --git a/openfut-hook/src/tls_bypass.rs b/openfut-hook/src/tls_bypass.rs index fdca730..8e53f78 100644 --- a/openfut-hook/src/tls_bypass.rs +++ b/openfut-hook/src/tls_bypass.rs @@ -4,10 +4,10 @@ use windows_sys::Win32::Foundation::BOOL; // CERT_CHAIN_POLICY_STATUS.dwError offset 0 = u32 error code; 0 = success. // We use raw pointers to avoid pulling in the full Cryptography struct tree. type CertVerifyChainPolicyFn = unsafe extern "system" fn( - *const u8, // pszPolicyOID - *const (), // pChainContext - *const (), // pPolicyPara - *mut u32, // &mut pPolicyStatus.dwError (first field) + *const u8, // pszPolicyOID + *const (), // pChainContext + *const (), // pPolicyPara + *mut u32, // &mut pPolicyStatus.dwError (first field) ) -> BOOL; static REAL: OnceLock = OnceLock::new(); @@ -25,7 +25,12 @@ pub unsafe extern "system" fn hooked_cert_verify_chain_policy( p_policy_status: *mut u32, ) -> BOOL { if let Some(real) = REAL.get().copied() { - real(psz_policy_oid, p_chain_context, p_policy_para, p_policy_status); + real( + psz_policy_oid, + p_chain_context, + p_policy_para, + p_policy_status, + ); } // Clear the error field of CERT_CHAIN_POLICY_STATUS regardless if !p_policy_status.is_null() { diff --git a/openfut-hook/src/transport_watch.rs b/openfut-hook/src/transport_watch.rs index cfd573f..a7cf2e9 100644 --- a/openfut-hook/src/transport_watch.rs +++ b/openfut-hook/src/transport_watch.rs @@ -83,7 +83,9 @@ pub fn note_getaddrinfo(host: &str) { } else { "" }; - crate::write_log(&format!("TRANSPORT_WATCH: getaddrinfo host=\"{host}\"{tag}\n")); + crate::write_log(&format!( + "TRANSPORT_WATCH: getaddrinfo host=\"{host}\"{tag}\n" + )); } const AF_INET: u16 = 2; // IPv4 diff --git a/src/process.rs b/src/process.rs index aef6869..d519e98 100644 --- a/src/process.rs +++ b/src/process.rs @@ -66,9 +66,8 @@ impl ServiceHandle { } cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); - let mut child = cmd.spawn().map_err(|e| { + let mut child = cmd.spawn().inspect_err(|e| { *self.status.lock().unwrap() = ServiceStatus::Failed(e.to_string()); - e })?; // Drain stdout