hook: in-process RE instrumentation + LSX redirect + capture tooling
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {{
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -22,32 +22,41 @@ unsafe fn make_trampoline(orig: *mut u8, name: &str) -> Option<usize> {
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user