Files
openfut-launcher/openfut-hook/src/probe.rs
T
funman300 3c3dc32fc6 probe: add behavior-preserving mid-function detour to capture the live OnlineStatusEvent listener
The OnlineStatusEventT::HandleMessage dispatch resolves its game-side
listener only at runtime (call [rax+0x28]). openfut_listener_stub patches
FIFA23.exe+0x274d4d7 to replicate the four dispatch instructions while
logging the resolved vtable/fn, then resumes. Alignment-safe (saves/rounds
rsp before the log call). Result: listener = FIFA23.exe+0x2751060 = ret 0,
a no-op default vtable slot -> the online->auth transition is state-polled,
not callback-driven.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 17:25:33 -07:00

206 lines
9.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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::{AtomicBool, AtomicUsize, Ordering};
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE};
// ─── live game-side listener capture ────────────────────────────────────────────
//
// OnlineStatusEventT::HandleMessage dispatches the parsed bool to the game's online
// listener via a virtual call `call [rax+0x28]` at FIFA23.exe+0x274d4e2, where rax
// is the vtable of the object at [rsi-0x38]. The concrete listener is only known at
// runtime. We capture it with a behavior-preserving mid-function detour: patch the
// 14 bytes at +0x274d4d7 (which are exactly `lea rcx,[rsi-0x38]; mov rax,[rcx]; lea
// rdx,[rbp-0x49]; call [rax+0x28]`) to jump to a stub that replicates those four
// instructions but logs the resolved listener address in between, then resumes at
// +0x274d4e5. Non-volatile regs (rsi/rbp/…) are preserved by the ABI; volatiles
// match the original dispatch's clobbers.
/// Runtime absolute address to resume at after the replicated dispatch
/// (main-exe base + 0x274d4e5). Read by the asm stub.
#[no_mangle]
static mut RESUME_ADDR: u64 = 0;
static MAIN_BASE: AtomicUsize = AtomicUsize::new(0);
static LISTENER_LOGGED: AtomicBool = AtomicBool::new(false);
/// Called by the stub with the listener object's vtable and the resolved listener
/// function pointer (vtable[0x28]). Logs once (RVAs for static RE).
unsafe extern "C" fn listener_log(vtable: usize, func: usize) {
if LISTENER_LOGGED.swap(true, Ordering::Relaxed) {
return;
}
let base = MAIN_BASE.load(Ordering::Relaxed);
crate::write_log(&format!(
"PROBE OnlineStatus.listener: vtable={vtable:#x} (rva {:#x}) fn={func:#x} (rva {:#x})\n",
vtable.wrapping_sub(base),
func.wrapping_sub(base),
));
}
core::arch::global_asm!(
".intel_syntax noprefix",
".global openfut_listener_stub",
"openfut_listener_stub:",
"lea rcx, [rsi - 0x38]",
"mov rax, [rcx]", // rax = listener vtable
"mov rdx, [rax + 0x28]", // rdx = listener fn (arg2)
"mov rcx, rax", // rcx = vtable (arg1)
// Align the stack to 16 before the call, saving the original rsp so we can
// restore it (0x28 misaligns; SSE code in the logger then faults).
"mov r11, rsp",
"and rsp, -16",
"sub rsp, 0x30", // 0x20 shadow + 0x10 spare, stays 16-aligned
"mov [rsp + 0x20], r11", // stash original rsp
"call {log}",
"mov rsp, [rsp + 0x20]", // restore original rsp
"lea rcx, [rsi - 0x38]", // replicate the original dispatch
"mov rax, [rcx]",
"lea rdx, [rbp - 0x49]",
"call qword ptr [rax + 0x28]",
"mov r10, qword ptr [rip + {resume}]",
"jmp r10",
".att_syntax prefix",
log = sym listener_log,
resume = sym RESUME_ADDR,
);
extern "C" {
fn openfut_listener_stub();
}
/// Patch the OnlineStatusEvent dispatch site to route through the logging stub.
pub unsafe fn install_listener_probe() {
let base = GetModuleHandleA(core::ptr::null());
if base.is_null() {
crate::write_log("PROBE listener: main exe not found\n");
return;
}
let base = base as usize;
MAIN_BASE.store(base, Ordering::Relaxed);
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));
}
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_listener_probe();
});
}
/// 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));
}
}