0138a39f3e
Implement the LSX (EA App bootstrap, TCP :3216→:3217) server in the bridge
and clear the loading-screen crash gate. FIFA 23 now completes the full LSX
handshake + session against OpenFUT and advances to the online/Blaze phase
("<persona> is connecting to the EA Servers…") — verified live, not a cached
session (the displayed persona "fun" comes from our GetProfile).
Root cause of the invariant loading-screen crash: get_config() omitted the
PROGRESSIVE_INSTALLATION and PROGRESSIVE_INSTALLATION_EVENT services (Name="PI").
FIFA builds its service registry from GetConfig and resolves that handle during
online-init; the missing service left a null handle it dereferenced at
FIFA23.exe+0x133dfc5 (mov rax,[r13+0x18], r13=NULL) ~2s after LSX — identical
across every prior run. Found via FIFA's own CrashReport_*.json (stable RIP) +
diffing anadius64.dll's service-registration table against ours.
Also in this change:
- process_frame(): handle FIFA's pipelined (batched) LSX messages per read,
eliminating the ~15s handshake hang.
- Real anadius values (RE'd from anadius64.dll + anadius.cfg): PersonaId/UserId/
persona, locale list, GetGameInfo fixed `GameInfo="<value>"` attribute,
GetAllGameInfo 14-attr schema, GetSetting `Setting=` attribute.
- IsProgressiveInstallationAvailable served from sender="PI".
- docs/connection-gate-findings.md: full RE trail; relocate stale openfut-hook
copy under docs/ (canonical hook lives in openfut-launcher).
Next gate: Blaze/online (M2), ridden over ProtoSSL in-process.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
921 lines
32 KiB
Rust
921 lines
32 KiB
Rust
//! openfut-hook — FIFA 23 `version.dll` proxy + ProtoSSL plaintext capture.
|
|
//!
|
|
//! This DLL is built as `version.dll` and dropped next to `FIFA23.exe`. The
|
|
//! game loads it (DLL search-order / sideload), at which point we:
|
|
//!
|
|
//! 1. (Task 2) Forward every real `version.dll` export to the genuine system
|
|
//! DLL, so the game keeps working. We prove load with a log line.
|
|
//! 2. (Task 3) Pattern-scan FIFA23.exe for `_ProtoSSLSendPacket` (the DirtySDK
|
|
//! TLS record assembler we located at FIFA23.exe+0xEFA530) and install an
|
|
//! inline detour. At its entry the record payload is still PLAINTEXT, so we
|
|
//! log the content type + the source buffers, then call the original.
|
|
//!
|
|
//! Everything is written to `C:\openfut\hook.log` with timestamps, in stages,
|
|
//! so the log alone tells us how far initialization got.
|
|
//!
|
|
//! Clean-room: this is generic proxy/loader/hook scaffolding plus a byte
|
|
//! pattern derived from observing the binary the user owns. No EA source.
|
|
|
|
use core::ffi::c_void;
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
use std::sync::Mutex;
|
|
|
|
use retour::RawDetour;
|
|
use windows::core::{PCSTR, PCWSTR};
|
|
use windows::Win32::Foundation::{BOOL, HMODULE};
|
|
use windows::Win32::Networking::WinSock::{WSAGetLastError, AF_INET, SOCKADDR, SOCKADDR_IN};
|
|
use windows::Win32::System::LibraryLoader::{
|
|
DisableThreadLibraryCalls, GetModuleHandleW, GetProcAddress, LoadLibraryW,
|
|
};
|
|
use windows::Win32::System::ProcessStatus::{GetModuleInformation, MODULEINFO};
|
|
use windows::Win32::System::SystemInformation::GetLocalTime;
|
|
use windows::Win32::System::SystemServices::DLL_PROCESS_ATTACH;
|
|
use windows::Win32::System::Threading::{
|
|
CreateThread, GetCurrentProcess, GetCurrentThreadStackLimits, Sleep, THREAD_CREATION_FLAGS,
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Task 2: version.dll export forwarding
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Resolved addresses of the real system `version.dll` exports. Each proxy stub
|
|
/// below tail-jumps to its slot. AtomicUsize (not `static mut`) keeps this sound
|
|
/// and lets the naked stubs read the raw pointer via a simple memory load.
|
|
static REAL: [AtomicUsize; 17] = [const { AtomicUsize::new(0) }; 17];
|
|
|
|
/// The 17 exports a real `version.dll` provides, in the SAME order as the proxy
|
|
/// stubs' indices below.
|
|
const EXPORTS: [&str; 17] = [
|
|
"GetFileVersionInfoA",
|
|
"GetFileVersionInfoByHandle",
|
|
"GetFileVersionInfoExA",
|
|
"GetFileVersionInfoExW",
|
|
"GetFileVersionInfoSizeA",
|
|
"GetFileVersionInfoSizeExA",
|
|
"GetFileVersionInfoSizeExW",
|
|
"GetFileVersionInfoSizeW",
|
|
"GetFileVersionInfoW",
|
|
"VerFindFileA",
|
|
"VerFindFileW",
|
|
"VerInstallFileA",
|
|
"VerInstallFileW",
|
|
"VerLanguageNameA",
|
|
"VerLanguageNameW",
|
|
"VerQueryValueA",
|
|
"VerQueryValueW",
|
|
];
|
|
|
|
/// Generate an exported, naked proxy stub that tail-jumps to `REAL[idx]`.
|
|
/// A tail `jmp` leaves every register/stack arg untouched, so it forwards any
|
|
/// signature correctly regardless of how many arguments the real function takes.
|
|
macro_rules! proxy_stub {
|
|
($idx:literal, $name:ident) => {
|
|
#[no_mangle]
|
|
#[unsafe(naked)]
|
|
pub unsafe extern "system" fn $name() {
|
|
core::arch::naked_asm!(
|
|
"jmp qword ptr [rip + {base} + {off}]",
|
|
base = sym REAL,
|
|
off = const $idx * 8,
|
|
);
|
|
}
|
|
};
|
|
}
|
|
|
|
proxy_stub!(0, GetFileVersionInfoA);
|
|
proxy_stub!(1, GetFileVersionInfoByHandle);
|
|
proxy_stub!(2, GetFileVersionInfoExA);
|
|
proxy_stub!(3, GetFileVersionInfoExW);
|
|
proxy_stub!(4, GetFileVersionInfoSizeA);
|
|
proxy_stub!(5, GetFileVersionInfoSizeExA);
|
|
proxy_stub!(6, GetFileVersionInfoSizeExW);
|
|
proxy_stub!(7, GetFileVersionInfoSizeW);
|
|
proxy_stub!(8, GetFileVersionInfoW);
|
|
proxy_stub!(9, VerFindFileA);
|
|
proxy_stub!(10, VerFindFileW);
|
|
proxy_stub!(11, VerInstallFileA);
|
|
proxy_stub!(12, VerInstallFileW);
|
|
proxy_stub!(13, VerLanguageNameA);
|
|
proxy_stub!(14, VerLanguageNameW);
|
|
proxy_stub!(15, VerQueryValueA);
|
|
proxy_stub!(16, VerQueryValueW);
|
|
|
|
/// Load the genuine `version.dll` by full path (so we don't re-load ourselves)
|
|
/// and fill `REAL[]` with each export's address. Done synchronously in DllMain
|
|
/// so the slots are ready before the game calls any version function.
|
|
unsafe fn resolve_exports() {
|
|
let path = wide("C:\\Windows\\System32\\version.dll");
|
|
let module = match LoadLibraryW(PCWSTR(path.as_ptr())) {
|
|
Ok(m) => m,
|
|
Err(e) => {
|
|
log(&format!("FATAL: could not load real version.dll: {e:?}"));
|
|
return;
|
|
}
|
|
};
|
|
let mut missing = 0;
|
|
for (i, name) in EXPORTS.iter().enumerate() {
|
|
let cname = std::ffi::CString::new(*name).unwrap();
|
|
let addr = GetProcAddress(module, PCSTR(cname.as_ptr() as *const u8))
|
|
.map(|f| f as usize)
|
|
.unwrap_or(0);
|
|
REAL[i].store(addr, Ordering::SeqCst);
|
|
if addr == 0 {
|
|
missing += 1;
|
|
log(&format!("warn: real version.dll missing export {name}"));
|
|
}
|
|
}
|
|
log(&format!("forwarded {} version.dll exports", 17 - missing));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Task 3: the _ProtoSSLSendPacket detour
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Trampoline to the original `_ProtoSSLSendPacket`, stored after we hook.
|
|
static ORIG: AtomicUsize = AtomicUsize::new(0);
|
|
|
|
/// Signature derived from the disassembly (Windows x64 ABI):
|
|
/// `_ProtoSSLSendPacket(pState, contentType, bufA, lenA, bufB, lenB) -> i32`.
|
|
type SendPacket =
|
|
unsafe extern "system" fn(*mut c_void, u32, *const u8, i32, *const u8, i32) -> i32;
|
|
|
|
/// Byte pattern for the `_ProtoSSLSendPacket` prologue. The four `0x00` bytes at
|
|
/// the masked positions are the stack-cookie `mov rax,[rip+disp32]` displacement
|
|
/// (position-dependent), so they're wildcarded via `MASK`.
|
|
const PATTERN: [u8; 36] = [
|
|
0x40, 0x53, 0x55, 0x56, 0x57, 0x41, 0x54, 0x41, 0x55, 0x41, 0x57, 0x48, 0x81, 0xEC, 0xB0,
|
|
0x00, 0x00, 0x00, 0x48, 0x8B, 0x05, 0x00, 0x00, 0x00, 0x00, 0x48, 0x33, 0xC4, 0x48, 0x89,
|
|
0x84, 0x24, 0xA0, 0x00, 0x00, 0x00,
|
|
];
|
|
/// `false` = wildcard byte (don't compare). Indices 21..=24 are the disp32.
|
|
const MASK: [bool; 36] = {
|
|
let mut m = [true; 36];
|
|
m[21] = false;
|
|
m[22] = false;
|
|
m[23] = false;
|
|
m[24] = false;
|
|
m
|
|
};
|
|
|
|
/// Our detour. At entry the record payload is still plaintext, so we log the
|
|
/// content type and source buffers, then forward to the original unchanged.
|
|
unsafe extern "system" fn hooked(
|
|
p_state: *mut c_void,
|
|
content_type: u32,
|
|
buf_a: *const u8,
|
|
len_a: i32,
|
|
buf_b: *const u8,
|
|
len_b: i32,
|
|
) -> i32 {
|
|
log_frame(content_type as u8, buf_a, len_a, buf_b, len_b);
|
|
|
|
let orig = ORIG.load(Ordering::SeqCst);
|
|
if orig != 0 {
|
|
let orig: SendPacket = core::mem::transmute(orig);
|
|
orig(p_state, content_type, buf_a, len_a, buf_b, len_b)
|
|
} else {
|
|
// Should never happen (we store ORIG before the game can call us), but
|
|
// never crash the game if it does.
|
|
0
|
|
}
|
|
}
|
|
|
|
/// Background init: pattern-scan FIFA23.exe for the function, then detour it.
|
|
/// Retries for a while in case the image isn't fully paged in at load.
|
|
unsafe extern "system" fn init_thread(_: *mut c_void) -> u32 {
|
|
// Connection capture first. Listen on the LSX port (gate 1, so the launcher
|
|
// bootstrap succeeds) and on the redirect port (for external TLS/Blaze).
|
|
store_exe_range();
|
|
start_listener(LSX_PORT, "LSX");
|
|
start_listener(LOCAL_PORT, "BLZ");
|
|
hook_dns();
|
|
hook_winsock_data();
|
|
hook_connect();
|
|
|
|
// Then the plaintext-capture detour on _ProtoSSLSendPacket, plus the
|
|
// read-only anadius GoOnline probe (M1). Retry until both are installed.
|
|
let mut send_done = false;
|
|
let mut anadius_done = false;
|
|
for _ in 0..60 {
|
|
if !send_done {
|
|
if let Some(addr) = find_send_packet() {
|
|
install_hook(addr);
|
|
send_done = true;
|
|
}
|
|
}
|
|
if !anadius_done && hook_anadius_probes() {
|
|
anadius_done = true;
|
|
}
|
|
if send_done && anadius_done {
|
|
return 0;
|
|
}
|
|
Sleep(1000);
|
|
}
|
|
if !send_done {
|
|
log("ERROR: _ProtoSSLSendPacket pattern not found after 60s");
|
|
}
|
|
if !anadius_done {
|
|
log("ERROR: anadius64.dll not loaded after 60s; GoOnline probe not installed");
|
|
}
|
|
0
|
|
}
|
|
|
|
/// Scan the FIFA23.exe image for the `_ProtoSSLSendPacket` prologue pattern.
|
|
unsafe fn find_send_packet() -> Option<usize> {
|
|
let module = GetModuleHandleW(PCWSTR::null()).ok()?; // null => the EXE itself
|
|
let base = module.0 as usize;
|
|
|
|
let mut info = MODULEINFO::default();
|
|
GetModuleInformation(
|
|
GetCurrentProcess(),
|
|
module,
|
|
&mut info,
|
|
core::mem::size_of::<MODULEINFO>() as u32,
|
|
)
|
|
.ok()?;
|
|
let size = info.SizeOfImage as usize;
|
|
let hay = core::slice::from_raw_parts(base as *const u8, size);
|
|
|
|
let plen = PATTERN.len();
|
|
if hay.len() < plen {
|
|
return None;
|
|
}
|
|
for i in 0..=hay.len() - plen {
|
|
// Cheap first-byte gate before the full compare.
|
|
if hay[i] != PATTERN[0] {
|
|
continue;
|
|
}
|
|
let mut ok = true;
|
|
for j in 1..plen {
|
|
if MASK[j] && hay[i + j] != PATTERN[j] {
|
|
ok = false;
|
|
break;
|
|
}
|
|
}
|
|
if ok {
|
|
return Some(base + i);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Install the inline detour on the resolved function address.
|
|
unsafe fn install_hook(addr: usize) {
|
|
let detour = match RawDetour::new(addr as *const (), hooked as *const ()) {
|
|
Ok(d) => d,
|
|
Err(e) => {
|
|
log(&format!("ERROR: could not create detour: {e:?}"));
|
|
return;
|
|
}
|
|
};
|
|
if let Err(e) = detour.enable() {
|
|
log(&format!("ERROR: could not enable detour: {e:?}"));
|
|
return;
|
|
}
|
|
// Leak the detour so it lives forever (dropping it would un-hook), and
|
|
// publish its trampoline so `hooked` can call the original.
|
|
let detour: &'static RawDetour = Box::leak(Box::new(detour));
|
|
ORIG.store(detour.trampoline() as *const () as usize, Ordering::SeqCst);
|
|
log(&format!(
|
|
"hook installed: _ProtoSSLSendPacket @ 0x{addr:X} (FIFA23.exe+0x{:X})",
|
|
addr - module_base(),
|
|
));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Connection capture: log every connect() and redirect external attempts to a
|
|
// local listener, so the client's TCP succeeds and it starts sending TLS.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Local port our in-DLL listener binds; redirected connections land here.
|
|
const LOCAL_PORT: u16 = 7777;
|
|
|
|
/// The EA launcher LSX port. The game connects here for launcher↔game bootstrap
|
|
/// (gate 1). We listen so the connect succeeds and we can see the LSX protocol.
|
|
const LSX_PORT: u16 = 3216;
|
|
|
|
/// Trampoline to the original ws2_32 `connect`.
|
|
static ORIG_CONNECT: AtomicUsize = AtomicUsize::new(0);
|
|
|
|
type ConnectFn = unsafe extern "system" fn(usize, *const SOCKADDR, i32) -> i32;
|
|
|
|
/// Our `connect` detour: log the real destination, and for any non-loopback
|
|
/// IPv4 target, rewrite it to 127.0.0.1:LOCAL_PORT before calling the original.
|
|
unsafe extern "system" fn hooked_connect(s: usize, name: *const SOCKADDR, namelen: i32) -> i32 {
|
|
let orig: ConnectFn = core::mem::transmute(ORIG_CONNECT.load(Ordering::SeqCst));
|
|
|
|
if !name.is_null() && (*name).sa_family == AF_INET {
|
|
let sin = name as *const SOCKADDR_IN;
|
|
let port = u16::from_be((*sin).sin_port);
|
|
let octets = (*sin).sin_addr.S_un.S_addr.to_ne_bytes();
|
|
let is_loopback = octets[0] == 127;
|
|
let dst = format!("{}.{}.{}.{}:{}", octets[0], octets[1], octets[2], octets[3], port);
|
|
|
|
if !is_loopback {
|
|
// Build a fresh 127.0.0.1:LOCAL_PORT address and connect there.
|
|
let mut local: SOCKADDR_IN = core::mem::zeroed();
|
|
local.sin_family = AF_INET;
|
|
local.sin_port = LOCAL_PORT.to_be();
|
|
local.sin_addr.S_un.S_addr = u32::from_ne_bytes([127, 0, 0, 1]);
|
|
let ret = orig(
|
|
s,
|
|
&local as *const SOCKADDR_IN as *const SOCKADDR,
|
|
core::mem::size_of::<SOCKADDR_IN>() as i32,
|
|
);
|
|
let err = if ret != 0 { WSAGetLastError().0 } else { 0 };
|
|
log(&format!("CONNECT -> {dst} [redirected->7777] ret={ret} err={err}"));
|
|
return ret;
|
|
} else {
|
|
let ret = orig(s, name, namelen);
|
|
let err = if ret != 0 { WSAGetLastError().0 } else { 0 };
|
|
log(&format!("CONNECT -> {dst} ret={ret} err={err}"));
|
|
return ret;
|
|
}
|
|
}
|
|
|
|
orig(s, name, namelen)
|
|
}
|
|
|
|
// --- DNS logging: what hostnames does the client try to resolve? ----------
|
|
|
|
static ORIG_GAIW: AtomicUsize = AtomicUsize::new(0);
|
|
static ORIG_GAI: AtomicUsize = AtomicUsize::new(0);
|
|
|
|
type GaiWFn = unsafe extern "system" fn(PCWSTR, PCWSTR, *const c_void, *mut *mut c_void) -> i32;
|
|
type GaiFn = unsafe extern "system" fn(PCSTR, PCSTR, *const c_void, *mut *mut c_void) -> i32;
|
|
|
|
unsafe extern "system" fn hooked_gaiw(
|
|
node: PCWSTR,
|
|
svc: PCWSTR,
|
|
hints: *const c_void,
|
|
res: *mut *mut c_void,
|
|
) -> i32 {
|
|
let name = if node.is_null() {
|
|
"<null>".to_string()
|
|
} else {
|
|
node.to_string().unwrap_or_else(|_| "<?>".into())
|
|
};
|
|
let orig: GaiWFn = core::mem::transmute(ORIG_GAIW.load(Ordering::SeqCst));
|
|
let ret = orig(node, svc, hints, res);
|
|
log(&format!("DNS GetAddrInfoW(\"{name}\") ret={ret}"));
|
|
ret
|
|
}
|
|
|
|
unsafe extern "system" fn hooked_gai(
|
|
node: PCSTR,
|
|
svc: PCSTR,
|
|
hints: *const c_void,
|
|
res: *mut *mut c_void,
|
|
) -> i32 {
|
|
let name = if node.is_null() {
|
|
"<null>".to_string()
|
|
} else {
|
|
node.to_string().unwrap_or_else(|_| "<?>".into())
|
|
};
|
|
let orig: GaiFn = core::mem::transmute(ORIG_GAI.load(Ordering::SeqCst));
|
|
let ret = orig(node, svc, hints, res);
|
|
log(&format!("DNS getaddrinfo(\"{name}\") ret={ret}"));
|
|
ret
|
|
}
|
|
|
|
/// Generic: resolve `name` in `module`, install a detour to `detour`, store the
|
|
/// trampoline in `slot`.
|
|
unsafe fn install_detour(
|
|
module: HMODULE,
|
|
name: &[u8],
|
|
detour: *const (),
|
|
slot: &AtomicUsize,
|
|
label: &str,
|
|
) {
|
|
let addr = match GetProcAddress(module, PCSTR(name.as_ptr())) {
|
|
Some(f) => f as usize,
|
|
None => {
|
|
log(&format!("ERROR: {label} not found"));
|
|
return;
|
|
}
|
|
};
|
|
install_detour_at(addr, detour, slot, label);
|
|
}
|
|
|
|
/// Install an inline detour at a raw address (for non-exported targets such as
|
|
/// internal anadius handlers located by module+offset).
|
|
unsafe fn install_detour_at(addr: usize, detour: *const (), slot: &AtomicUsize, label: &str) {
|
|
let d = match RawDetour::new(addr as *const (), detour) {
|
|
Ok(d) => d,
|
|
Err(e) => {
|
|
log(&format!("ERROR: {label} detour create: {e:?}"));
|
|
return;
|
|
}
|
|
};
|
|
if d.enable().is_err() {
|
|
log(&format!("ERROR: {label} detour enable failed"));
|
|
return;
|
|
}
|
|
let d: &'static RawDetour = Box::leak(Box::new(d));
|
|
slot.store(d.trampoline() as *const () as usize, Ordering::SeqCst);
|
|
log(&format!("{label} hook installed"));
|
|
}
|
|
|
|
// --- M1 read-only probe: anadius GoOnline handler -------------------------
|
|
|
|
static ORIG_GOONLINE: AtomicUsize = AtomicUsize::new(0);
|
|
static EXE_BASE: AtomicUsize = AtomicUsize::new(0);
|
|
static EXE_SIZE: AtomicUsize = AtomicUsize::new(0);
|
|
static STACK_LOGGED: AtomicUsize = AtomicUsize::new(0);
|
|
|
|
/// Record FIFA23.exe's base + size so we can recognise its frames in a backtrace.
|
|
unsafe fn store_exe_range() {
|
|
if let Ok(h) = GetModuleHandleW(PCWSTR::null()) {
|
|
let mut mi = MODULEINFO::default();
|
|
if GetModuleInformation(
|
|
GetCurrentProcess(),
|
|
h,
|
|
&mut mi,
|
|
core::mem::size_of::<MODULEINFO>() as u32,
|
|
)
|
|
.is_ok()
|
|
{
|
|
EXE_BASE.store(h.0 as usize, Ordering::SeqCst);
|
|
EXE_SIZE.store(mi.SizeOfImage as usize, Ordering::SeqCst);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Scan the raw stack for values that land in FIFA23.exe (the game-side
|
|
/// online-flow return addresses that called into GoOnline). Unwind-free, so it
|
|
/// survives the detour trampolines that break RtlCaptureStackBackTrace.
|
|
/// One-shot to avoid log spam.
|
|
unsafe fn log_fifa_callstack(tag: &str) {
|
|
if STACK_LOGGED.swap(1, Ordering::SeqCst) != 0 {
|
|
return;
|
|
}
|
|
let base = EXE_BASE.load(Ordering::SeqCst);
|
|
let size = EXE_SIZE.load(Ordering::SeqCst);
|
|
if base == 0 || size == 0 {
|
|
log(&format!("{tag} stack scan skipped (exe range unknown)"));
|
|
return;
|
|
}
|
|
// Address of a local ~= current rsp; the stack grows down, so callers'
|
|
// return addresses sit at HIGHER addresses. Scan upward, but NEVER past the
|
|
// committed stack top (reading beyond it faults — that crashed the game).
|
|
let mut low: usize = 0;
|
|
let mut high: usize = 0;
|
|
GetCurrentThreadStackLimits(&mut low, &mut high);
|
|
let probe: usize = 0;
|
|
let sp = &probe as *const usize as usize;
|
|
let end = high; // scan the whole rest of the stack (committed, safe)
|
|
let mut line = format!(
|
|
"{tag} stack[low=0x{low:X} high=0x{high:X} sp=0x{sp:X}] FIFA23.exe refs:"
|
|
);
|
|
let mut count = 0;
|
|
let mut p = sp;
|
|
while p + 8 <= end {
|
|
let val = *(p as *const usize);
|
|
if val >= base && val < base + size {
|
|
line.push_str(&format!(" +0x{:X}", val - base));
|
|
count += 1;
|
|
if count >= 40 {
|
|
break;
|
|
}
|
|
}
|
|
p += 8;
|
|
}
|
|
log(&line);
|
|
}
|
|
|
|
/// M2 flip on anadius's GoOnline handler (anadius64.dll+0x2BB90). The original
|
|
/// handler is `mov rcx,rdx; lea r8,[+0xADD73]; lea rdx,[+0xADE64 = "0"]; call
|
|
/// +0x25BE0; mov al,1` — i.e. it builds its ErrorSuccess response with the value
|
|
/// "0" (offline). We replicate it but pass "1" (+0xAF530 = the connected value),
|
|
/// so GoOnline reports online, then return success (al=1).
|
|
unsafe extern "system" fn hooked_goonline(_a: usize, b: usize, _c: usize, _d: usize) -> usize {
|
|
log_fifa_callstack("GoOnline");
|
|
let base = ANADIUS_BASE.load(Ordering::SeqCst);
|
|
if base != 0 {
|
|
log("FLIP GoOnline -> reporting online (\"1\")");
|
|
let builder: unsafe extern "system" fn(usize, usize, usize) -> usize =
|
|
core::mem::transmute(base + 0x25BE0);
|
|
// 0x25BE0(rcx = handler's rdx, rdx = "1", r8 = +0xADD73)
|
|
builder(b, base + 0xAF530, base + 0xADD73);
|
|
return 1;
|
|
}
|
|
let orig = ORIG_GOONLINE.load(Ordering::SeqCst);
|
|
if orig != 0 {
|
|
let f: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
|
|
core::mem::transmute(orig);
|
|
f(_a, b, _c, _d)
|
|
} else {
|
|
0
|
|
}
|
|
}
|
|
|
|
// --- M2 flip: force GetInternetConnectedState to report "connected" --------
|
|
|
|
static ORIG_ICS: AtomicUsize = AtomicUsize::new(0);
|
|
static ANADIUS_BASE: AtomicUsize = AtomicUsize::new(0);
|
|
|
|
/// anadius's GetInternetConnectedState handler (anadius64.dll+0x27790) builds an
|
|
/// LSX response whose `connected` value is:
|
|
/// (byte[+0xCAB1B] || byte[+0xCAB1A]) ? connected : offline
|
|
/// Both default to 0 → offline → the game aborts at "connecting". We force both
|
|
/// flags to 1 before the original runs, so it builds the "connected" response.
|
|
unsafe extern "system" fn hooked_ics(a: usize, b: usize, c: usize, d: usize) -> usize {
|
|
let base = ANADIUS_BASE.load(Ordering::SeqCst);
|
|
if base != 0 {
|
|
core::ptr::write_volatile((base + 0xCAB1A) as *mut u8, 1u8);
|
|
core::ptr::write_volatile((base + 0xCAB1B) as *mut u8, 1u8);
|
|
}
|
|
log("FLIP GetInternetConnectedState -> forcing connected (flags set)");
|
|
let orig = ORIG_ICS.load(Ordering::SeqCst);
|
|
if orig != 0 {
|
|
let f: unsafe extern "system" fn(usize, usize, usize, usize) -> usize =
|
|
core::mem::transmute(orig);
|
|
f(a, b, c, d)
|
|
} else {
|
|
0
|
|
}
|
|
}
|
|
|
|
/// Resolve anadius64.dll's runtime base, detour the GoOnline probe, and install
|
|
/// the M2 GetInternetConnectedState flip. Returns false if anadius isn't loaded.
|
|
unsafe fn hook_anadius_probes() -> bool {
|
|
let base = match GetModuleHandleW(PCWSTR(wide("anadius64.dll").as_ptr())) {
|
|
Ok(m) => m.0 as usize,
|
|
Err(_) => return false, // not loaded yet
|
|
};
|
|
ANADIUS_BASE.store(base, Ordering::SeqCst);
|
|
log(&format!("anadius64.dll base = 0x{base:X}"));
|
|
|
|
install_detour_at(
|
|
base + 0x2BB90,
|
|
hooked_goonline as *const (),
|
|
&ORIG_GOONLINE,
|
|
"PROBE anadius GoOnline @ +0x2BB90",
|
|
);
|
|
install_detour_at(
|
|
base + 0x27790,
|
|
hooked_ics as *const (),
|
|
&ORIG_ICS,
|
|
"FLIP anadius GetInternetConnectedState @ +0x27790",
|
|
);
|
|
true
|
|
}
|
|
|
|
/// Detour the DNS resolvers so we see every hostname lookup.
|
|
unsafe fn hook_dns() {
|
|
let ws2 = match LoadLibraryW(PCWSTR(wide("ws2_32.dll").as_ptr())) {
|
|
Ok(m) => m,
|
|
Err(e) => {
|
|
log(&format!("ERROR: load ws2_32 for DNS: {e:?}"));
|
|
return;
|
|
}
|
|
};
|
|
install_detour(ws2, b"GetAddrInfoW\0", hooked_gaiw as *const (), &ORIG_GAIW, "GetAddrInfoW");
|
|
install_detour(ws2, b"getaddrinfo\0", hooked_gai as *const (), &ORIG_GAI, "getaddrinfo");
|
|
}
|
|
|
|
// --- LSX capture: read the Ebisu-SDK <-> anadius XML conversation ----------
|
|
|
|
static ORIG_SEND: AtomicUsize = AtomicUsize::new(0);
|
|
static ORIG_RECV: AtomicUsize = AtomicUsize::new(0);
|
|
|
|
type SendFn = unsafe extern "system" fn(usize, *const u8, i32, i32) -> i32;
|
|
type RecvFn = unsafe extern "system" fn(usize, *mut u8, i32, i32) -> i32;
|
|
|
|
/// Cheap test: does this buffer look like LSX/Ebisu XML (not TLS/binary)?
|
|
fn looks_like_lsx(buf: &[u8]) -> bool {
|
|
let n = buf.len().min(64);
|
|
let head = &buf[..n];
|
|
let has_lt = head.iter().any(|&b| b == b'<');
|
|
let has_gt = head.iter().any(|&b| b == b'>');
|
|
head.windows(3).any(|w| w == b"LSX")
|
|
|| head.windows(5).any(|w| w == b"Ebisu")
|
|
|| (has_lt && has_gt)
|
|
}
|
|
|
|
unsafe extern "system" fn hooked_send(s: usize, buf: *const u8, len: i32, flags: i32) -> i32 {
|
|
if len > 0 && !buf.is_null() {
|
|
let head = core::slice::from_raw_parts(buf, (len as usize).min(64));
|
|
if looks_like_lsx(head) {
|
|
let show = core::slice::from_raw_parts(buf, (len as usize).min(800));
|
|
log(&format!("LSX send sock={s} {len}B: {}", ascii_render(show)));
|
|
}
|
|
}
|
|
let orig: SendFn = core::mem::transmute(ORIG_SEND.load(Ordering::SeqCst));
|
|
orig(s, buf, len, flags)
|
|
}
|
|
|
|
unsafe extern "system" fn hooked_recv(s: usize, buf: *mut u8, len: i32, flags: i32) -> i32 {
|
|
let orig: RecvFn = core::mem::transmute(ORIG_RECV.load(Ordering::SeqCst));
|
|
let ret = orig(s, buf, len, flags);
|
|
if ret > 0 && !buf.is_null() {
|
|
let head = core::slice::from_raw_parts(buf, (ret as usize).min(64));
|
|
if looks_like_lsx(head) {
|
|
let show = core::slice::from_raw_parts(buf, (ret as usize).min(800));
|
|
log(&format!("LSX recv sock={s} {ret}B: {}", ascii_render(show)));
|
|
}
|
|
}
|
|
ret
|
|
}
|
|
|
|
// Async (overlapped/IOCP) variants. A WSABUF is { len, buf }.
|
|
#[repr(C)]
|
|
struct WsaBuf {
|
|
len: u32,
|
|
buf: *mut u8,
|
|
}
|
|
|
|
static ORIG_WSASEND: AtomicUsize = AtomicUsize::new(0);
|
|
static ORIG_WSARECV: AtomicUsize = AtomicUsize::new(0);
|
|
|
|
type WsaSendFn = unsafe extern "system" fn(
|
|
usize,
|
|
*const WsaBuf,
|
|
u32,
|
|
*mut u32,
|
|
u32,
|
|
*mut c_void,
|
|
*mut c_void,
|
|
) -> i32;
|
|
type WsaRecvFn = unsafe extern "system" fn(
|
|
usize,
|
|
*const WsaBuf,
|
|
u32,
|
|
*mut u32,
|
|
*mut u32,
|
|
*mut c_void,
|
|
*mut c_void,
|
|
) -> i32;
|
|
|
|
unsafe extern "system" fn hooked_wsasend(
|
|
s: usize,
|
|
bufs: *const WsaBuf,
|
|
count: u32,
|
|
sent: *mut u32,
|
|
flags: u32,
|
|
ovl: *mut c_void,
|
|
cr: *mut c_void,
|
|
) -> i32 {
|
|
// Outgoing data is readable before the call — capture the first buffer.
|
|
if !bufs.is_null() && count > 0 {
|
|
let b0 = &*bufs;
|
|
if b0.len > 0 && !b0.buf.is_null() {
|
|
let head = core::slice::from_raw_parts(b0.buf, (b0.len as usize).min(64));
|
|
if looks_like_lsx(head) {
|
|
let show = core::slice::from_raw_parts(b0.buf, (b0.len as usize).min(800));
|
|
log(&format!("LSX WSASend sock={s} {}B: {}", b0.len, ascii_render(show)));
|
|
}
|
|
}
|
|
}
|
|
let orig: WsaSendFn = core::mem::transmute(ORIG_WSASEND.load(Ordering::SeqCst));
|
|
orig(s, bufs, count, sent, flags, ovl, cr)
|
|
}
|
|
|
|
unsafe extern "system" fn hooked_wsarecv(
|
|
s: usize,
|
|
bufs: *const WsaBuf,
|
|
count: u32,
|
|
recvd: *mut u32,
|
|
flags: *mut u32,
|
|
ovl: *mut c_void,
|
|
cr: *mut c_void,
|
|
) -> i32 {
|
|
let orig: WsaRecvFn = core::mem::transmute(ORIG_WSARECV.load(Ordering::SeqCst));
|
|
let ret = orig(s, bufs, count, recvd, flags, ovl, cr);
|
|
// Only the synchronous case (no overlapped) has data ready on return.
|
|
if ret == 0 && ovl.is_null() && !recvd.is_null() && !bufs.is_null() && count > 0 {
|
|
let n = *recvd as usize;
|
|
let b0 = &*bufs;
|
|
if n > 0 && !b0.buf.is_null() {
|
|
let cap = n.min(b0.len as usize);
|
|
let head = core::slice::from_raw_parts(b0.buf, cap.min(64));
|
|
if looks_like_lsx(head) {
|
|
let show = core::slice::from_raw_parts(b0.buf, cap.min(800));
|
|
log(&format!("LSX WSARecv sock={s} {n}B: {}", ascii_render(show)));
|
|
}
|
|
}
|
|
}
|
|
ret
|
|
}
|
|
|
|
/// Detour ws2_32 send/recv (sync) and WSASend/WSARecv (async) to capture LSX XML.
|
|
unsafe fn hook_winsock_data() {
|
|
let ws2 = match LoadLibraryW(PCWSTR(wide("ws2_32.dll").as_ptr())) {
|
|
Ok(m) => m,
|
|
Err(e) => {
|
|
log(&format!("ERROR: load ws2_32 for send/recv: {e:?}"));
|
|
return;
|
|
}
|
|
};
|
|
install_detour(ws2, b"send\0", hooked_send as *const (), &ORIG_SEND, "send");
|
|
install_detour(ws2, b"recv\0", hooked_recv as *const (), &ORIG_RECV, "recv");
|
|
install_detour(ws2, b"WSASend\0", hooked_wsasend as *const (), &ORIG_WSASEND, "WSASend");
|
|
install_detour(ws2, b"WSARecv\0", hooked_wsarecv as *const (), &ORIG_WSARECV, "WSARecv");
|
|
}
|
|
|
|
/// Resolve and detour ws2_32 `connect`.
|
|
unsafe fn hook_connect() {
|
|
let ws2 = match LoadLibraryW(PCWSTR(wide("ws2_32.dll").as_ptr())) {
|
|
Ok(m) => m,
|
|
Err(e) => {
|
|
log(&format!("ERROR: could not load ws2_32.dll: {e:?}"));
|
|
return;
|
|
}
|
|
};
|
|
let addr = match GetProcAddress(ws2, PCSTR(b"connect\0".as_ptr())) {
|
|
Some(f) => f as usize,
|
|
None => {
|
|
log("ERROR: connect not found in ws2_32.dll");
|
|
return;
|
|
}
|
|
};
|
|
let detour = match RawDetour::new(addr as *const (), hooked_connect as *const ()) {
|
|
Ok(d) => d,
|
|
Err(e) => {
|
|
log(&format!("ERROR: could not create connect detour: {e:?}"));
|
|
return;
|
|
}
|
|
};
|
|
if let Err(e) = detour.enable() {
|
|
log(&format!("ERROR: could not enable connect detour: {e:?}"));
|
|
return;
|
|
}
|
|
let detour: &'static RawDetour = Box::leak(Box::new(detour));
|
|
ORIG_CONNECT.store(detour.trampoline() as *const () as usize, Ordering::SeqCst);
|
|
log("connect hook installed (external IPv4 -> 127.0.0.1:7777)");
|
|
}
|
|
|
|
/// Spawn a TCP listener on `127.0.0.1:port`, tagging logged traffic with `tag`.
|
|
/// Accepts connections and logs what the client sends — as readable text (for
|
|
/// the text-based LSX protocol) plus a hex prefix (for binary TLS/Blaze).
|
|
fn start_listener(port: u16, tag: &'static str) {
|
|
std::thread::spawn(move || {
|
|
let listener = match std::net::TcpListener::bind(("127.0.0.1", port)) {
|
|
Ok(l) => l,
|
|
Err(e) => {
|
|
log(&format!("ERROR: listener[{tag}] bind {port} failed: {e}"));
|
|
return;
|
|
}
|
|
};
|
|
let bound = listener
|
|
.local_addr()
|
|
.map(|a| a.to_string())
|
|
.unwrap_or_default();
|
|
log(&format!("listener[{tag}] up, bound {bound}, accepting"));
|
|
|
|
// Explicit accept loop so accept errors are visible (not swallowed).
|
|
loop {
|
|
match listener.accept() {
|
|
Ok((stream, peer)) => {
|
|
log(&format!("ACCEPT[{tag}] from {peer}"));
|
|
std::thread::spawn(move || handle_conn(stream, tag, peer.to_string()));
|
|
}
|
|
Err(e) => {
|
|
log(&format!("ACCEPT[{tag}] error: {e}"));
|
|
std::thread::sleep(std::time::Duration::from_millis(250));
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
fn handle_conn(mut stream: std::net::TcpStream, tag: &'static str, peer: String) {
|
|
use std::io::Read;
|
|
let mut buf = [0u8; 2048];
|
|
let mut total = 0usize;
|
|
loop {
|
|
match stream.read(&mut buf) {
|
|
Ok(0) | Err(_) => break,
|
|
Ok(n) => {
|
|
total += n;
|
|
let ascii = ascii_render(&buf[..n.min(400)]);
|
|
let hexp = hex(&buf[..n.min(24)]);
|
|
log(&format!("RECV[{tag}] {n}B | hex: {hexp} | text: {ascii}"));
|
|
}
|
|
}
|
|
}
|
|
log(&format!("CLOSE[{tag}] from {peer} after {total}B total"));
|
|
}
|
|
|
|
/// Render bytes as printable ASCII (non-printable -> '.'), for text protocols.
|
|
fn ascii_render(bytes: &[u8]) -> String {
|
|
bytes
|
|
.iter()
|
|
.map(|&b| {
|
|
if (0x20..=0x7e).contains(&b) || b == b'\n' || b == b'\r' || b == b'\t' {
|
|
b as char
|
|
} else {
|
|
'.'
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Logging
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn log_frame(content_type: u8, buf_a: *const u8, len_a: i32, buf_b: *const u8, len_b: i32) {
|
|
let label = match content_type {
|
|
0x14 => "ccs",
|
|
0x15 => "alert",
|
|
0x16 => "handshake",
|
|
0x17 => "appdata",
|
|
_ => "?",
|
|
};
|
|
let mut line = format!("SEND type=0x{content_type:02X}({label}) lenA={len_a} lenB={len_b}");
|
|
if !buf_a.is_null() && len_a > 0 {
|
|
let n = (len_a as usize).min(48);
|
|
let bytes = unsafe { core::slice::from_raw_parts(buf_a, n) };
|
|
line.push_str(&format!(" | A: {}", hex(bytes)));
|
|
}
|
|
if !buf_b.is_null() && len_b > 0 {
|
|
let n = (len_b as usize).min(16);
|
|
let bytes = unsafe { core::slice::from_raw_parts(buf_b, n) };
|
|
line.push_str(&format!(" | B: {}", hex(bytes)));
|
|
}
|
|
log(&line);
|
|
}
|
|
|
|
fn hex(bytes: &[u8]) -> String {
|
|
bytes
|
|
.iter()
|
|
.map(|b| format!("{b:02X}"))
|
|
.collect::<Vec<_>>()
|
|
.join(" ")
|
|
}
|
|
|
|
/// Serializes log writes so concurrent threads don't corrupt each other's lines.
|
|
static LOG_LOCK: Mutex<()> = Mutex::new(());
|
|
|
|
/// Append a timestamped line to `C:\openfut\hook.log`.
|
|
fn log(msg: &str) {
|
|
use std::io::Write;
|
|
let _guard = LOG_LOCK.lock();
|
|
let _ = std::fs::create_dir_all("C:\\openfut");
|
|
if let Ok(mut f) = std::fs::OpenOptions::new()
|
|
.create(true)
|
|
.append(true)
|
|
.open("C:\\openfut\\hook.log")
|
|
{
|
|
let _ = writeln!(f, "[{}] {}", now(), msg);
|
|
}
|
|
}
|
|
|
|
fn now() -> String {
|
|
let st = unsafe { GetLocalTime() };
|
|
format!(
|
|
"{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
|
|
st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers + entry point
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn wide(s: &str) -> Vec<u16> {
|
|
s.encode_utf16().chain(std::iter::once(0)).collect()
|
|
}
|
|
|
|
/// FIFA23.exe base address (the main module), for pretty logging.
|
|
fn module_base() -> usize {
|
|
unsafe {
|
|
GetModuleHandleW(PCWSTR::null())
|
|
.map(|h| h.0 as usize)
|
|
.unwrap_or(0)
|
|
}
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "system" fn DllMain(module: HMODULE, reason: u32, _reserved: *mut c_void) -> BOOL {
|
|
if reason == DLL_PROCESS_ATTACH {
|
|
unsafe {
|
|
let _ = DisableThreadLibraryCalls(module);
|
|
|
|
let host = std::env::current_exe()
|
|
.map(|p| p.display().to_string())
|
|
.unwrap_or_default();
|
|
log(&format!(
|
|
"openfut-hook loaded | pid {} | host {host}",
|
|
std::process::id()
|
|
));
|
|
|
|
// Forward exports synchronously (must be ready before any call)...
|
|
resolve_exports();
|
|
|
|
// ...then do the pattern scan + hook on a background thread (never
|
|
// do real work directly in DllMain — loader lock).
|
|
let _ = CreateThread(
|
|
None,
|
|
0,
|
|
Some(init_thread),
|
|
None,
|
|
THREAD_CREATION_FLAGS(0),
|
|
None,
|
|
);
|
|
}
|
|
}
|
|
BOOL(1)
|
|
}
|