3c3dc32fc6
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>
206 lines
9.1 KiB
Rust
206 lines
9.1 KiB
Rust
//! 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));
|
||
}
|
||
}
|