merge: reconcile launcher capability and SBC tracing

Reconcile the two divergent launcher lineages that share merge base 87241ac:
  - feat/launcher-arming (13339c1): one-click client arming, modular
    preflight/services, and the FIFA 17 verified patched-client capability
    reporting (fifa17_capability + local_services stdout parsing + backend
    registration).
  - feat/sbc-hook-tracing (958ff24): openfut-hook SBC request tracing / RE
    instrumentation (sbc_hook, sbc_trace, probe, transport_watch, ...).

The lineages are almost disjoint (launcher crate vs openfut-hook crate); the
only overlap was src/process.rs, which launcher-arming removed (functionality
moved into local_services/game_launch, `mod process` dropped from main.rs) and
sbc-hook-tracing incidentally tidied (map_err->inspect_err). Resolved by keeping
the file DELETED: it is an orphan module in the refactored launcher and is not
part of the SBC feature (which lives entirely in openfut-hook). Both features are
retained in full.
This commit is contained in:
funman300
2026-08-13 05:09:58 +00:00
21 changed files with 5662 additions and 267 deletions
+5
View File
@@ -1 +1,6 @@
target/ target/
# runtime SQLite DB (created when services run from this dir)
openfut.db
openfut.db-shm
openfut.db-wal
+17
View File
@@ -6,6 +6,20 @@ edition = "2021"
[lib] [lib]
crate-type = ["cdylib"] 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 = []
# Build with `--features fifa17` for the FIFA 17 injection path. DllMain runs ONLY
# the minimal FIFA-17-safe logic in fifa17.rs (prove injection, dump module map,
# patch DirtySDK/ProtoSSL cert-verify) and skips ALL the FIFA-23-specific hooking.
fifa17 = []
[dependencies] [dependencies]
windows-sys = { version = "0.59", features = [ windows-sys = { version = "0.59", features = [
"Win32_Foundation", "Win32_Foundation",
@@ -15,6 +29,9 @@ windows-sys = { version = "0.59", features = [
"Win32_Networking_WinSock", "Win32_Networking_WinSock",
"Win32_Security_Cryptography", "Win32_Security_Cryptography",
"Win32_System_Threading", "Win32_System_Threading",
"Win32_System_Diagnostics_ToolHelp",
"Win32_System_Diagnostics_Debug",
"Win32_System_Kernel",
] } ] }
[profile.release] [profile.release]
+17
View File
@@ -0,0 +1,17 @@
use std::env;
use std::path::PathBuf;
fn main() {
println!("cargo:rerun-if-changed=version.def");
// The proxy's PE export surface is part of its runtime contract. Feed an
// explicit module-definition file to the MinGW linker instead of relying
// solely on Rust symbol export attributes and linker retention heuristics.
if env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows")
&& env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("gnu")
{
let definition =
PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("version.def");
println!("cargo:rustc-link-arg={}", definition.display());
}
}
+145 -32
View File
@@ -9,6 +9,15 @@ const PORT_HTTPS_NBO: u16 = 0xBB01; // 443 big-endian
const PORT_BRIDGE_NBO: u16 = 0xFB20; // 8443 big-endian const PORT_BRIDGE_NBO: u16 = 0xFB20; // 8443 big-endian
const PORT_BLAZE_REDIRECTOR_NBO: u16 = 0x3927; // 10041 big-endian const PORT_BLAZE_REDIRECTOR_NBO: u16 = 0x3927; // 10041 big-endian
const PORT_BLAZE_MAIN_NBO: u16 = 0x8FA4; // 42127 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 const ADDR_LOOPBACK_NBO: u32 = 0x0100_007F; // 127.0.0.1 big-endian
#[repr(C)] #[repr(C)]
@@ -19,6 +28,26 @@ struct SockaddrIn {
sin_zero: [u8; 8], sin_zero: [u8; 8],
} }
const AF_INET6: u16 = 23; // Windows AF_INET6 value (we run under the Win ABI in Wine)
/// Win32 `sockaddr_in6`. `sin6_port` is network byte order; `sin6_addr` is 16 raw
/// address bytes in network order. 28 bytes total.
#[repr(C)]
struct SockaddrIn6 {
sin6_family: u16,
sin6_port: u16,
sin6_flowinfo: u32,
sin6_addr: [u8; 16],
sin6_scope_id: u32,
}
/// IPv4-mapped IPv6 loopback: `::ffff:127.0.0.1`. An `AF_INET6` socket connecting to
/// this sends real IPv4 packets to 127.0.0.1, so the connection lands on the bridge's
/// 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];
// Address of ws2_32!connect (set at hook installation) // Address of ws2_32!connect (set at hook installation)
static CONNECT_ADDR: AtomicUsize = AtomicUsize::new(0); static CONNECT_ADDR: AtomicUsize = AtomicUsize::new(0);
@@ -27,18 +56,26 @@ static mut ORIGINAL_BYTES: [u8; 14] = [0u8; 14];
// For WSAConnect IAT fallback // For WSAConnect IAT fallback
type WsaConnectFn = unsafe extern "system" fn( type WsaConnectFn = unsafe extern "system" fn(
s: usize, name: *const u8, namelen: i32, s: usize,
caller: *const (), callee: *const (), name: *const u8,
sqos: *const (), gqos: *const ()) -> i32; namelen: i32,
caller: *const (),
callee: *const (),
sqos: *const (),
gqos: *const (),
) -> i32;
static REAL_WSA: OnceLock<WsaConnectFn> = OnceLock::new(); static REAL_WSA: OnceLock<WsaConnectFn> = 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) { unsafe fn write_hook(target: *mut u8, dest: u64) {
use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE}; use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE};
let mut old: u32 = 0; let mut old: u32 = 0;
VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old); VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old);
// FF 25 00 00 00 00 JMP [rip+0] // 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(2) as *mut u32).write(0u32);
(target.add(6) as *mut u64).write(dest); (target.add(6) as *mut u64).write(dest);
VirtualProtect(target as _, 14, old, &mut old); VirtualProtect(target as _, 14, old, &mut old);
@@ -48,42 +85,99 @@ unsafe fn restore_original(target: *mut u8) {
use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE}; use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE};
let mut old: u32 = 0; let mut old: u32 = 0;
VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old); 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); VirtualProtect(target as _, 14, old, &mut old);
} }
unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u8; 16], i32)> { /// If `name` is an EA-relevant connect target, return a rewritten sockaddr pointing at
if namelen < 8 { return None; } /// the local bridge (plus its byte length). Handles BOTH `AF_INET` and `AF_INET6`: the
/// game's Blaze/DirtySDK stack dials EA over IPv6 (v4-mapped) on :443, and the old
/// IPv4-only path let those slip straight past us to the real (dead) servers.
///
/// The returned buffer is 28 bytes (enough for a `sockaddr_in6`); the second value is
/// how many of those bytes are meaningful (16 for v4, 28 for v6). `pub(crate)` so the
/// ConnectEx path can share this one implementation.
pub(crate) unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u8; 28], i32)> {
if namelen < 8 || name.is_null() {
return None;
}
// The first u16 of any sockaddr is the address family.
let family = *(name as *const u16);
let mut buf = [0u8; 28];
match family {
AF_INET => {
// SAFE: family is AF_INET and namelen >= 8 == the sockaddr_in fields we read.
let sa = &*(name as *const SockaddrIn); let sa = &*(name as *const SockaddrIn);
if sa.sin_family != AF_INET { return None; }
let orig = sa.sin_addr.to_le_bytes();
let orig_port = u16::from_be(sa.sin_port);
let new_port_nbo = match sa.sin_port { 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_BLAZE_REDIRECTOR_NBO => PORT_BLAZE_REDIRECTOR_NBO, PORT_BLAZE_REDIRECTOR_NBO => PORT_BLAZE_REDIRECTOR_NBO,
PORT_BLAZE_MAIN_NBO => PORT_BLAZE_MAIN_NBO, PORT_BLAZE_MAIN_NBO => PORT_BLAZE_MAIN_NBO,
_ => return None, _ => 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!( crate::write_log(&format!(
"connect_hook: {}.{}.{}.{}:{} → 127.0.0.1:{}\n", "connect_hook: v4 {}.{}.{}.{}:{} → 127.0.0.1:{}\n",
orig[3], orig[2], orig[1], orig[0], orig_port, o[0],
o[1],
o[2],
o[3],
u16::from_be(sa.sin_port),
u16::from_be(new_port_nbo) u16::from_be(new_port_nbo)
)); ));
// SAFE: buf is 28 bytes, larger than the 16-byte sockaddr_in we write.
let mut buf = [0u8; 16];
let out = &mut *(buf.as_mut_ptr() as *mut SockaddrIn); let out = &mut *(buf.as_mut_ptr() as *mut SockaddrIn);
out.sin_family = AF_INET; out.sin_family = AF_INET;
out.sin_port = new_port_nbo; out.sin_port = new_port_nbo;
out.sin_addr = ADDR_LOOPBACK_NBO; out.sin_addr = ADDR_LOOPBACK_NBO;
Some((buf, 16)) Some((buf, 16))
} }
AF_INET6 => {
if namelen < 28 {
return None;
}
// SAFE: family is AF_INET6 and namelen >= 28 == sizeof(sockaddr_in6).
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_BLAZE_REDIRECTOR_NBO => PORT_BLAZE_REDIRECTOR_NBO,
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),
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_flowinfo = 0;
out.sin6_addr = V4MAPPED_LOOPBACK;
out.sin6_scope_id = 0;
Some((buf, 28))
}
_ => None,
}
}
pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen: i32) -> i32 { pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen: i32) -> i32 {
let addr = CONNECT_ADDR.load(Ordering::Relaxed) as *mut u8; let addr = CONNECT_ADDR.load(Ordering::Relaxed) as *mut u8;
// Milestone-0 transport watch (self-gates on OPENFUT_TRANSPORT_WATCH).
crate::transport_watch::note_connect("connect", name, namelen, s);
// Log every call so we can confirm the hook fires at all // Log every call so we can confirm the hook fires at all
if namelen >= 8 { if namelen >= 8 {
let sa = &*(name as *const SockaddrIn); let sa = &*(name as *const SockaddrIn);
@@ -95,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}; use windows_sys::Win32::Networking::WinSock::{getsockopt, SOL_SOCKET, SO_TYPE};
let mut ty: i32 = -1; let mut ty: i32 = -1;
let mut len: i32 = 4; 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 ty
}; };
crate::write_log(&format!( crate::write_log(&format!(
@@ -111,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) { let (call_name, call_len) = if let Some((buf, len)) = redirect_if_ea(name, namelen) {
restore_original(addr); restore_original(addr);
let r = { let r = {
let f: unsafe extern "system" fn(usize, *const u8, i32) -> i32 let f: unsafe extern "system" fn(usize, *const u8, i32) -> i32 =
= core::mem::transmute(addr); core::mem::transmute(addr);
f(s, buf.as_ptr(), len) f(s, buf.as_ptr(), len)
}; };
write_hook(addr, hooked_connect as u64); write_hook(addr, hooked_connect as *const () as u64);
return r; return r;
} else { } else {
(name, namelen) (name, namelen)
@@ -123,18 +223,19 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen:
restore_original(addr); restore_original(addr);
let r = { let r = {
let f: unsafe extern "system" fn(usize, *const u8, i32) -> i32 let f: unsafe extern "system" fn(usize, *const u8, i32) -> i32 = core::mem::transmute(addr);
= core::mem::transmute(addr);
f(s, call_name, call_len) 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 { if namelen >= 8 {
let sa = &*(call_name as *const SockaddrIn); let sa = &*(call_name as *const SockaddrIn);
if sa.sin_family == AF_INET { if sa.sin_family == AF_INET {
let err = if r != 0 { let err = if r != 0 {
use windows_sys::Win32::Networking::WinSock::WSAGetLastError; use windows_sys::Win32::Networking::WinSock::WSAGetLastError;
WSAGetLastError() WSAGetLastError()
} else { 0 }; } else {
0
};
crate::write_log(&format!("connect_hook: result={r} wsa_err={err}\n")); crate::write_log(&format!("connect_hook: result={r} wsa_err={err}\n"));
} }
} }
@@ -142,10 +243,16 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen:
} }
pub unsafe extern "system" fn hooked_wsa_connect( pub unsafe extern "system" fn hooked_wsa_connect(
s: usize, name: *const u8, namelen: i32, s: usize,
caller: *const (), callee: *const (), name: *const u8,
sqos: *const (), gqos: *const (), namelen: i32,
caller: *const (),
callee: *const (),
sqos: *const (),
gqos: *const (),
) -> i32 { ) -> i32 {
// Milestone-0 transport watch (self-gates on OPENFUT_TRANSPORT_WATCH).
crate::transport_watch::note_connect("WSAConnect", name, namelen, s);
let real = REAL_WSA.get().copied().unwrap(); let real = REAL_WSA.get().copied().unwrap();
if let Some((buf, len)) = redirect_if_ea(name, namelen) { if let Some((buf, len)) = redirect_if_ea(name, namelen) {
real(s, buf.as_ptr(), len, caller, callee, sqos, gqos) real(s, buf.as_ptr(), len, caller, callee, sqos, gqos)
@@ -159,17 +266,23 @@ pub unsafe fn install_inline_connect_hook() -> bool {
use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress}; use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress};
let ws2 = GetModuleHandleA(b"ws2_32.dll\0".as_ptr()); 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()) { let connect_fn = match GetProcAddress(ws2, b"connect\0".as_ptr()) {
Some(f) => f as *mut u8, Some(f) => f as *mut u8,
None => return false, None => return false,
}; };
// Save original 14 bytes // 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); CONNECT_ADDR.store(connect_fn as usize, Ordering::Relaxed);
// Overwrite first 14 bytes with absolute indirect JMP to our hook // 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 true
} }
+54 -63
View File
@@ -1,36 +1,22 @@
use core::ffi::c_void;
/// Intercepts ConnectEx (EA/DirtySDK's preferred async connect API). /// Intercepts ConnectEx (EA/DirtySDK's preferred async connect API).
/// ///
/// DirtySDK calls WSAIoctl(SIO_GET_EXTENSION_FUNCTION_POINTER, WSAID_CONNECTEX) once at /// 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 /// 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. /// inline so that when it returns a ConnectEx pointer we swap it for our own wrapper.
use core::sync::atomic::{AtomicUsize, Ordering}; use core::sync::atomic::{AtomicUsize, Ordering};
use core::ffi::c_void;
const AF_INET: u16 = 2; // Address rewriting (v4 + v6) is shared from connect_hook::redirect_if_ea, so the port
const PORT_HTTPS_NBO: u16 = 0xBB01; // 443 big-endian // constants and sockaddr structs no longer live here.
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
const ADDR_LOOPBACK_NBO: u32 = 0x0100_007F; // 127.0.0.1 big-endian
// SIO_GET_EXTENSION_FUNCTION_POINTER // SIO_GET_EXTENSION_FUNCTION_POINTER
const SIO_GET_EXT_FN: u32 = 0xC8000006; const SIO_GET_EXT_FN: u32 = 0xC8000006;
// WSAID_CONNECTEX = {25A207B9-DDF3-4660-8EE9-76E58C74063E} // WSAID_CONNECTEX = {25A207B9-DDF3-4660-8EE9-76E58C74063E}
const CONNECTEX_GUID: [u8; 16] = [ const CONNECTEX_GUID: [u8; 16] = [
0xB9, 0x07, 0xA2, 0x25, 0xB9, 0x07, 0xA2, 0x25, 0xF3, 0xDD, 0x60, 0x46, 0x8E, 0xE9, 0x76, 0xE5, 0x8C, 0x74, 0x06, 0x3E,
0xF3, 0xDD, 0x60, 0x46,
0x8E, 0xE9, 0x76, 0xE5, 0x8C, 0x74, 0x06, 0x3E,
]; ];
#[repr(C)]
struct SockaddrIn {
sin_family: u16,
sin_port: u16,
sin_addr: u32,
sin_zero: [u8; 8],
}
// The real ConnectEx pointer, saved after WSAIoctl returns it // The real ConnectEx pointer, saved after WSAIoctl returns it
static REAL_CONNECTEX: AtomicUsize = AtomicUsize::new(0); static REAL_CONNECTEX: AtomicUsize = AtomicUsize::new(0);
@@ -65,7 +51,8 @@ unsafe fn write_hook(target: *mut u8, dest: u64) {
use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE}; use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE};
let mut old: u32 = 0; let mut old: u32 = 0;
VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old); 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(2) as *mut u32).write(0u32);
(target.add(6) as *mut u64).write(dest); (target.add(6) as *mut u64).write(dest);
VirtualProtect(target as _, 14, old, &mut old); VirtualProtect(target as _, 14, old, &mut old);
@@ -75,7 +62,7 @@ unsafe fn restore_wsaioctl(target: *mut u8) {
use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE}; use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE};
let mut old: u32 = 0; let mut old: u32 = 0;
VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old); 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); VirtualProtect(target as _, 14, old, &mut old);
} }
@@ -91,33 +78,31 @@ unsafe extern "system" fn hooked_connectex(
) -> i32 { ) -> i32 {
let real_fn: ConnectExFn = core::mem::transmute(REAL_CONNECTEX.load(Ordering::Relaxed)); let real_fn: ConnectExFn = core::mem::transmute(REAL_CONNECTEX.load(Ordering::Relaxed));
if namelen >= 8 { // Milestone-0 transport watch (self-gates on OPENFUT_TRANSPORT_WATCH).
let sa = &*(name as *const SockaddrIn); crate::transport_watch::note_connect("ConnectEx", name, namelen, s);
if sa.sin_family == AF_INET {
let o = sa.sin_addr.to_le_bytes(); // Share the one redirect implementation (v4 + v6) with connect_hook, so ConnectEx
let orig_port = u16::from_be(sa.sin_port); // dials get the same IPv6 handling as plain connect().
let new_port_nbo = match sa.sin_port { if let Some((buf, len)) = crate::connect_hook::redirect_if_ea(name, namelen) {
PORT_HTTPS_NBO => PORT_BRIDGE_NBO, return real_fn(
PORT_BLAZE_REDIRECTOR_NBO => PORT_BLAZE_REDIRECTOR_NBO, s,
PORT_BLAZE_MAIN_NBO => PORT_BLAZE_MAIN_NBO, buf.as_ptr(),
_ => 0, len,
}; send_buf,
if new_port_nbo != 0 { send_data_len,
crate::write_log(&format!( bytes_sent,
"connectex_hook: {}.{}.{}.{}:{} → 127.0.0.1:{}\n", overlapped,
o[3], o[2], o[1], o[0], orig_port, );
u16::from_be(new_port_nbo)
));
let mut redirect = [0u8; 16];
let out = &mut *(redirect.as_mut_ptr() as *mut SockaddrIn);
out.sin_family = AF_INET;
out.sin_port = new_port_nbo;
out.sin_addr = ADDR_LOOPBACK_NBO;
return real_fn(s, redirect.as_ptr(), 16, send_buf, send_data_len, bytes_sent, overlapped);
} }
} real_fn(
} s,
real_fn(s, name, namelen, send_buf, send_data_len, bytes_sent, overlapped) name,
namelen,
send_buf,
send_data_len,
bytes_sent,
overlapped,
)
} }
/// Our WSAIoctl hook: when ConnectEx is requested, save the real pointer and return ours /// Our WSAIoctl hook: when ConnectEx is requested, save the real pointer and return ours
@@ -138,28 +123,28 @@ pub unsafe extern "system" fn hooked_wsaioctl(
restore_wsaioctl(addr); restore_wsaioctl(addr);
let result = { let result = {
let f: WsaIoctlFn = core::mem::transmute(addr); 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 this was a ConnectEx request that succeeded, swap the pointer
if result == 0 if result == 0 && code == SIO_GET_EXT_FN && in_len == 16 && !in_buf.is_null() {
&& 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); let guid = core::slice::from_raw_parts(in_buf as *const u8, 16);
if guid == CONNECTEX_GUID if guid == CONNECTEX_GUID && out_len >= 8 && !out_buf.is_null() {
&& out_len >= 8
&& !out_buf.is_null()
{
let out_ptr = out_buf as *mut usize; let out_ptr = out_buf as *mut usize;
let real_addr = *out_ptr; let real_addr = *out_ptr;
if REAL_CONNECTEX.compare_exchange(0, real_addr, Ordering::Relaxed, Ordering::Relaxed).is_ok() { if REAL_CONNECTEX
crate::write_log(&format!("connectex_hook: intercepted ConnectEx @ {real_addr:#x}\n")); .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 // Return our hook instead
*out_ptr = hooked_connectex as usize; *out_ptr = hooked_connectex as *const () as usize;
} }
} }
result result
@@ -168,13 +153,19 @@ pub unsafe extern "system" fn hooked_wsaioctl(
pub unsafe fn install_wsaioctl_hook() -> bool { pub unsafe fn install_wsaioctl_hook() -> bool {
use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress}; use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress};
let ws2 = GetModuleHandleA(b"ws2_32.dll\0".as_ptr()); 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()) { let fn_ptr = match GetProcAddress(ws2, b"WSAIoctl\0".as_ptr()) {
Some(f) => f as *mut u8, Some(f) => f as *mut u8,
None => return false, 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); 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 true
} }
+183
View File
@@ -0,0 +1,183 @@
//! Synthetic "notification" struct for the direct-call dial trigger.
//!
//! STATIC ARTIFACT ONLY — this module builds the byte layout the dial handler
//! (FIFA23.exe+0x4f4d360) expects in its `rdx` argument, plus a do-nothing
//! completion callback. It does NOT call the game, does NOT install any detour,
//! and is NOT wired into the hook yet. The invocation phase (later) consumes
//! `build_notification()` + `completion_stub`.
//!
//! Layout contract (from the 2026-07-03 dial-branch RE report on 0x144f4d590):
//! [+0x00] byte : entry gate — MUST be non-zero (else the error path fires). => 1
//! [+0x80] qword : completion delegate fn pointer. => &completion_stub
//! [+0x88] qword : delegate capture #1. => 0
//! [+0x90] qword : delegate capture #2. => 0
//! [+0xa0] dword : RpcJob key/priority (copied, never compared on dial path). => 0
//! everything else in [0x00..0x100] : 0
//! The RE confirmed no other offset in this range is read on the success path.
//! Total size 0x100 (256): the tail 0xa4..0x100 is zero padding — cheap insurance
//! against a read we might have missed. Any offset here is TODO/CONFIRM against the
//! RE report; if the game contradicts it at runtime, stop and re-verify.
// This module is deliberately unused for now (the invocation phase will call into
// it). Silence "never used" warnings until then rather than sprinkle #[allow] on
// each item. Remove this once the trigger wires the API up.
#![allow(dead_code)]
use core::sync::atomic::{AtomicU32, Ordering};
/// Size of the notification struct, in bytes. 0x100 = 256.
const NOTIFICATION_SIZE: usize = 0x100;
// --- field offsets (named so the code reads like the RE contract) -------------
const OFF_GATE: usize = 0x00; // byte, must be non-zero
const OFF_DELEGATE_FN: usize = 0x80; // qword, completion fn pointer
const OFF_DELEGATE_CAP1: usize = 0x88; // qword, capture (0)
const OFF_DELEGATE_CAP2: usize = 0x90; // qword, capture (0)
const OFF_KEY: usize = 0xa0; // dword, job key/priority (0)
/// Counts how many times `completion_stub` has been entered.
///
/// Why `AtomicU32` and not `static mut u32`: a `static mut` needs `unsafe` to
/// touch and, worse, gives *undefined behaviour* if two threads write it at once
/// (a data race). The completion callback may be invoked from an arbitrary game
/// thread, so a plain counter would race. `AtomicU32` makes increment a single
/// lock-free hardware instruction with well-defined concurrent semantics, and it
/// needs no `unsafe`. `Ordering::Relaxed` is enough here: we only care about the
/// count value, not about ordering it against other memory.
static COMPLETION_STUB_CALLS: AtomicU32 = AtomicU32::new(0);
/// The completion callback the game may invoke when the RpcJob finishes.
///
/// `extern "C"`: on the `x86_64-pc-windows-gnu` target this selects the Microsoft
/// x64 calling convention — exactly how the game invokes the pointer (`call r10`,
/// args in rcx/rdx/r8/r9, return in rax, caller cleans the stack). Matching the
/// convention is what makes it safe for the game to call us.
///
/// We declare four pointer-sized params and ignore them. The RE showed the delegate
/// is called with e.g. an HRESULT in `rdx` and a `this`-like pointer in `rcx`; the
/// success-path completion may pass different values. Because Win64 is caller-clean
/// and puts the first four integer args in registers, declaring four ignored args is
/// safe no matter what the caller actually passes — we simply never read them.
///
/// The body does the absolute minimum: bump the atomic counter and return 0. NO
/// logging, NO allocation, NO calls — a completion callback can fire from any game
/// context, and even a log write there could be unsafe. Observe from outside via
/// `completion_stub_call_count()` instead.
///
/// Returns `usize` = 0, which reads as an `S_OK`-shaped HRESULT if the caller looks
/// at the return value. (Returning void would be equally fine; 0 is a safe default.)
pub extern "C" fn completion_stub(_a: usize, _b: usize, _c: usize, _d: usize) -> usize {
// `fetch_add` is a single atomic read-modify-write (lock xadd) — no lock, no
// syscall, no allocation. Safe to call from any thread/context.
COMPLETION_STUB_CALLS.fetch_add(1, Ordering::Relaxed);
0
}
/// Read how many times `completion_stub` has fired. For an outside observer thread —
/// keeps all I/O out of the stub itself.
pub fn completion_stub_call_count() -> u32 {
COMPLETION_STUB_CALLS.load(Ordering::Relaxed)
}
/// Write a little-endian u64 into `buf` starting at `offset`.
///
/// Endianness matters because we're hand-laying a memory image the game will read
/// back as a raw pointer/integer. x86-64 is *little-endian*: the least-significant
/// byte sits at the lowest address. `value.to_le_bytes()` produces the 8 bytes in
/// exactly that order, so when the game does `mov rax,[ptr]` it reconstructs the
/// original `value`. Using the native byte order by hand (or `transmute`) would be
/// wrong on a big-endian machine; `to_le_bytes` states the intent explicitly.
///
/// `buf[offset..offset + 8]` is an 8-byte sub-slice; `copy_from_slice` copies the
/// 8-byte array into it. Both sides are length 8, so it can't panic here. (This is
/// the standard, safe way to poke a fixed-width integer into a `[u8]`.)
fn write_u64_le(buf: &mut [u8], offset: usize, value: u64) {
buf[offset..offset + 8].copy_from_slice(&value.to_le_bytes());
}
/// Write a little-endian u32 into `buf` starting at `offset`. (Same idea as
/// `write_u64_le`, 4 bytes wide.)
fn write_u32_le(buf: &mut [u8], offset: usize, value: u32) {
buf[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
}
/// Build the fully-populated notification struct, ready to be passed by pointer to
/// the dial handler as its `rdx` argument.
///
/// Returns a `[u8; 0x100]` by value. Why a byte array and not a `#[repr(C)]` struct:
/// the layout is a precise *offset* contract recovered by RE, with meaningful data
/// only at 0x00/0x80/0x88/0x90/0xa0 and zeros elsewhere. A byte array makes every
/// offset literally visible and immune to any field-ordering/padding surprise. A
/// `#[repr(C)] struct` with explicit padding fields would work too, but it's easier
/// to get a padding byte wrong than to index a flat array. (For future reference:
/// the `bytemuck` crate can safely reinterpret a `#[repr(C)]` struct as `&[u8]`
/// zero-copy — worth knowing, but overkill here and an extra dependency.)
pub fn build_notification() -> [u8; NOTIFICATION_SIZE] {
// Start fully zeroed. This already satisfies every "= 0" field (caps at +0x88/
// +0x90, the key at +0xa0, and all padding); we only need to set the non-zero
// fields below.
let mut buf = [0u8; NOTIFICATION_SIZE];
// [+0x00] entry gate: must be non-zero to reach the dial path.
buf[OFF_GATE] = 1;
// [+0x80] completion delegate function pointer = &completion_stub.
//
// `completion_stub as *const ()`: a *function item* in Rust is a zero-sized,
// unique type, not a value. Casting it to a raw pointer coerces it to a function
// pointer and then to an untyped code pointer `*const ()` — i.e. the address of
// the function's machine code. The intermediate `*const ()` before `as u64` is
// the idiomatic form: it says "treat this as an address" and also avoids the
// `clippy`/rustc "direct cast of function item into an integer" lint you'd get
// from `completion_stub as u64`.
let stub_addr = completion_stub as *const () as u64;
write_u64_le(&mut buf, OFF_DELEGATE_FN, stub_addr);
// [+0x88]/[+0x90] delegate captures = 0. Already zero from initialization; write
// them explicitly so the layout intent is visible at a glance.
write_u64_le(&mut buf, OFF_DELEGATE_CAP1, 0);
write_u64_le(&mut buf, OFF_DELEGATE_CAP2, 0);
// [+0xa0] RpcJob key/priority dword = 0 (copied, never compared on the dial path).
write_u32_le(&mut buf, OFF_KEY, 0);
buf
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn notification_layout() {
let n = build_notification();
// Total size is exactly 0x100.
assert_eq!(n.len(), NOTIFICATION_SIZE);
// [+0x00] gate byte == 1.
assert_eq!(n[0x00], 1);
// [+0xa0..0xa4] as u32 == 0.
// `try_into().unwrap()` turns the 4-byte slice into a `[u8; 4]` (it can only
// fail if the slice weren't length 4, which it is), and `from_le_bytes`
// reads it back the same little-endian way we wrote it.
let key = u32::from_le_bytes(n[0xa0..0xa4].try_into().unwrap());
assert_eq!(key, 0);
// [+0x80..0x88] as u64 == address of completion_stub.
let stub = u64::from_le_bytes(n[0x80..0x88].try_into().unwrap());
assert_eq!(stub, completion_stub as *const () as u64);
// [+0x88..0x90] and [+0x90..0x98] captures == 0.
assert_eq!(u64::from_le_bytes(n[0x88..0x90].try_into().unwrap()), 0);
assert_eq!(u64::from_le_bytes(n[0x90..0x98].try_into().unwrap()), 0);
}
#[test]
fn stub_counter_increments() {
let before = completion_stub_call_count();
let _ = completion_stub(0, 0, 0, 0);
assert_eq!(completion_stub_call_count(), before + 1);
}
}
+108
View File
@@ -0,0 +1,108 @@
//! FIFA 17 injection path (feature = "fifa17").
//!
//! This is a *separate, minimal* entry point from the FIFA-23 `install_hooks`.
//! FIFA 17 is a different game with different in-memory structures, so we run NONE
//! of the FIFA-23 connect/LSX/origin_spy/dial logic here — that would at best
//! no-op and at worst crash. For now this proves the version.dll hijack actually
//! loads us into FIFA17.exe and dumps the module map, which we need to locate
//! DirtySDK/ProtoSSL's cert-verify function (the next milestone: patch it so the
//! secure Blaze redirector's TLS handshake succeeds against our bridge cert).
//!
//! Everything here is read-only except the (not-yet-enabled) cert-verify patch.
use crate::write_log;
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, Module32FirstW, Module32NextW, MODULEENTRY32W, TH32CS_SNAPMODULE,
TH32CS_SNAPMODULE32,
};
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
/// Read the SizeOfImage from a module's in-memory PE headers.
unsafe fn size_of_image(base: usize) -> u32 {
if base == 0 {
return 0;
}
// DOS header -> e_lfanew (i32 @ 0x3c) -> PE header. SizeOfImage is in the
// optional header at offset 0x50 from the PE signature (same for PE32/PE32+).
let e_lfanew = *((base + 0x3c) as *const i32);
let pe = base + e_lfanew as usize;
// sanity: 'PE\0\0'
if *(pe as *const u32) != 0x0000_4550 {
return 0;
}
*((pe + 24 + 0x38) as *const u32) // opt header +0x38 = SizeOfImage
}
fn wide_to_string(w: &[u16]) -> String {
let end = w.iter().position(|&c| c == 0).unwrap_or(w.len());
String::from_utf16_lossy(&w[..end])
}
/// Enumerate loaded modules (name, base, size) via ToolHelp and log them.
unsafe fn dump_modules() {
let snap = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, 0);
if snap == INVALID_HANDLE_VALUE {
write_log("fifa17: module snapshot FAILED\n");
return;
}
let mut me: MODULEENTRY32W = core::mem::zeroed();
me.dwSize = core::mem::size_of::<MODULEENTRY32W>() as u32;
if Module32FirstW(snap, &mut me) != 0 {
loop {
let name = wide_to_string(&me.szModule);
let base = me.modBaseAddr as usize;
let size = me.modBaseSize;
write_log(&format!(
"fifa17: module {name:<28} base={base:#018x} size={size:#x}\n"
));
me.dwSize = core::mem::size_of::<MODULEENTRY32W>() as u32;
if Module32NextW(snap, &mut me) == 0 {
break;
}
}
} else {
write_log("fifa17: Module32FirstW FAILED\n");
}
CloseHandle(snap);
}
/// Worker that runs AFTER DllMain returns (loader lock released). ToolHelp and
/// other loader-touching calls are unsafe under the loader lock, so we defer them
/// to this thread. This is what fixed the "game exits right after DllMain" issue.
unsafe extern "system" fn worker(_: *mut core::ffi::c_void) -> u32 {
write_log("=== fifa17 hook: worker thread start ===\n");
let main_base = GetModuleHandleA(core::ptr::null()) as usize;
let img = size_of_image(main_base);
write_log(&format!(
"fifa17: main exe base={main_base:#018x} SizeOfImage={img:#x}\n"
));
dump_modules();
write_log("fifa17: worker complete (injection healthy)\n");
// SBC render intervention (inert unless OPENFUT_SBC_HOOK=1). Spawns its own deferred
// worker that waits for CardsDLL to load. See sbc_hook.rs / docs/sbc-hook-dll-spec.md.
crate::sbc_hook::install();
// Passive transaction tracing has a separate kill switch from cache resolution.
// It currently fails closed until safe relocating trampolines are proven.
crate::sbc_trace::install();
crate::sbc_request_trace::install();
0
}
/// Minimal FIFA-17 install. Keep DllMain itself trivial: only spawn a worker
/// thread and return immediately, so we never touch the loader lock from here.
pub unsafe fn install() {
use windows_sys::Win32::System::Threading::CreateThread;
write_log("=== fifa17 hook: DllMain ATTACH (spawning worker) ===\n");
let h = CreateThread(
core::ptr::null(),
0,
Some(worker),
core::ptr::null(),
0,
core::ptr::null_mut(),
);
if h == 0 as _ {
write_log("fifa17: CreateThread FAILED\n");
}
}
+12 -10
View File
@@ -1,19 +1,15 @@
use std::{ use std::{
ffi::CStr, ffi::CStr,
sync::{ sync::{
OnceLock,
atomic::{AtomicBool, Ordering}, 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( type GetaddrinfoFn =
*const u8, unsafe extern "system" fn(*const u8, *const u8, *const ADDRINFOA, *mut *mut ADDRINFOA) -> i32;
*const u8,
*const ADDRINFOA,
*mut *mut ADDRINFOA,
) -> i32;
static REAL: OnceLock<GetaddrinfoFn> = OnceLock::new(); static REAL: OnceLock<GetaddrinfoFn> = OnceLock::new();
static REDIRECT_IP: OnceLock<Vec<u8>> = OnceLock::new(); static REDIRECT_IP: OnceLock<Vec<u8>> = OnceLock::new();
@@ -54,15 +50,21 @@ pub unsafe extern "system" fn hooked_getaddrinfo(
if !node_name.is_null() { if !node_name.is_null() {
if let Ok(host) = CStr::from_ptr(node_name as *const i8).to_str() { if let Ok(host) = CStr::from_ptr(node_name as *const i8).to_str() {
crate::write_log(&format!("openfut_hook: getaddrinfo({host})\n")); crate::write_log(&format!("openfut_hook: getaddrinfo({host})\n"));
// Milestone-0 transport watch (self-gates on OPENFUT_TRANSPORT_WATCH).
crate::transport_watch::note_getaddrinfo(host);
if is_ea_host(host) { if is_ea_host(host) {
// Apply the ProtoSSL cert-verify bypass the first time we see an EA // Apply the ProtoSSL cert-verify bypass the first time we see an EA
// hostname — EAWebKit.dll must be loaded by now because it's calling us. // hostname — EAWebKit.dll must be loaded by now because it's calling us.
if !CERT_PATCHED.load(Ordering::Relaxed) { if !CERT_PATCHED.load(Ordering::Relaxed) {
if crate::ssl_patch::patch_eawebkit_cert_verify() { if crate::ssl_patch::patch_eawebkit_cert_verify() {
CERT_PATCHED.store(true, Ordering::Relaxed); 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 { } 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",
);
} }
} }
+12 -7
View File
@@ -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"). /// 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()); let module = GetModuleHandleA(module_name.as_ptr());
if module.is_null() { if module.is_null() {
return 0; 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) patch_module(module, original_fn, hook_fn)
} }
unsafe fn patch_module( unsafe fn patch_module(module: HMODULE, original_fn: *const (), hook_fn: *const ()) -> usize {
module: HMODULE,
original_fn: *const (),
hook_fn: *const (),
) -> usize {
if module.is_null() { if module.is_null() {
return 0; return 0;
} }
@@ -117,7 +117,12 @@ unsafe fn patch_module(
if val == original_fn as usize { if val == original_fn as usize {
let target = iat_slot.add(i) as *const std::ffi::c_void; let target = iat_slot.add(i) as *const std::ffi::c_void;
let mut old: u32 = 0; let mut old: u32 = 0;
VirtualProtect(target, std::mem::size_of::<usize>(), PAGE_EXECUTE_READWRITE, &mut old); VirtualProtect(
target,
std::mem::size_of::<usize>(),
PAGE_EXECUTE_READWRITE,
&mut old,
);
*iat_slot.add(i) = hook_fn as usize; *iat_slot.add(i) = hook_fn as usize;
VirtualProtect(target, std::mem::size_of::<usize>(), old, &mut old); VirtualProtect(target, std::mem::size_of::<usize>(), old, &mut old);
count += 1; count += 1;
+173 -32
View File
@@ -1,68 +1,177 @@
mod config; mod config;
mod connect_hook; mod connect_hook;
mod connectex_hook; mod connectex_hook;
mod dial_notification;
#[cfg(feature = "fifa17")]
mod fifa17;
mod hooks; mod hooks;
mod iat; mod iat;
mod origin_spy; mod origin_spy;
#[cfg(feature = "probe")]
mod probe;
#[cfg(feature = "capture_baseline")]
mod recv_hook;
#[cfg(feature = "fifa17")]
mod sbc_hook;
#[cfg(feature = "fifa17")]
mod sbc_request_trace;
#[cfg(feature = "fifa17")]
mod sbc_trace;
mod ssl_patch; mod ssl_patch;
mod tls_bypass; mod tls_bypass;
mod transport_watch;
mod version_proxy;
use windows_sys::Win32::{ use windows_sys::Win32::{
Foundation::{BOOL, HMODULE, TRUE}, Foundation::{BOOL, HMODULE, TRUE},
System::SystemServices::DLL_PROCESS_ATTACH,
Networking::WinSock::ADDRINFOA, Networking::WinSock::ADDRINFOA,
System::SystemServices::DLL_PROCESS_ATTACH,
}; };
pub(crate) fn write_log(msg: &str) { pub(crate) fn write_log(msg: &str) {
use std::io::Write; use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new() if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true).append(true) .create(true)
.append(true)
.open(r"C:\openfut_hook.log") .open(r"C:\openfut_hook.log")
{ let _ = f.write_all(msg.as_bytes()); } {
let _ = f.write_all(msg.as_bytes());
}
}
/// Force the log to stable storage. `write_log` already opens+closes the file per line,
/// so nothing is buffered *inside our process* (a process crash can't lose a written
/// line). `sync_all` additionally flushes the OS cache to disk, for durability even
/// across a full system crash. We call this right before the dial trigger's call so the
/// pre-call log line is guaranteed on disk if the call faults.
#[allow(dead_code)]
pub(crate) fn flush_log() {
if let Ok(f) = std::fs::OpenOptions::new()
.append(true)
.open(r"C:\openfut_hook.log")
{
let _ = f.sync_all();
}
} }
#[no_mangle] #[no_mangle]
pub unsafe extern "system" fn DllMain(module: HMODULE, reason: u32, _: *mut ()) -> BOOL { pub unsafe extern "system" fn DllMain(module: HMODULE, reason: u32, _: *mut ()) -> BOOL {
if reason == DLL_PROCESS_ATTACH { install_hooks(module); } if reason == DLL_PROCESS_ATTACH {
// VERSION forwarding must be ready before DllMain returns. Hook setup
// may be deferred, but a caller can use any proxy export immediately.
if version_proxy::resolve() {
install_hooks(module);
}
}
TRUE TRUE
} }
unsafe fn install_hooks(module: HMODULE) { unsafe fn install_hooks(module: HMODULE) {
// FIFA 17 path: run ONLY the minimal, FIFA-17-safe logic and skip every
// FIFA-23-specific hook below (they assume FIFA 23's memory layout).
#[cfg(feature = "fifa17")]
{
let _ = module;
fifa17::install();
return;
}
#[cfg(not(feature = "fifa17"))]
install_hooks_fifa23(module)
}
#[cfg(not(feature = "fifa17"))]
unsafe fn install_hooks_fifa23(module: HMODULE) {
write_log("openfut_hook: DllMain fired\n"); write_log("openfut_hook: DllMain fired\n");
// Milestone-0 transport watch: arm (or note disarmed) from env once, up front, so
// the getaddrinfo/connect/ConnectEx detours below can log Blaze-flavored activity.
transport_watch::arm_from_env();
let ip = config::read_redirect_ip(module); let ip = config::read_redirect_ip(module);
hooks::set_redirect_ip(ip); hooks::set_redirect_ip(ip);
let ga = iat::resolve(b"ws2_32.dll\0", b"getaddrinfo\0"); let ga = iat::resolve(b"ws2_32.dll\0", b"getaddrinfo\0");
if !ga.is_null() { if !ga.is_null() {
let f: unsafe extern "system" fn(*const u8,*const u8,*const ADDRINFOA,*mut *mut ADDRINFOA)->i32 let f: unsafe extern "system" fn(
= std::mem::transmute(ga); *const u8,
*const u8,
*const ADDRINFOA,
*mut *mut ADDRINFOA,
) -> i32 = std::mem::transmute(ga);
hooks::set_real(f); hooks::set_real(f);
let n = iat::patch_iat(ga, hooks::hooked_getaddrinfo as *const ()); let n = iat::patch_iat(ga, hooks::hooked_getaddrinfo as *const ());
let m = iat::patch_iat_in(b"EAWebKit.dll\0", ga, hooks::hooked_getaddrinfo as *const ()); let m = iat::patch_iat_in(
b"EAWebKit.dll\0",
ga,
hooks::hooked_getaddrinfo as *const (),
);
write_log(&format!("openfut_hook: getaddrinfo IAT patched {n}+{m}\n")); write_log(&format!("openfut_hook: getaddrinfo IAT patched {n}+{m}\n"));
} }
if ssl_patch::patch_main_exe_cert_verify() { write_log("ssl: main exe cert-verify patched\n"); } if ssl_patch::patch_main_exe_cert_verify() {
else { write_log("ssl: main exe cert-verify NOT FOUND\n"); } write_log("ssl: main exe cert-verify patched\n");
if ssl_patch::patch_eawebkit_cert_verify() { write_log("ssl: EAWebKit cert-verify patched\n"); } } else {
else { write_log("ssl: EAWebKit cert-verify deferred\n"); } write_log("ssl: main exe cert-verify NOT FOUND\n");
}
if ssl_patch::patch_eawebkit_cert_verify() {
write_log("ssl: EAWebKit cert-verify patched\n");
} else {
write_log("ssl: EAWebKit cert-verify deferred\n");
}
if connect_hook::install_inline_connect_hook() { write_log("connect: inline-hooked\n"); } if connect_hook::install_inline_connect_hook() {
else { write_log("connect: hook FAILED\n"); } write_log("connect: inline-hooked\n");
} else {
write_log("connect: hook FAILED\n");
}
let wp = iat::resolve(b"ws2_32.dll\0", b"WSAConnect\0"); let wp = iat::resolve(b"ws2_32.dll\0", b"WSAConnect\0");
if !wp.is_null() { if !wp.is_null() {
let f: unsafe extern "system" fn(usize,*const u8,i32,*const(),*const(),*const(),*const())->i32 let f: unsafe extern "system" fn(
= std::mem::transmute(wp); usize,
*const u8,
i32,
*const (),
*const (),
*const (),
*const (),
) -> i32 = std::mem::transmute(wp);
connect_hook::set_real_wsa_connect(f); connect_hook::set_real_wsa_connect(f);
iat::patch_iat(wp, connect_hook::hooked_wsa_connect as *const ()); iat::patch_iat(wp, connect_hook::hooked_wsa_connect as *const ());
write_log("connect: WSAConnect IAT patched\n"); write_log("connect: WSAConnect IAT patched\n");
} }
if connectex_hook::install_wsaioctl_hook() { write_log("connectex: WSAIoctl inline-hooked\n"); } if connectex_hook::install_wsaioctl_hook() {
else { write_log("connectex: WSAIoctl hook FAILED\n"); } 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 // 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. // 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 { macro_rules! hook_iat {
($dll:expr, $sym:expr, $setter:ident, $handler:expr, $ty:ty) => {{ ($dll:expr, $sym:expr, $setter:ident, $handler:expr, $ty:ty) => {{
@@ -72,31 +181,63 @@ unsafe fn install_hooks(module: HMODULE) {
origin_spy::$setter(f); origin_spy::$setter(f);
iat::patch_iat(ptr, $handler as *const ()); iat::patch_iat(ptr, $handler as *const ());
"ok" "ok"
} else { "miss" } } else {
"miss"
}
}}; }};
} }
let ra = hook_iat!(b"advapi32.dll\0", b"RegQueryValueExA\0", set_real_reg_a, let ra = hook_iat!(
b"advapi32.dll\0",
b"RegQueryValueExA\0",
set_real_reg_a,
origin_spy::hooked_reg_query_a, origin_spy::hooked_reg_query_a,
unsafe extern "system" fn(isize,*const u8,*mut u32,*mut u32,*mut u8,*mut u32)->i32); unsafe extern "system" fn(isize, *const u8, *mut u32, *mut u32, *mut u8, *mut u32) -> i32
let rw = hook_iat!(b"advapi32.dll\0", b"RegQueryValueExW\0", set_real_reg_w, );
let rw = hook_iat!(
b"advapi32.dll\0",
b"RegQueryValueExW\0",
set_real_reg_w,
origin_spy::hooked_reg_query_w, origin_spy::hooked_reg_query_w,
unsafe extern "system" fn(isize,*const u16,*mut u32,*mut u32,*mut u8,*mut u32)->i32); unsafe extern "system" fn(isize, *const u16, *mut u32, *mut u32, *mut u8, *mut u32) -> i32
let ma = hook_iat!(b"kernel32.dll\0", b"OpenMutexA\0", set_real_mutex_a, );
let ma = hook_iat!(
b"kernel32.dll\0",
b"OpenMutexA\0",
set_real_mutex_a,
origin_spy::hooked_open_mutex_a, origin_spy::hooked_open_mutex_a,
unsafe extern "system" fn(u32,i32,*const u8)->isize); unsafe extern "system" fn(u32, i32, *const u8) -> isize
let mw = hook_iat!(b"kernel32.dll\0", b"OpenMutexW\0", set_real_mutex_w, );
let mw = hook_iat!(
b"kernel32.dll\0",
b"OpenMutexW\0",
set_real_mutex_w,
origin_spy::hooked_open_mutex_w, origin_spy::hooked_open_mutex_w,
unsafe extern "system" fn(u32,i32,*const u16)->isize); unsafe extern "system" fn(u32, i32, *const u16) -> isize
write_log(&format!("origin_spy: RegA={ra} RegW={rw} MutexA={ma} MutexW={mw}\n")); );
write_log(&format!(
"origin_spy: RegA={ra} RegW={rw} MutexA={ma} MutexW={mw}\n"
));
let cv = iat::resolve(b"crypt32.dll\0", b"CertVerifyCertificateChainPolicy\0"); let cv = iat::resolve(b"crypt32.dll\0", b"CertVerifyCertificateChainPolicy\0");
if !cv.is_null() { if !cv.is_null() {
let f: unsafe extern "system" fn(*const u8,*const(),*const(),*mut u32)->BOOL let f: unsafe extern "system" fn(*const u8, *const (), *const (), *mut u32) -> BOOL =
= std::mem::transmute(cv); std::mem::transmute(cv);
tls_bypass::set_real(f); tls_bypass::set_real(f);
iat::patch_iat(cv, tls_bypass::hooked_cert_verify_chain_policy as *const ()); iat::patch_iat(cv, tls_bypass::hooked_cert_verify_chain_policy as *const ());
iat::patch_iat_in(b"EAWebKit.dll\0", cv, tls_bypass::hooked_cert_verify_chain_policy as *const ()); iat::patch_iat_in(
iat::patch_iat_in(b"winhttp.dll\0", cv, tls_bypass::hooked_cert_verify_chain_policy as *const ()); b"EAWebKit.dll\0",
iat::patch_iat_in(b"wininet.dll\0", cv, tls_bypass::hooked_cert_verify_chain_policy as *const ()); cv,
tls_bypass::hooked_cert_verify_chain_policy as *const (),
);
iat::patch_iat_in(
b"winhttp.dll\0",
cv,
tls_bypass::hooked_cert_verify_chain_policy as *const (),
);
iat::patch_iat_in(
b"wininet.dll\0",
cv,
tls_bypass::hooked_cert_verify_chain_policy as *const (),
);
} }
} }
+39 -14
View File
@@ -27,28 +27,49 @@ static REAL_REG_W: OnceLock<RegQueryValueExWFn> = OnceLock::new();
static REAL_MUTEX_A: OnceLock<OpenMutexAFn> = OnceLock::new(); static REAL_MUTEX_A: OnceLock<OpenMutexAFn> = OnceLock::new();
static REAL_MUTEX_W: OnceLock<OpenMutexWFn> = OnceLock::new(); static REAL_MUTEX_W: OnceLock<OpenMutexWFn> = OnceLock::new();
pub fn set_real_reg_a(f: RegQueryValueExAFn) { let _ = REAL_REG_A.set(f); } pub fn set_real_reg_a(f: RegQueryValueExAFn) {
pub fn set_real_reg_w(f: RegQueryValueExWFn) { let _ = REAL_REG_W.set(f); } let _ = REAL_REG_A.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_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 { 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) }; let bytes = unsafe { std::ffi::CStr::from_ptr(p as *const i8) };
bytes.to_string_lossy().into_owned() bytes.to_string_lossy().into_owned()
} }
fn wide_to_string(p: *const u16) -> String { 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; 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) }) String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(p, len) })
} }
fn is_interesting(name: &str) -> bool { fn is_interesting(name: &str) -> bool {
name.contains("LSX") || name.contains("Origin") || name.contains("EAL") || name.contains("LSX")
name.contains("Client") || name.contains("lsx") || name.contains("Port") || || name.contains("Origin")
name.contains("EA") || name.contains("Connection") || 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( 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 name = narrow_to_string(lpmutexname);
let real = REAL_MUTEX_A.get().copied().unwrap(); let real = REAL_MUTEX_A.get().copied().unwrap();
let handle = real(dwdesiredaccess, binherithandle, lpmutexname); let handle = real(dwdesiredaccess, binherithandle, lpmutexname);
crate::write_log(&format!("origin_spy: OpenMutexA({name}) → {}\n", crate::write_log(&format!(
if handle == 0 { "NOT_FOUND" } else { "FOUND" })); "origin_spy: OpenMutexA({name}) → {}\n",
if handle == 0 { "NOT_FOUND" } else { "FOUND" }
));
handle handle
} }
@@ -106,7 +129,9 @@ pub unsafe extern "system" fn hooked_open_mutex_w(
let name = wide_to_string(lpmutexname); let name = wide_to_string(lpmutexname);
let real = REAL_MUTEX_W.get().copied().unwrap(); let real = REAL_MUTEX_W.get().copied().unwrap();
let handle = real(dwdesiredaccess, binherithandle, lpmutexname); let handle = real(dwdesiredaccess, binherithandle, lpmutexname);
crate::write_log(&format!("origin_spy: OpenMutexW({name}) → {}\n", crate::write_log(&format!(
if handle == 0 { "NOT_FOUND" } else { "FOUND" })); "origin_spy: OpenMutexW({name}) → {}\n",
if handle == 0 { "NOT_FOUND" } else { "FOUND" }
));
handle handle
} }
File diff suppressed because it is too large Load Diff
+189 -64
View File
@@ -12,7 +12,8 @@ unsafe fn write_jmp(target: *mut u8, dest: u64) {
use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE}; use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE};
let mut old: u32 = 0; let mut old: u32 = 0;
VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old); 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(2) as *mut u32).write(0);
(target.add(6) as *mut u64).write(dest); (target.add(6) as *mut u64).write(dest);
VirtualProtect(target as _, 14, old, &mut old); VirtualProtect(target as _, 14, old, &mut old);
@@ -22,32 +23,48 @@ unsafe fn make_trampoline(orig: *mut u8, name: &str) -> Option<usize> {
use windows_sys::Win32::System::Memory::{ use windows_sys::Win32::System::Memory::{
VirtualAlloc, MEM_COMMIT, MEM_RESERVE, PAGE_EXECUTE_READWRITE, VirtualAlloc, MEM_COMMIT, MEM_RESERVE, PAGE_EXECUTE_READWRITE,
}; };
// Log prologue so we can diagnose if trampolines misbehave // Read enough prologue to walk instruction boundaries.
let bytes: [u8; 14] = core::array::from_fn(|i| *orig.add(i)); let probe: [u8; 24] = core::array::from_fn(|i| *orig.add(i));
let hex: String = bytes.iter().map(|b| format!("{b:02x} ")).collect(); let hex: String = probe[..14].iter().map(|b| format!("{b:02x} ")).collect();
crate::write_log(&format!("recv_hook: {name} prologue {hex}\n")); crate::write_log(&format!("recv_hook: {name} prologue {hex}\n"));
// Walk instruction boundaries to find relative branches. // Copy WHOLE instructions until we've covered >= 14 bytes (the size of the JMP
// Byte-by-byte scanning mis-identifies immediate operands (e.g. `sub rsp, 0x70`) // patch), so the trampoline never splits an instruction. Copying a fixed 14
// as jump opcodes, so we must parse properly. // bytes lands mid-instruction on these prologues and crashes on execution.
if has_rip_relative_branch(&bytes) { let mut copy_len = 0usize;
crate::write_log(&format!("recv_hook: {name} has relative branch in prologue, skipping trampoline\n")); 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; return None;
} }
copy_len += len;
}
let mem = VirtualAlloc( let mem = VirtualAlloc(
core::ptr::null_mut(), 32, core::ptr::null_mut(),
MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE, 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; let t = mem as *mut u8;
core::ptr::copy_nonoverlapping(orig, t, 14); core::ptr::copy_nonoverlapping(orig, t, copy_len);
// JMP [RIP+0] → orig+14 // JMP [RIP+0] → orig+copy_len (resume at the next whole instruction)
let cont = (orig as u64) + 14; let cont = (orig as u64) + copy_len as u64;
t.add(14).write(0xFF); t.add(15).write(0x25); t.add(copy_len).write(0xFF);
(t.add(16) as *mut u32).write(0); t.add(copy_len + 1).write(0x25);
(t.add(20) as *mut u64).write(cont); (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) Some(t as usize)
} }
@@ -58,8 +75,12 @@ fn has_rip_relative_branch(bytes: &[u8]) -> bool {
let mut pos = 0; let mut pos = 0;
while pos < bytes.len() { while pos < bytes.len() {
let (len, branch) = decode_instr_len(&bytes[pos..]); let (len, branch) = decode_instr_len(&bytes[pos..]);
if branch { return true; } if branch {
if len == 0 { break; } // unknown/truncated — stop safely return true;
}
if len == 0 {
break;
} // unknown/truncated — stop safely
pos += len; pos += len;
} }
false false
@@ -69,9 +90,29 @@ fn modrm_extra(modrm: u8) -> usize {
let md = (modrm >> 6) & 3; let md = (modrm >> 6) & 3;
let rm = modrm & 7; let rm = modrm & 7;
match md { match md {
0 => if rm == 5 { 4 } else if rm == 4 { 1 } else { 0 }, 0 => {
1 => if rm == 4 { 2 } else { 1 }, if rm == 5 {
2 => if rm == 4 { 5 } else { 4 }, 4
} else if rm == 4 {
1
} else {
0
}
}
1 => {
if rm == 4 {
2
} else {
1
}
}
2 => {
if rm == 4 {
5
} else {
4
}
}
_ => 0, _ => 0,
} }
} }
@@ -79,16 +120,31 @@ fn modrm_extra(modrm: u8) -> usize {
/// Returns (instruction_length_in_bytes, is_rip_relative_branch). /// Returns (instruction_length_in_bytes, is_rip_relative_branch).
/// Returns (0, false) for unknown/truncated. /// Returns (0, false) for unknown/truncated.
fn decode_instr_len(b: &[u8]) -> (usize, bool) { 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; let mut i = 0;
// Legacy prefixes // Legacy prefixes
while let Some(&p) = b.get(i) { 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 (404F) // REX prefix (404F)
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; i += 1;
match op { match op {
@@ -103,28 +159,45 @@ fn decode_instr_len(b: &[u8]) -> (usize, bool) {
0xE9 | 0xE8 => (i + 4, true), 0xE9 | 0xE8 => (i + 4, true),
// 0F prefix // 0F prefix
0x0F => { 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; i += 1;
if (0x80..=0x8F).contains(&op2) { return (i + 4, true); } // Jcc rel32 if (0x80..=0x8F).contains(&op2) {
return (i + 4, true);
} // Jcc rel32
// Most 0F XX: ModRM // Most 0F XX: ModRM
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), false) (i + 1 + modrm_extra(modrm), false)
} }
// Instructions with ModRM only (no immediate) // Instructions with ModRM only (no immediate)
0x85 | 0x87 | 0x88 | 0x89 | 0x8A | 0x8B | 0x8C | 0x8D | 0x8E | 0x8F | 0x85 | 0x87 | 0x88 | 0x89 | 0x8A | 0x8B | 0x8C | 0x8D | 0x8E | 0x8F | 0x01 | 0x03
0x01 | 0x03 | 0x09 | 0x0B | 0x11 | 0x13 | 0x21 | 0x23 | 0x29 | 0x2B | | 0x09 | 0x0B | 0x11 | 0x13 | 0x21 | 0x23 | 0x29 | 0x2B | 0x31 | 0x33 | 0x39 | 0x3B
0x31 | 0x33 | 0x39 | 0x3B | 0xD3 | 0xFF | 0xF7 => { | 0xD3 | 0xFF | 0xF7 => {
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), false) (i + 1 + modrm_extra(modrm), false)
} }
// ModRM + imm8 // ModRM + imm8
0x6B | 0x80 | 0x83 | 0xC0 | 0xC1 | 0xC6 => { 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) (i + 1 + modrm_extra(modrm) + 1, false)
} }
// ModRM + imm32 // ModRM + imm32
0x69 | 0x81 | 0xC7 => { 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) (i + 1 + modrm_extra(modrm) + 4, false)
} }
// MOV reg, imm8/imm32 // MOV reg, imm8/imm32
@@ -143,7 +216,9 @@ fn decode_instr_len(b: &[u8]) -> (usize, bool) {
unsafe fn get_fn(dll: &[u8], sym: &[u8]) -> Option<*mut u8> { unsafe fn get_fn(dll: &[u8], sym: &[u8]) -> Option<*mut u8> {
use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress}; use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress};
let h = GetModuleHandleA(dll.as_ptr()); 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) GetProcAddress(h, sym.as_ptr()).map(|f| f as *mut u8)
} }
@@ -151,46 +226,96 @@ unsafe fn get_fn(dll: &[u8], sym: &[u8]) -> Option<*mut u8> {
static RECV_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0); static RECV_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
pub unsafe extern "system" fn hooked_recv(s: usize, buf: *mut u8, len: i32, flags: i32) -> i32 { /// True if socket `s` is connected to the EA App LSX port (127.0.0.1:3216).
if crate::lsx::is_lsx(s) { /// Used in capture mode to tap only the LSX conversation.
return crate::lsx::on_recv(s, buf, len); 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;
} }
let t = RECV_TRAMPOLINE.load(Ordering::Relaxed); // sockaddr_in: sa_family (2 bytes) then sin_port (2 bytes, network order).
if t == 0 { return -1; } u16::from_be_bytes([sa[2], sa[3]]) == 3216
let f: unsafe extern "system" fn(usize, *mut u8, i32, i32) -> i32 = core::mem::transmute(t);
f(s, buf, len, flags)
} }
// 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 { 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") { match make_trampoline(ptr, "recv") {
Some(t) => { RECV_TRAMPOLINE.store(t, Ordering::Relaxed); } Some(t) => REAL_RECV.store(t, Ordering::Relaxed),
None => { crate::write_log("recv_hook: recv trampoline failed, hook skipped\n"); return false; } None => return false,
} }
write_jmp(ptr, hooked_recv as u64); write_jmp(ptr, hooked_recv as u64);
true 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 { 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") { match make_trampoline(ptr, "send") {
Some(t) => { SEND_TRAMPOLINE.store(t, Ordering::Relaxed); } Some(t) => REAL_SEND.store(t, Ordering::Relaxed),
None => { crate::write_log("recv_hook: send trampoline failed, hook skipped\n"); return false; } None => return false,
} }
write_jmp(ptr, hooked_send as u64); write_jmp(ptr, hooked_send as u64);
true 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)
}
+864
View File
@@ -0,0 +1,864 @@
//! FIFA 17 SBC render intervention (feature = "fifa17").
//!
//! Makes the FUT **SBC menu render real data** from inside the process. Full spec
//! (all addresses, RVA math, call order, crash risks, staged test plan):
//! fifa17-recon/docs/sbc-hook-dll-spec.md
//!
//! Everything here is **inert by default** and gated by env vars, so shipping the DLL
//! with this module compiled in changes nothing unless a var is set:
//! OPENFUT_SBC_HOOK=1 -> arm the deferred worker (resolve + log; READ-ONLY)
//! OPENFUT_SBC_ARM_ONLY=1 -> Tier-0 negative control: write BYTE[B+0x28]=1 (renders EMPTY)
//! OPENFUT_SBC_COMMIT=1 -> after proven native parse success, arm populated M
//! OPENFUT_SBC_POPULATE=1 -> legacy Tier-1 gate: BLOCKED (logs corrected trace gap, returns)
//!
//! CardsDLL_Win64_retail.dll is loaded lazily (only on entering Ultimate Team), so we
//! defer off the loader lock and poll for it — the same shape as
//! `probe::install_probes_deferred` polling for anadius64.dll.
//!
//! ── Address model (static VAs; PE image base 0x180000000) ────────────────────────
//! All values below are RVAs (VA_static - 0x180000000); live = cards_base + rva.
//! See the spec for the verified disassembly behind each one.
use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use windows_sys::Win32::System::Diagnostics::Debug::FlushInstructionCache;
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
use windows_sys::Win32::System::Memory::{
VirtualProtect, VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READ,
PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOACCESS, PAGE_READWRITE,
PAGE_WRITECOPY,
};
use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentThreadId};
// ── RVAs (verified byte-exact against /tmp/fut/cardsdll.dll this pass) ────────────
const IMAGE_BASE: usize = 0x180000000;
/// FNV prologue used as the slide-proof control (must match the on-disk PE bytes).
const CTRL_RVA: usize = 0x180d00; // VA 0x180180d00
const CTRL_BYTES: &[u8] = &[
0x48, 0x83, 0xec, 0x28, 0x48, 0x85, 0xc9, 0x74, 0x50, 0x45, 0x33, 0xc0,
];
const A_SLOT_RVA: usize = 0x2e6398; // *(0x1802e6398) = A (FUT root singleton)
const A_VTABLE_RVA: usize = 0x21c2a0;
const B_OFF: usize = 0x1f9d8; // B = A + 0x1f9d8 (SBC request/ready TTL cache)
const B_VTABLE_RVA: usize = 0x1fae70;
const B_READY_OFF: usize = 0x28; // B+0x28 ready byte (the isValid gate)
const B_COLL_OFF: usize = 0x08; // B+0x08 collection ptr (MUST stay 0 — see spec §4/C5)
const M_CACHE_OFF: usize = 0x20a68; // M = *(A + 0x20a68) (render source; per-session heap)
const M_COUNT_OFF: usize = 0x50; // WORD[M+0x50] category count
const SBC_CONTROLLER_VTABLE_RVA: usize = 0x20a820;
const SBC_CONTROLLER_EVENT_VTABLE_RVA: usize = 0x20a888;
const SBC_CONTROLLER_EVENT_SUBOBJECT_OFF: usize = 0x138;
const SBC_CONTROLLER_MODEL_OFF: usize = 0x140;
const SBC_COMPLETION_STATUS_JNE_RVA: usize = 0x0b8962;
const SBC_COMPLETION_STATUS_JNE: [u8; 2] = [0x75, 0x48];
const SBC_COMPLETION_STATUS_FALLTHROUGH: [u8; 2] = [0x90, 0x90];
const B_DTOR_RVA: usize = 0x63040;
const B_ISVALID_RVA: usize = 0x65d40;
const B_CLEAR_RVA: usize = 0x65d20;
const B_READY_EXPECTED_BEFORE_ARM: u8 = 0;
#[allow(dead_code)]
const AVT_M_GETTER: usize = 0x9b0; // A.vtable[+0x9b0] = 0x18011b7d0 (M lazy getter)
#[allow(dead_code)]
const AVT_B_GETTER: usize = 0x4e8; // A.vtable[+0x4e8] = 0x18011c1f0 (B getter thunk)
// Callable RVAs (for the Tier-1 populate sequence — see spec §6/§8). Kept for
// reference/wiring; not invoked while Tier-1 is blocked.
#[allow(dead_code)]
mod rva {
pub const M_LAZY_GETTER: usize = 0x11b7d0;
pub const ISVALID: usize = 0x65d40;
pub const DESER_SBS_SETS: usize = 0x17b2b0;
pub const SAX_CTX_INIT: usize = 0x1c63e0;
pub const REGISTRY_GETTER: usize = 0xd7170;
pub const MANAGER_GETTER: usize = 0x9c80;
pub const CLEAR_M: usize = 0x15f3a0;
pub const CAT_CTOR: usize = 0x159da0;
pub const CAT_DESER: usize = 0x17ab80;
pub const CAT_FINALIZE: usize = 0x160e50;
pub const APPEND: usize = 0x15a770;
pub const CAT_DTOR: usize = 0x1105d0;
pub const IDX_REBUILD_1: usize = 0x160e00;
pub const IDX_REBUILD_2: usize = 0x160f30;
pub const IDX_REBUILD_3: usize = 0x161020;
pub const REFRESH_DISPATCH: usize = 0x1a4a70; // Scaleform events 0x756c-0x7574
}
static ARMED: AtomicBool = AtomicBool::new(false);
static ARM_ONLY: AtomicBool = AtomicBool::new(false);
static COMMIT: AtomicBool = AtomicBool::new(false);
static POPULATE: AtomicBool = AtomicBool::new(false);
static DONE: AtomicBool = AtomicBool::new(false);
static CARDS_BASE: AtomicUsize = AtomicUsize::new(0);
static SBC_CONTROLLER: AtomicUsize = AtomicUsize::new(0);
static STATE: AtomicUsize = AtomicUsize::new(RuntimeState::Disabled as usize);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[repr(usize)]
enum RuntimeState {
Disabled,
Resolved,
Intercepted,
Parsed,
Validated,
Committed,
Failed,
}
fn valid_transition(from: RuntimeState, to: RuntimeState) -> bool {
matches!(
(from, to),
(RuntimeState::Disabled, RuntimeState::Resolved)
| (RuntimeState::Resolved, RuntimeState::Intercepted)
| (RuntimeState::Intercepted, RuntimeState::Parsed)
| (RuntimeState::Parsed, RuntimeState::Validated)
// Resolve-only/Tier-0 validates without installing an interceptor.
| (RuntimeState::Resolved, RuntimeState::Validated)
| (RuntimeState::Validated, RuntimeState::Committed)
| (_, RuntimeState::Failed)
)
}
fn transition(from: RuntimeState, to: RuntimeState) -> bool {
valid_transition(from, to)
&& STATE
.compare_exchange(
from as usize,
to as usize,
Ordering::AcqRel,
Ordering::Acquire,
)
.is_ok()
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ValidationError {
AddressOverflow,
AUnreadable,
AVtableMismatch,
AGetterMismatch,
BVtableMismatch,
BDtorMismatch,
BIsValidMismatch,
BClearMismatch,
MSlotUnreadable,
ReadyByteUnexpected,
CollectionUnreadable,
CollectionNotNull,
ReadyByteNotWritable,
ModelEmpty,
ControllerMissing,
ControllerVtableMismatch,
ControllerModelMismatch,
CompletionBranchMismatch,
CompletionBranchProtectFailed,
CompletionBranchFlushFailed,
}
#[derive(Clone, Copy, Debug)]
struct RuntimeSnapshot {
a: usize,
a_vtable: usize,
a_b_getter: usize,
b: usize,
b_vtable: usize,
b_dtor: usize,
b_isvalid: usize,
b_clear: usize,
b_ready: u8,
b_coll: usize,
m: usize,
}
fn expected_va(base: usize, rva: usize) -> Result<usize, ValidationError> {
base.checked_add(rva)
.ok_or(ValidationError::AddressOverflow)
}
fn validate_snapshot(base: usize, s: &RuntimeSnapshot) -> Result<(), ValidationError> {
if s.a == 0
|| s.b
!= s.a
.checked_add(B_OFF)
.ok_or(ValidationError::AddressOverflow)?
{
return Err(ValidationError::AUnreadable);
}
if s.a_vtable != expected_va(base, A_VTABLE_RVA)? {
return Err(ValidationError::AVtableMismatch);
}
if s.a_b_getter != expected_va(base, 0x11c1f0)? {
return Err(ValidationError::AGetterMismatch);
}
if s.b_vtable != expected_va(base, B_VTABLE_RVA)? {
return Err(ValidationError::BVtableMismatch);
}
if s.b_dtor != expected_va(base, B_DTOR_RVA)? {
return Err(ValidationError::BDtorMismatch);
}
if s.b_isvalid != expected_va(base, B_ISVALID_RVA)? {
return Err(ValidationError::BIsValidMismatch);
}
if s.b_clear != expected_va(base, B_CLEAR_RVA)? {
return Err(ValidationError::BClearMismatch);
}
if s.b_ready != B_READY_EXPECTED_BEFORE_ARM {
return Err(ValidationError::ReadyByteUnexpected);
}
if s.b_coll != 0 {
return Err(ValidationError::CollectionNotNull);
}
let _ = s.m; // The guarded snapshot read proves the M slot itself is readable.
Ok(())
}
/// Fault-safe pointer read (mirrors `probe::read_ptr`): returns None unless `ptr` lands
/// in a committed, readable page and the full 8 bytes fit inside the region.
unsafe fn read_ptr(ptr: usize) -> Option<usize> {
if ptr < 0x10000 || ptr & 7 != 0 {
return None;
}
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
let n = VirtualQuery(
ptr as _,
&mut mbi,
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
);
if n == 0 || mbi.State != MEM_COMMIT {
return None;
}
if mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 {
return None;
}
if ptr + 8 > mbi.BaseAddress as usize + mbi.RegionSize {
return None;
}
Some(core::ptr::read_volatile(ptr as *const usize))
}
/// Guarded byte read.
unsafe fn read_u8(ptr: usize) -> Option<u8> {
if ptr < 0x10000 {
return None;
}
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
let n = VirtualQuery(
ptr as _,
&mut mbi,
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
);
if n == 0 || mbi.State != MEM_COMMIT || mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 {
return None;
}
if ptr + 1 > mbi.BaseAddress as usize + mbi.RegionSize {
return None;
}
Some(core::ptr::read_volatile(ptr as *const u8))
}
/// A Tier-0 write is allowed only when the complete byte lies in a committed,
/// non-guarded region whose current protection explicitly permits writes.
unsafe fn writable_u8(ptr: usize) -> bool {
if ptr < 0x10000 {
return false;
}
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
let n = VirtualQuery(
ptr as _,
&mut mbi,
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
);
if n == 0 || mbi.State != MEM_COMMIT || mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 {
return false;
}
let protection = mbi.Protect & 0xff;
let writable = matches!(
protection,
PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY
);
writable
&& ptr
.checked_add(1)
.is_some_and(|end| end <= (mbi.BaseAddress as usize).saturating_add(mbi.RegionSize))
}
unsafe fn executable_range(ptr: usize, len: usize) -> bool {
let Some(end) = ptr.checked_add(len) else {
return false;
};
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
let n = VirtualQuery(
ptr as _,
&mut mbi,
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
);
if n == 0 || mbi.State != MEM_COMMIT || mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 {
return false;
}
let protection = mbi.Protect & 0xff;
matches!(
protection,
PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY
) && end <= (mbi.BaseAddress as usize).saturating_add(mbi.RegionSize)
}
/// Guarded 16-bit read (M category count is a WORD).
unsafe fn read_u16(ptr: usize) -> Option<u16> {
let lo = read_u8(ptr)? as u16;
let hi = read_u8(ptr + 1)? as u16;
Some(lo | (hi << 8))
}
/// Resolve CardsDLL's runtime base, or 0. Tries the exact loaded name; the ToolHelp
/// fallback (name-contains "CardsDLL") lives in the spec — add it if EA ever renames.
unsafe fn resolve_cards_base() -> usize {
let h = GetModuleHandleA(b"CardsDLL_Win64_retail.dll\0".as_ptr());
if !h.is_null() {
return h as usize;
}
// Also try the short form some tooling reports.
let h2 = GetModuleHandleA(b"CardsDLL.dll\0".as_ptr());
if !h2.is_null() {
return h2 as usize;
}
0
}
#[inline]
fn va(base: usize, rva: usize) -> usize {
base + rva
}
/// Prove the module didn't move: the FNV control prologue must match the on-disk PE.
unsafe fn control_matches(base: usize) -> bool {
let p = va(base, CTRL_RVA);
for (i, &want) in CTRL_BYTES.iter().enumerate() {
match read_u8(p + i) {
Some(got) if got == want => {}
_ => return false,
}
}
true
}
/// Take one guarded identity snapshot. A failure to read any identity-bearing field is
/// distinct from a value mismatch and aborts before mutation.
unsafe fn runtime_snapshot(base: usize) -> Result<RuntimeSnapshot, ValidationError> {
let a_slot = expected_va(base, A_SLOT_RVA)?;
let a = read_ptr(a_slot)
.filter(|&value| value != 0)
.ok_or(ValidationError::AUnreadable)?;
let a_vtable = read_ptr(a).ok_or(ValidationError::AVtableMismatch)?;
let a_b_getter = read_ptr(
a_vtable
.checked_add(AVT_B_GETTER)
.ok_or(ValidationError::AddressOverflow)?,
)
.ok_or(ValidationError::AGetterMismatch)?;
let b = a
.checked_add(B_OFF)
.ok_or(ValidationError::AddressOverflow)?;
let b_vtable = read_ptr(b).ok_or(ValidationError::BVtableMismatch)?;
let b_dtor = read_ptr(b_vtable).ok_or(ValidationError::BDtorMismatch)?;
let b_isvalid = read_ptr(
b_vtable
.checked_add(8)
.ok_or(ValidationError::AddressOverflow)?,
)
.ok_or(ValidationError::BIsValidMismatch)?;
let b_clear = read_ptr(
b_vtable
.checked_add(16)
.ok_or(ValidationError::AddressOverflow)?,
)
.ok_or(ValidationError::BClearMismatch)?;
let b_ready = read_u8(
b.checked_add(B_READY_OFF)
.ok_or(ValidationError::AddressOverflow)?,
)
.ok_or(ValidationError::ReadyByteUnexpected)?;
let b_coll = read_ptr(
b.checked_add(B_COLL_OFF)
.ok_or(ValidationError::AddressOverflow)?,
)
.ok_or(ValidationError::CollectionUnreadable)?;
let m = read_ptr(
a.checked_add(M_CACHE_OFF)
.ok_or(ValidationError::AddressOverflow)?,
)
.ok_or(ValidationError::MSlotUnreadable)?;
Ok(RuntimeSnapshot {
a,
a_vtable,
a_b_getter,
b,
b_vtable,
b_dtor,
b_isvalid,
b_clear,
b_ready,
b_coll,
m,
})
}
fn set_failed(error: ValidationError) {
STATE.store(RuntimeState::Failed as usize, Ordering::Release);
crate::write_log(&format!(
"SBC_HOOK: runtime validation FAILED: {error:?} -- no write\n"
));
}
/// Public entry: called from `fifa17::install`. Spawns the deferred worker if
/// OPENFUT_SBC_HOOK=1; otherwise logs "disabled" and returns (fully inert).
pub fn install() {
let armed = std::env::var("OPENFUT_SBC_HOOK")
.map(|v| v == "1")
.unwrap_or(false);
ARMED.store(armed, Ordering::Relaxed);
if !armed {
STATE.store(RuntimeState::Disabled as usize, Ordering::Relaxed);
crate::write_log("SBC_HOOK: disabled (set OPENFUT_SBC_HOOK=1 to enable)\n");
return;
}
ARM_ONLY.store(
std::env::var("OPENFUT_SBC_ARM_ONLY")
.map(|v| v == "1")
.unwrap_or(false),
Ordering::Relaxed,
);
COMMIT.store(
std::env::var("OPENFUT_SBC_COMMIT")
.map(|v| v == "1")
.unwrap_or(false),
Ordering::Relaxed,
);
POPULATE.store(
std::env::var("OPENFUT_SBC_POPULATE")
.map(|v| v == "1")
.unwrap_or(false),
Ordering::Relaxed,
);
crate::write_log("SBC_HOOK: ARMED (deferred worker spawning)\n");
std::thread::spawn(|| unsafe { worker() });
}
/// Records the concrete SBC controller observed registering FUT_SBS_CATEGORIES.
/// The registration hook is observational; all structural checks happen again on
/// the notifier thread before this address is trusted.
pub(crate) unsafe fn note_sbc_controller(controller: usize) {
let base = CARDS_BASE.load(Ordering::Acquire);
let valid = base != 0
&& read_ptr(controller) == base.checked_add(SBC_CONTROLLER_VTABLE_RVA)
&& controller
.checked_add(SBC_CONTROLLER_EVENT_SUBOBJECT_OFF)
.and_then(|p| read_ptr(p))
== base.checked_add(SBC_CONTROLLER_EVENT_VTABLE_RVA);
if valid {
SBC_CONTROLLER.store(controller, Ordering::Release);
crate::write_log(&format!(
"SBC_CONTROLLER_TRACE: captured controller={controller:#x}\n"
));
} else {
crate::write_log(&format!(
"SBC_CONTROLLER_TRACE: rejected controller={controller:#x} (vtable mismatch)\n"
));
}
}
unsafe fn log_controller_model(native_model: usize) {
let controller = SBC_CONTROLLER.load(Ordering::Acquire);
let controller_model = controller
.checked_add(SBC_CONTROLLER_MODEL_OFF)
.and_then(|p| read_ptr(p))
.unwrap_or(0);
let main_vtable = read_ptr(controller).unwrap_or(0);
let event_vtable = controller
.checked_add(SBC_CONTROLLER_EVENT_SUBOBJECT_OFF)
.and_then(|p| read_ptr(p))
.unwrap_or(0);
crate::write_log(&format!(
"SBC_CONTROLLER_TRACE: notifier controller={controller:#x} main_vt={main_vtable:#x} event_vt={event_vtable:#x} controller_M={controller_model:#x} parsed_M={native_model:#x} match={}\n",
controller != 0 && controller_model == native_model,
));
}
unsafe fn validated_sbc_controller(
base: usize,
native_model: usize,
) -> Result<usize, ValidationError> {
let controller = SBC_CONTROLLER.load(Ordering::Acquire);
if controller == 0 {
return Err(ValidationError::ControllerMissing);
}
if read_ptr(controller) != base.checked_add(SBC_CONTROLLER_VTABLE_RVA)
|| controller
.checked_add(SBC_CONTROLLER_EVENT_SUBOBJECT_OFF)
.and_then(|p| read_ptr(p))
!= base.checked_add(SBC_CONTROLLER_EVENT_VTABLE_RVA)
{
return Err(ValidationError::ControllerVtableMismatch);
}
if controller
.checked_add(SBC_CONTROLLER_MODEL_OFF)
.and_then(|p| read_ptr(p))
!= Some(native_model)
{
return Err(ValidationError::ControllerModelMismatch);
}
Ok(controller)
}
/// Route the already-scheduled category completion through CardsDLL's own success
/// branch. The original function first rejects a non-zero status with a two-byte
/// `jne ServerErrSets`; after a separately proven native parse, that status belongs
/// to the stale scheduler completion rather than the category HTTP transaction.
unsafe fn arm_native_completion_success(base: usize) -> Result<(), ValidationError> {
let target = base
.checked_add(SBC_COMPLETION_STATUS_JNE_RVA)
.ok_or(ValidationError::AddressOverflow)?;
if !executable_range(target, SBC_COMPLETION_STATUS_JNE.len())
|| core::slice::from_raw_parts(target as *const u8, SBC_COMPLETION_STATUS_JNE.len())
!= SBC_COMPLETION_STATUS_JNE
{
return Err(ValidationError::CompletionBranchMismatch);
}
let mut old = 0u32;
if VirtualProtect(
target as _,
SBC_COMPLETION_STATUS_FALLTHROUGH.len(),
PAGE_EXECUTE_READWRITE,
&mut old,
) == 0
{
return Err(ValidationError::CompletionBranchProtectFailed);
}
core::ptr::copy_nonoverlapping(
SBC_COMPLETION_STATUS_FALLTHROUGH.as_ptr(),
target as *mut u8,
SBC_COMPLETION_STATUS_FALLTHROUGH.len(),
);
let flushed = FlushInstructionCache(
GetCurrentProcess(),
target as _,
SBC_COMPLETION_STATUS_FALLTHROUGH.len(),
) != 0;
let mut ignored = 0u32;
let protected = VirtualProtect(
target as _,
SBC_COMPLETION_STATUS_FALLTHROUGH.len(),
old,
&mut ignored,
) != 0;
if !flushed || !protected {
return Err(ValidationError::CompletionBranchFlushFailed);
}
crate::write_log(&format!(
"SBC_HOOK: armed native completion success branch at {target:#x} tid={}\n",
GetCurrentThreadId(),
));
Ok(())
}
/// Commit the already-populated native SBC model after the category success notifier.
///
/// This is called synchronously by the passive notifier wrapper *after* the original
/// notifier returns. It never invokes a parser or constructs game objects. The only
/// mutation is the established cache-ready byte, and only when the normal parser has
/// produced at least one category and every pointer/vtable invariant still matches.
pub(crate) unsafe fn commit_after_native_parse() {
if !COMMIT.load(Ordering::Acquire) {
return;
}
let base = CARDS_BASE.load(Ordering::Acquire);
if base == 0 || !control_matches(base) {
set_failed(ValidationError::AUnreadable);
return;
}
let snapshot = match runtime_snapshot(base).and_then(|snapshot| {
validate_snapshot(base, &snapshot)?;
if snapshot.m == 0
|| read_u16(snapshot.m + M_COUNT_OFF)
.filter(|&count| count > 0)
.is_none()
{
return Err(ValidationError::ModelEmpty);
}
if !writable_u8(snapshot.b + B_READY_OFF) {
return Err(ValidationError::ReadyByteNotWritable);
}
Ok(snapshot)
}) {
Ok(snapshot) => snapshot,
Err(error) => {
set_failed(error);
return;
}
};
let count = read_u16(snapshot.m + M_COUNT_OFF).unwrap_or(0);
log_controller_model(snapshot.m);
if DONE.swap(true, Ordering::AcqRel) {
return;
}
crate::write_log(&format!(
"SBC_HOOK: post-parse commit -> M={:#x} categories={} BYTE[{:#x}]=1\n",
snapshot.m,
count,
snapshot.b + B_READY_OFF,
));
core::ptr::write_volatile((snapshot.b + B_READY_OFF) as *mut u8, 1);
if read_u8(snapshot.b + B_READY_OFF) != Some(1)
|| !transition(RuntimeState::Validated, RuntimeState::Committed)
{
set_failed(ValidationError::ReadyByteUnexpected);
return;
}
let _controller = match validated_sbc_controller(base, snapshot.m) {
Ok(controller) => controller,
Err(error) => {
set_failed(error);
return;
}
};
if let Err(error) = arm_native_completion_success(base) {
set_failed(error);
return;
}
crate::write_log(
"SBC_HOOK: post-parse commit DONE; awaiting CardsDLL native completion events\n",
);
}
/// Deferred worker: waits (up to ~5 min) for CardsDLL to load — it only appears when
/// the user enters Ultimate Team — then runs the resolve/log (+ optional Tier-0 arm)
/// exactly once.
unsafe fn worker() {
let mut base = 0usize;
for _ in 0..600u32 {
base = resolve_cards_base();
if base != 0 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
if base == 0 {
crate::write_log("SBC_HOOK: CardsDLL_Win64_retail.dll never loaded — giving up\n");
return;
}
CARDS_BASE.store(base, Ordering::Relaxed);
let slide = base.wrapping_sub(IMAGE_BASE);
let ctrl_ok = control_matches(base);
crate::write_log(&format!(
"SBC_HOOK: CardsDLL base={base:#x} slide={slide:#x} CONTROL={}\n",
if ctrl_ok { "OK" } else { "MISMATCH-ABORT" }
));
if !ctrl_ok {
STATE.store(RuntimeState::Failed as usize, Ordering::Release);
return; // module map moved -> offsets untrustworthy (spec §1)
}
if !transition(RuntimeState::Disabled, RuntimeState::Resolved) {
crate::write_log("SBC_HOOK: invalid state transition to Resolved -- no write\n");
STATE.store(RuntimeState::Failed as usize, Ordering::Release);
return;
}
// Resolve and validate A -> B, M. The vtable method checks make it substantially
// harder for a coincidental heap pointer to pass after a binary/layout mismatch.
let snapshot = match runtime_snapshot(base).and_then(|snapshot| {
validate_snapshot(base, &snapshot)?;
Ok(snapshot)
}) {
Ok(snapshot) => snapshot,
Err(error) => {
set_failed(error);
return;
}
};
if !transition(RuntimeState::Resolved, RuntimeState::Validated) {
crate::write_log("SBC_HOOK: invalid state transition to Validated -- no write\n");
STATE.store(RuntimeState::Failed as usize, Ordering::Release);
return;
}
let a = snapshot.a;
let b = snapshot.b;
let m = snapshot.m;
let m_count = (m != 0).then(|| read_u16(m + M_COUNT_OFF)).flatten();
crate::write_log(&format!(
"SBC_HOOK: A={a:#x} B={b:#x} B+0x28(ready)={:?} B+0x08(coll)={:?} M=*(A+0x20a68)={:?} WORD[M+0x50]={:?}\n",
Some(snapshot.b_ready), opt_hex(Some(snapshot.b_coll)), opt_hex(Some(m)), m_count,
));
// Tier-0 — arm-only negative control. Write ONLY BYTE[B+0x28]=1; leave B+0x08=0 so
// isValid takes the short-circuit (spec §4). Renders the menu EMPTY (M null/empty) —
// this is the baseline, NOT the fix. One-shot.
if ARM_ONLY.load(Ordering::Relaxed) {
if DONE.swap(true, Ordering::Relaxed) {
return;
}
// Re-snapshot immediately before mutation to reduce the time-of-check/time-of-use
// window. In particular, the exact patch byte must still be 0 and B+0x08 null.
let write_snapshot = match runtime_snapshot(base).and_then(|snapshot| {
validate_snapshot(base, &snapshot)?;
if !writable_u8(snapshot.b + B_READY_OFF) {
return Err(ValidationError::ReadyByteNotWritable);
}
Ok(snapshot)
}) {
Ok(snapshot) => snapshot,
Err(error) => {
set_failed(error);
return;
}
};
crate::write_log(&format!(
"SBC_HOOK: Tier-0 arm-only -> writing BYTE[{:#x}]=1 (expect EMPTY render, no modal)\n",
write_snapshot.b + B_READY_OFF
));
core::ptr::write_volatile((write_snapshot.b + B_READY_OFF) as *mut u8, 1u8);
match read_u8(write_snapshot.b + B_READY_OFF) {
Some(1) if transition(RuntimeState::Validated, RuntimeState::Committed) => {}
_ => {
set_failed(ValidationError::ReadyByteUnexpected);
return;
}
}
crate::write_log(
"SBC_HOOK: Tier-0 arm-only DONE (open the SBC menu; ~2 placeholder tiles expected)\n",
);
return;
}
// Legacy Tier-1 gate — deliberately blocked. The fresh live exchange proves FIFA
// already owns a real response and SAX reader for /sbs/sets. The next milestone is
// passive tracing of the native response-to-deserializer dispatch, not construction
// of a reader. Cold-calling with a fabricated reader would CLEAR M and/or segfault.
if POPULATE.load(Ordering::Relaxed) {
crate::write_log(
"SBC_HOOK: legacy Tier-1 populate is BLOCKED — capture the genuine response \
and reader at the native dispatch boundary first (see client-hook plan M3/M4). \
No deser call made; fabricated readers can clear M or crash.\n",
);
}
}
fn opt_hex(o: Option<usize>) -> String {
match o {
Some(v) => format!("{v:#x}"),
None => "<unreadable>".to_string(),
}
}
/// Legacy Tier-1 scaffold. **Never call this with a fabricated reader.** The intended
/// implementation is now a guarded synchronous dispatch repair that borrows the genuine
/// response and reader from the real HTTP transaction on its native thread.
///
/// Sequence once `reader` (a primed SAX reader over canned sbs/sets JSON) exists:
/// let base = CARDS_BASE.load(Relaxed);
/// let deser: unsafe extern "system" fn(*mut u8, *mut u8) -> bool =
/// transmute(va(base, rva::DESER_SBS_SETS));
/// deser(core::ptr::null_mut(), reader); // self-locates mgr, clears+appends+finalizes+commits M
/// // then Tier-0 arm: BYTE[B+0x28]=1, leave B+0x08=0
/// // then refresh so 0x1800b5eda re-reads WORD[M+0x50]
#[allow(dead_code)]
unsafe fn populate_m(_reader: *mut u8) {
// Intentionally unimplemented: the passive trace must prove the response/reader
// ownership and exact virtual-dispatch boundary before any parser call is enabled.
unreachable!(
"populate_m requires a proven native dispatch contract; see client-hook plan M3/M4"
);
}
#[cfg(test)]
mod tests {
use super::*;
fn valid_snapshot(base: usize) -> RuntimeSnapshot {
let a = 0x1000_0000usize;
RuntimeSnapshot {
a,
a_vtable: base + A_VTABLE_RVA,
a_b_getter: base + 0x11c1f0,
b: a + B_OFF,
b_vtable: base + B_VTABLE_RVA,
b_dtor: base + B_DTOR_RVA,
b_isvalid: base + B_ISVALID_RVA,
b_clear: base + B_CLEAR_RVA,
b_ready: B_READY_EXPECTED_BEFORE_ARM,
b_coll: 0,
m: 0,
}
}
#[test]
fn accepts_exact_runtime_identity_with_null_uninitialized_m() {
let base = 0x7fff_0000_0000usize;
assert_eq!(validate_snapshot(base, &valid_snapshot(base)), Ok(()));
}
#[test]
fn rejects_wrong_a_or_b_class_identity() {
let base = 0x7fff_0000_0000usize;
let mut snapshot = valid_snapshot(base);
snapshot.a_vtable += 8;
assert_eq!(
validate_snapshot(base, &snapshot),
Err(ValidationError::AVtableMismatch)
);
let mut snapshot = valid_snapshot(base);
snapshot.b_vtable += 8;
assert_eq!(
validate_snapshot(base, &snapshot),
Err(ValidationError::BVtableMismatch)
);
}
#[test]
fn rejects_changed_patch_byte_or_live_collection() {
let base = 0x7fff_0000_0000usize;
let mut snapshot = valid_snapshot(base);
snapshot.b_ready = 1;
assert_eq!(
validate_snapshot(base, &snapshot),
Err(ValidationError::ReadyByteUnexpected)
);
let mut snapshot = valid_snapshot(base);
snapshot.b_coll = 0x1234_0000;
assert_eq!(
validate_snapshot(base, &snapshot),
Err(ValidationError::CollectionNotNull)
);
}
#[test]
fn state_machine_is_forward_only_and_fail_closed() {
assert!(valid_transition(
RuntimeState::Disabled,
RuntimeState::Resolved
));
assert!(valid_transition(
RuntimeState::Resolved,
RuntimeState::Validated
));
assert!(valid_transition(
RuntimeState::Validated,
RuntimeState::Committed
));
assert!(valid_transition(RuntimeState::Parsed, RuntimeState::Failed));
assert!(!valid_transition(
RuntimeState::Validated,
RuntimeState::Resolved
));
assert!(!valid_transition(
RuntimeState::Failed,
RuntimeState::Resolved
));
assert!(!valid_transition(
RuntimeState::Resolved,
RuntimeState::Committed
));
}
}
+478
View File
@@ -0,0 +1,478 @@
//! Optional category-request callback tracing through its class-unique vtable.
//!
//! Unlike the entry trampolines, these probes atomically replace two aligned
//! pointer slots. The branchy callback dispatcher at 0x180154830 is never patched.
use core::ffi::c_void;
use core::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use windows_sys::Win32::System::LibraryLoader::{
GetModuleHandleA, GetModuleHandleExA, GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS,
GET_MODULE_HANDLE_EX_FLAG_PIN,
};
use windows_sys::Win32::System::Memory::{
VirtualProtect, VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_GUARD, PAGE_NOACCESS,
PAGE_READWRITE,
};
use windows_sys::Win32::System::Threading::GetCurrentThreadId;
const REQUEST_VTABLE_RVA: usize = 0x22e5c0;
const SLOT_88: usize = 0x88;
const SLOT_90: usize = 0x90;
const ORIGINAL_88_RVA: usize = 0x1631e0;
const ORIGINAL_90_RVA: usize = 0x154830;
const ORIGINAL_88_SIGNATURE: [u8; 16] = [
0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x74, 0x24, 0x10, 0x57, 0x48, 0x83, 0xec, 0x20, 0x48,
];
const ORIGINAL_90_SIGNATURE: [u8; 16] = [
0x4c, 0x8b, 0x81, 0x90, 0x00, 0x00, 0x00, 0x4d, 0x85, 0xc0, 0x74, 0x0a, 0x48, 0x81, 0xc1, 0x90,
];
type Callback88 = unsafe extern "system" fn(*mut c_void, *mut c_void);
type Callback90 = unsafe extern "system" fn(*mut c_void, *mut c_void);
static ENABLED: AtomicBool = AtomicBool::new(false);
static INSTALLED: AtomicBool = AtomicBool::new(false);
static ORIGINAL_88: AtomicUsize = AtomicUsize::new(0);
static ORIGINAL_90: AtomicUsize = AtomicUsize::new(0);
static ENTER_88: AtomicU64 = AtomicU64::new(0);
static EXIT_88: AtomicU64 = AtomicU64::new(0);
static ENTER_90: AtomicU64 = AtomicU64::new(0);
static EXIT_90: AtomicU64 = AtomicU64::new(0);
static LAST_REQUEST_88: AtomicUsize = AtomicUsize::new(0);
static LAST_ARGUMENT_88: AtomicUsize = AtomicUsize::new(0);
static LAST_THREAD_88: AtomicUsize = AtomicUsize::new(0);
static LAST_REQUEST_90: AtomicUsize = AtomicUsize::new(0);
static LAST_ARGUMENT_90: AtomicUsize = AtomicUsize::new(0);
static LAST_THREAD_90: AtomicUsize = AtomicUsize::new(0);
static CALLBACK_90: AtomicUsize = AtomicUsize::new(0);
static CALLBACK_98: AtomicUsize = AtomicUsize::new(0);
static CALLBACK_A0: AtomicUsize = AtomicUsize::new(0);
static CALLBACK_A8: AtomicUsize = AtomicUsize::new(0);
static SELECTED_90: AtomicUsize = AtomicUsize::new(0);
static OWNER_88: AtomicUsize = AtomicUsize::new(0);
static OWNER_VTABLE_88: AtomicUsize = AtomicUsize::new(0);
static CONSUMER_88: AtomicUsize = AtomicUsize::new(0);
static RESPONSE_VTABLE_88: AtomicUsize = AtomicUsize::new(0);
static OWNER_SLOT_BEFORE_88: AtomicUsize = AtomicUsize::new(0);
static OWNER_SLOT_AFTER_88: AtomicUsize = AtomicUsize::new(0);
static OWNER_INNER_88: AtomicUsize = AtomicUsize::new(0);
static OWNER_STATE_BEFORE_88: AtomicUsize = AtomicUsize::new(usize::MAX);
static OWNER_STATE_AFTER_88: AtomicUsize = AtomicUsize::new(usize::MAX);
static OWNER_FLAGS_88: AtomicUsize = AtomicUsize::new(usize::MAX);
static OWNER_MANAGER_88: AtomicUsize = AtomicUsize::new(0);
static OWNER_MANAGER_STATE_88: AtomicUsize = AtomicUsize::new(usize::MAX);
fn enabled(value: Option<&str>) -> bool {
matches!(value, Some("1"))
}
fn checked_va(base: usize, rva: usize) -> Option<usize> {
base.checked_add(rva)
}
fn image_range_covered(size: usize, rva: usize, length: usize) -> bool {
rva.checked_add(length)
.map(|end| end <= size)
.unwrap_or(false)
}
unsafe fn readable_range(address: usize, length: usize) -> bool {
let Some(end) = address.checked_add(length) else {
return false;
};
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
VirtualQuery(
address as _,
&mut mbi,
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
) != 0
&& mbi.State == MEM_COMMIT
&& mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS) == 0
&& end <= mbi.BaseAddress as usize + mbi.RegionSize
}
unsafe fn range_in_image_allocation(base: usize, address: usize, length: usize) -> bool {
let Some(end) = address.checked_add(length) else {
return false;
};
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
VirtualQuery(
address as _,
&mut mbi,
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
) != 0
&& mbi.AllocationBase as usize == base
&& mbi.State == MEM_COMMIT
&& mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS) == 0
&& end <= mbi.BaseAddress as usize + mbi.RegionSize
}
unsafe fn image_size(base: usize) -> Option<usize> {
if !readable_range(base, 0x1000) || *(base as *const u16) != 0x5a4d {
return None;
}
let pe_offset = *((base + 0x3c) as *const u32) as usize;
if pe_offset > 0xf00 {
return None;
}
let pe = base.checked_add(pe_offset)?;
if *(pe as *const u32) != 0x0000_4550 {
return None;
}
let size_field = pe.checked_add(24 + 0x38)?;
Some(*(size_field as *const u32) as usize)
}
unsafe fn signature_matches(address: usize, expected: &[u8]) -> bool {
readable_range(address, expected.len())
&& core::slice::from_raw_parts(address as *const u8, expected.len()) == expected
}
unsafe fn guarded_ptr(address: usize) -> usize {
if address & 7 == 0 && readable_range(address, 8) {
core::ptr::read_volatile(address as *const usize)
} else {
0
}
}
unsafe fn guarded_u32(address: usize) -> Option<u32> {
if readable_range(address, 4) {
Some(core::ptr::read_volatile(address as *const u32))
} else {
None
}
}
unsafe fn guarded_u8(address: usize) -> Option<u8> {
if readable_range(address, 1) {
Some(core::ptr::read_volatile(address as *const u8))
} else {
None
}
}
unsafe fn field_ptr(object: usize, offset: usize) -> usize {
object
.checked_add(offset)
.map(|address| guarded_ptr(address))
.unwrap_or(0)
}
unsafe extern "system" fn wrapper_88(request: *mut c_void, argument: *mut c_void) {
ENTER_88.fetch_add(1, Ordering::Relaxed);
LAST_REQUEST_88.store(request as usize, Ordering::Relaxed);
LAST_ARGUMENT_88.store(argument as usize, Ordering::Relaxed);
LAST_THREAD_88.store(GetCurrentThreadId() as usize, Ordering::Relaxed);
let request_address = request as usize;
let argument_address = argument as usize;
let owner = field_ptr(request_address, 8);
let owner_vtable = guarded_ptr(owner);
let consumer = field_ptr(owner_vtable, 0x18);
let owner_slot_before = guarded_ptr(argument_address);
let response_vtable = guarded_ptr(owner_slot_before);
let owner_inner = field_ptr(owner, 8);
let owner_state_before = owner_inner
.checked_add(8)
.and_then(|p| guarded_u32(p))
.map(|v| v as usize)
.unwrap_or(usize::MAX);
let owner_flags = owner_inner
.checked_add(0x0c)
.and_then(|p| guarded_u8(p))
.map(|v| v as usize)
.unwrap_or(usize::MAX);
let owner_manager = field_ptr(owner_inner, 0x14d0);
let owner_manager_state = owner_manager
.checked_add(0x1dc0)
.and_then(|p| guarded_u32(p))
.map(|v| v as usize)
.unwrap_or(usize::MAX);
OWNER_88.store(owner, Ordering::Relaxed);
OWNER_VTABLE_88.store(owner_vtable, Ordering::Relaxed);
CONSUMER_88.store(consumer, Ordering::Relaxed);
RESPONSE_VTABLE_88.store(response_vtable, Ordering::Relaxed);
OWNER_SLOT_BEFORE_88.store(owner_slot_before, Ordering::Relaxed);
OWNER_INNER_88.store(owner_inner, Ordering::Relaxed);
OWNER_STATE_BEFORE_88.store(owner_state_before, Ordering::Relaxed);
OWNER_FLAGS_88.store(owner_flags, Ordering::Relaxed);
OWNER_MANAGER_88.store(owner_manager, Ordering::Relaxed);
OWNER_MANAGER_STATE_88.store(owner_manager_state, Ordering::Relaxed);
let original: Callback88 = core::mem::transmute(ORIGINAL_88.load(Ordering::Acquire));
original(request, argument);
OWNER_SLOT_AFTER_88.store(guarded_ptr(argument_address), Ordering::Relaxed);
OWNER_STATE_AFTER_88.store(
owner_inner
.checked_add(8)
.and_then(|p| guarded_u32(p))
.map(|v| v as usize)
.unwrap_or(usize::MAX),
Ordering::Relaxed,
);
EXIT_88.fetch_add(1, Ordering::Release);
}
unsafe extern "system" fn wrapper_90(request: *mut c_void, argument: *mut c_void) {
ENTER_90.fetch_add(1, Ordering::Relaxed);
LAST_REQUEST_90.store(request as usize, Ordering::Relaxed);
LAST_ARGUMENT_90.store(argument as usize, Ordering::Relaxed);
LAST_THREAD_90.store(GetCurrentThreadId() as usize, Ordering::Relaxed);
let request_address = request as usize;
let callback_90 = field_ptr(request_address, 0x90);
let callback_98 = field_ptr(request_address, 0x98);
let callback_a0 = field_ptr(request_address, 0xa0);
let callback_a8 = field_ptr(request_address, 0xa8);
CALLBACK_90.store(callback_90, Ordering::Relaxed);
CALLBACK_98.store(callback_98, Ordering::Relaxed);
CALLBACK_A0.store(callback_a0, Ordering::Relaxed);
CALLBACK_A8.store(callback_a8, Ordering::Relaxed);
SELECTED_90.store(
if callback_90 != 0 {
callback_90
} else {
callback_a0
},
Ordering::Relaxed,
);
let original: Callback90 = core::mem::transmute(ORIGINAL_90.load(Ordering::Acquire));
original(request, argument);
EXIT_90.fetch_add(1, Ordering::Release);
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SwapOutcome {
Installed,
CleanFailure,
DegradedFirstSlotActive,
DegradedProtection,
}
unsafe fn install_slots(base: usize) -> SwapOutcome {
let Some(vtable) = checked_va(base, REQUEST_VTABLE_RVA) else {
return SwapOutcome::CleanFailure;
};
let Some(original_88) = checked_va(base, ORIGINAL_88_RVA) else {
return SwapOutcome::CleanFailure;
};
let Some(original_90) = checked_va(base, ORIGINAL_90_RVA) else {
return SwapOutcome::CleanFailure;
};
let Some(slot_88) = checked_va(vtable, SLOT_88) else {
return SwapOutcome::CleanFailure;
};
let Some(slot_90) = checked_va(vtable, SLOT_90) else {
return SwapOutcome::CleanFailure;
};
let Some(size) = image_size(base) else {
return SwapOutcome::CleanFailure;
};
if !image_range_covered(size, REQUEST_VTABLE_RVA, SLOT_90 + 8)
|| !image_range_covered(size, ORIGINAL_88_RVA, ORIGINAL_88_SIGNATURE.len())
|| !image_range_covered(size, ORIGINAL_90_RVA, ORIGINAL_90_SIGNATURE.len())
|| slot_88 & 7 != 0
|| slot_90 & 7 != 0
|| !crate::sbc_trace::validate_cards_build(base)
|| !range_in_image_allocation(base, vtable, SLOT_90 + 8)
|| !range_in_image_allocation(base, original_88, ORIGINAL_88_SIGNATURE.len())
|| !range_in_image_allocation(base, original_90, ORIGINAL_90_SIGNATURE.len())
|| !signature_matches(original_88, &ORIGINAL_88_SIGNATURE)
|| !signature_matches(original_90, &ORIGINAL_90_SIGNATURE)
|| (slot_88 as *const AtomicUsize)
.as_ref()
.unwrap()
.load(Ordering::Acquire)
!= original_88
|| (slot_90 as *const AtomicUsize)
.as_ref()
.unwrap()
.load(Ordering::Acquire)
!= original_90
{
return SwapOutcome::CleanFailure;
}
let mut pinned = core::ptr::null_mut();
if GetModuleHandleExA(
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN,
vtable as *const u8,
&mut pinned,
) == 0
|| pinned as usize != base
{
return SwapOutcome::CleanFailure;
}
ORIGINAL_88.store(original_88, Ordering::Release);
ORIGINAL_90.store(original_90, Ordering::Release);
// Both slots share the same vtable page. Keep it writable only across the two
// compare/exchanges and possible rollback.
let mut old = 0u32;
if VirtualProtect(slot_88 as _, 16, PAGE_READWRITE, &mut old) == 0 {
return SwapOutcome::CleanFailure;
}
let atom_88 = &*(slot_88 as *const AtomicUsize);
let atom_90 = &*(slot_90 as *const AtomicUsize);
let first = atom_88.compare_exchange(
original_88,
wrapper_88 as *const () as usize,
Ordering::AcqRel,
Ordering::Acquire,
);
let outcome = if first.is_err() {
SwapOutcome::CleanFailure
} else if atom_90
.compare_exchange(
original_90,
wrapper_90 as *const () as usize,
Ordering::AcqRel,
Ordering::Acquire,
)
.is_ok()
{
SwapOutcome::Installed
} else if atom_88
.compare_exchange(
wrapper_88 as *const () as usize,
original_88,
Ordering::AcqRel,
Ordering::Acquire,
)
.is_ok()
{
SwapOutcome::CleanFailure
} else {
SwapOutcome::DegradedFirstSlotActive
};
let mut ignored = 0u32;
if VirtualProtect(slot_88 as _, 16, old, &mut ignored) == 0 {
return if outcome == SwapOutcome::DegradedFirstSlotActive {
outcome
} else {
SwapOutcome::DegradedProtection
};
}
outcome
}
unsafe fn worker() {
for _ in 0..700u32 {
if crate::sbc_trace::code_patch_installers_ready() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
if !crate::sbc_trace::code_patch_installers_ready() {
crate::write_log("SBC_REQUEST_TRACE: code-patch readiness timeout; inactive\n");
return;
}
let mut base = 0usize;
for _ in 0..600u32 {
base = GetModuleHandleA(b"CardsDLL_Win64_retail.dll\0".as_ptr()) as usize;
if base != 0 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
match if base == 0 { SwapOutcome::CleanFailure } else { install_slots(base) } {
SwapOutcome::Installed => {
INSTALLED.store(true, Ordering::Release);
crate::write_log("SBC_REQUEST_TRACE: category request vtable slots +0x88/+0x90 installed\n");
let mut seen_88 = 0u64;
let mut seen_90 = 0u64;
let mut reports = 0u8;
while reports < 32 {
std::thread::sleep(std::time::Duration::from_millis(250));
let count_88 = ENTER_88.load(Ordering::Acquire);
let count_90 = ENTER_90.load(Ordering::Acquire);
if count_88 != seen_88 || count_90 != seen_90 {
crate::write_log(&format!(
"SBC_REQUEST_TRACE: +88 entry={} exit={} req={:#x} arg={:#x} tid={} owner={:#x} ovt={:#x} consumer={:#x} rvt={:#x} slot={:#x}->{:#x} inner={:#x} state={}->{} flags={:#x} manager={:#x} manager_state={}; +90 entry={} exit={} req={:#x} arg={:#x} tid={} cb90={:#x} cb98={:#x} cba0={:#x} cba8={:#x} selected={:#x}\n",
count_88,
EXIT_88.load(Ordering::Acquire),
LAST_REQUEST_88.load(Ordering::Relaxed),
LAST_ARGUMENT_88.load(Ordering::Relaxed),
LAST_THREAD_88.load(Ordering::Relaxed),
OWNER_88.load(Ordering::Relaxed),
OWNER_VTABLE_88.load(Ordering::Relaxed),
CONSUMER_88.load(Ordering::Relaxed),
RESPONSE_VTABLE_88.load(Ordering::Relaxed),
OWNER_SLOT_BEFORE_88.load(Ordering::Relaxed),
OWNER_SLOT_AFTER_88.load(Ordering::Relaxed),
OWNER_INNER_88.load(Ordering::Relaxed),
OWNER_STATE_BEFORE_88.load(Ordering::Relaxed),
OWNER_STATE_AFTER_88.load(Ordering::Relaxed),
OWNER_FLAGS_88.load(Ordering::Relaxed),
OWNER_MANAGER_88.load(Ordering::Relaxed),
OWNER_MANAGER_STATE_88.load(Ordering::Relaxed),
count_90,
EXIT_90.load(Ordering::Acquire),
LAST_REQUEST_90.load(Ordering::Relaxed),
LAST_ARGUMENT_90.load(Ordering::Relaxed),
LAST_THREAD_90.load(Ordering::Relaxed),
CALLBACK_90.load(Ordering::Relaxed),
CALLBACK_98.load(Ordering::Relaxed),
CALLBACK_A0.load(Ordering::Relaxed),
CALLBACK_A8.load(Ordering::Relaxed),
SELECTED_90.load(Ordering::Relaxed),
));
seen_88 = count_88;
seen_90 = count_90;
reports += 1;
}
}
crate::write_log("SBC_REQUEST_TRACE: report cap reached; vtable probes remain passive\n");
}
SwapOutcome::CleanFailure => crate::write_log("SBC_REQUEST_TRACE: clean install failure; inactive\n"),
SwapOutcome::DegradedFirstSlotActive => crate::write_log(
"SBC_REQUEST_TRACE: DEGRADED slot +0x88 may remain active; terminate game now\n",
),
SwapOutcome::DegradedProtection => crate::write_log(
"SBC_REQUEST_TRACE: DEGRADED vtable page protection restore failed; terminate game now\n",
),
}
}
pub(crate) fn install() {
let armed = enabled(std::env::var("OPENFUT_SBC_REQUEST_TRACE").ok().as_deref());
ENABLED.store(armed, Ordering::Release);
if !armed {
crate::write_log("SBC_REQUEST_TRACE: disabled\n");
return;
}
crate::write_log("SBC_REQUEST_TRACE: requested; deferred install starting\n");
std::thread::spawn(|| unsafe { worker() });
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gate_is_exact() {
assert!(!enabled(None));
assert!(!enabled(Some("true")));
assert!(enabled(Some("1")));
}
#[test]
fn slots_are_aligned_and_class_local() {
assert_eq!((REQUEST_VTABLE_RVA + SLOT_88) & 7, 0);
assert_eq!((REQUEST_VTABLE_RVA + SLOT_90) & 7, 0);
assert_eq!(SLOT_90 - SLOT_88, 8);
}
#[test]
fn image_coverage_is_checked_and_overflow_safe() {
assert!(image_range_covered(
0x230000,
REQUEST_VTABLE_RVA,
SLOT_90 + 8
));
assert!(!image_range_covered(
REQUEST_VTABLE_RVA + SLOT_90,
REQUEST_VTABLE_RVA,
SLOT_90 + 8
));
assert!(!image_range_covered(usize::MAX, usize::MAX, 8));
}
}
File diff suppressed because it is too large Load Diff
+16 -6
View File
@@ -9,11 +9,9 @@
// server's certificate chain. Always returning 1 is equivalent to trusting all certs, // 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. // which is the behaviour we want for the local self-signed bridge certificate.
use windows_sys::Win32::{ use windows_sys::Win32::System::{
System::{
LibraryLoader::GetModuleHandleA, LibraryLoader::GetModuleHandleA,
Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE}, Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE},
},
}; };
// Unique 22-byte prologue of ProtoSSL's cert-verify function. // Unique 22-byte prologue of ProtoSSL's cert-verify function.
@@ -38,7 +36,9 @@ const PATCH: &[u8] = &[
]; ];
fn patch_module(module: isize, scan_bytes: usize) -> bool { 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 base = module as usize;
let image: &[u8] = unsafe { core::slice::from_raw_parts(base as *const u8, scan_bytes) }; 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) { 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 target = (base + offset) as *mut u8;
let mut old_prot: u32 = 0; let mut old_prot: u32 = 0;
unsafe { 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()); 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 true
} }
+6 -1
View File
@@ -25,7 +25,12 @@ pub unsafe extern "system" fn hooked_cert_verify_chain_policy(
p_policy_status: *mut u32, p_policy_status: *mut u32,
) -> BOOL { ) -> BOOL {
if let Some(real) = REAL.get().copied() { 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 // Clear the error field of CERT_CHAIN_POLICY_STATUS regardless
if !p_policy_status.is_null() { if !p_policy_status.is_null() {
+227
View File
@@ -0,0 +1,227 @@
//! Milestone 0 — Blaze transport reachability observation.
//!
//! PURE LOGGING, NO NEW DETOURS. This module does not hook anything itself. It is
//! called from the three Winsock detours the hook ALREADY installs — getaddrinfo
//! (`hooks.rs`), connect/WSAConnect (`connect_hook.rs`) and ConnectEx
//! (`connectex_hook.rs`) — and, when armed, emits a single grep-friendly
//! `TRANSPORT_WATCH:` line per resolution/connect so we can answer one question:
//!
//! Does the FIFA 23 client attempt ANY Blaze-flavored transport activity across a
//! full menu+FUT session, or none at all?
//!
//! Everything here is READ-ONLY: we parse the hostname / sockaddr the game passed
//! only to describe it in the log. We never change a resolution result or a
//! connection target — that redirect logic lives in the detours themselves and is
//! untouched. The env kill switch `OPENFUT_TRANSPORT_WATCH=1` gates all output;
//! disarmed (default) this module is inert (each entry point returns immediately).
//!
//! Future-reference note (beyond-beginner, deliberately NOT done here): a
//! types-first design would model a `ConnectTarget` enum (Inet{ip,port} / NonInet /
//! Short) and a `TransportEvent` and route them through the `tracing` crate with
//! structured fields, instead of hand-formatting strings into a flat log file. That
//! buys machine-parseable logs and log levels. For a one-shot observation gate,
//! flat `write_log` lines that `grep` cleanly are the lower-ceremony choice.
use core::sync::atomic::{AtomicBool, Ordering};
/// Armed once at DLL load from `OPENFUT_TRANSPORT_WATCH`. `AtomicBool` (not a plain
/// `static mut bool`) because the detours that read it run on arbitrary game threads;
/// an atomic gives race-free reads with no `unsafe`. `Relaxed` is enough — this is a
/// standalone flag with no ordering relationship to other memory.
static ARMED: AtomicBool = AtomicBool::new(false);
/// Read the env var once, at DLL load, and log the arm state. Called from `DllMain`
/// (`install_hooks`). Reading the env in-process (rather than as a command prefix) is
/// what makes the switch actually propagate through the umu/Proton launch — the same
/// gotcha the probe switches hit; it works because the launch script `export`s it.
pub fn arm_from_env() {
let on = std::env::var("OPENFUT_TRANSPORT_WATCH")
.map(|v| v == "1")
.unwrap_or(false);
ARMED.store(on, Ordering::Relaxed);
crate::write_log(&format!(
"TRANSPORT_WATCH: {} (env OPENFUT_TRANSPORT_WATCH)\n",
if on { "ARMED" } else { "disarmed" }
));
}
fn armed() -> bool {
ARMED.load(Ordering::Relaxed)
}
/// True if `host` looks like EA/Blaze infrastructure. Broad on purpose: this is a log
/// classifier that makes a hit visually pop (`<-- BLAZE/EA-FLAVORED`), NOT a routing
/// decision. The actual redirect decision stays in `hooks::is_ea_host`, which is
/// deliberately narrower and unchanged.
fn is_blaze_flavored(host: &str) -> bool {
let h = host.to_ascii_lowercase();
[
"redirector",
"gosredirector",
"blaze",
"gosca",
"easfc",
"utas",
"fut",
"ea.com",
"easports",
]
.iter()
.any(|k| h.contains(k))
}
/// Log one getaddrinfo hostname. Self-gates on the arm flag, so the call site can be
/// unconditional. The existing `openfut_hook: getaddrinfo(...)` line stays; this adds
/// the tagged, classified line so `grep TRANSPORT_WATCH` sees the full resolution set
/// and a Blaze host stands out.
pub fn note_getaddrinfo(host: &str) {
if !armed() {
return;
}
let tag = if is_blaze_flavored(host) {
" <-- BLAZE/EA-FLAVORED"
} else {
""
};
crate::write_log(&format!(
"TRANSPORT_WATCH: getaddrinfo host=\"{host}\"{tag}\n"
));
}
const AF_INET: u16 = 2; // IPv4
const AF_INET6: u16 = 23; // IPv6 (Windows value; Linux uses 10 — we're in Wine/Win ABI)
/// Minimal view of a `sockaddr_in`; the first `u16` is the address family for ANY
/// sockaddr, so reading this layout is safe enough to classify the family even when
/// the real struct is a `sockaddr_un` or larger — we only trust the rest once we've
/// confirmed `sin_family == AF_INET`.
#[repr(C)]
struct SockaddrIn {
sin_family: u16,
sin_port: u16,
sin_addr: u32,
sin_zero: [u8; 8],
}
/// Minimal view of a `sockaddr_in6` (Win32 layout). `sin6_port` is network byte order;
/// `sin6_addr` is the 16 raw address bytes in network order. We ignore flowinfo/scope.
#[repr(C)]
struct SockaddrIn6 {
sin6_family: u16,
sin6_port: u16,
sin6_flowinfo: u32,
sin6_addr: [u8; 16],
sin6_scope_id: u32,
}
/// Is `port` a known/suspected Blaze port? SHAPE — public general knowledge; the exact
/// port for FIFA23's Blaze version is UNKNOWN. 42127 main, 10041/10744 redirector
/// variants, 3659 classic redirector.
fn is_blaze_port(port: u16) -> bool {
matches!(port, 42127 | 10744 | 3659 | 10041)
}
/// Log one outbound connect attempt. `api` names the call path (`connect` /
/// `WSAConnect` / `ConnectEx`) so we can tell which Winsock entry the client used.
///
/// SAFETY: `name` must point to at least `namelen` readable bytes — it's the sockaddr
/// the game just handed to a Winsock connect API, so that always holds at the call
/// sites. We read it read-only and never write through it. `s` is the socket handle,
/// used only to query `SO_TYPE` (TCP=1 / UDP=2) so a real Blaze TCP dial is
/// distinguishable from UDP game/voice traffic.
pub unsafe fn note_connect(api: &str, name: *const u8, namelen: i32, s: usize) {
if !armed() {
return;
}
if name.is_null() || namelen < 8 {
crate::write_log(&format!(
"TRANSPORT_WATCH: {api} (no/short sockaddr, namelen={namelen})\n"
));
return;
}
// SAFE: name is non-null and >= 8 bytes (checked above); the first u16 is the
// address family for ANY sockaddr, so reading it is valid regardless of the real
// struct type. We only trust family-specific fields after matching the family.
let family = *(name as *const u16);
// SAFE: getsockopt is a read-only Winsock query on a valid socket handle; a bad
// handle just leaves ty=-1, which we log verbatim. TCP=1 / UDP=2.
let sock_type = {
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,
);
ty
};
match family {
AF_INET => {
// SAFE: family is AF_INET and namelen >= 8 == sizeof(sockaddr_in) fields we read.
let sa = &*(name as *const SockaddrIn);
// sin_addr holds the address in NETWORK byte order; on little-endian x86,
// to_le_bytes reproduces those 4 bytes in memory order, which IS the dotted
// quad. So b[0].b[1].b[2].b[3] is correct. (The legacy connect_hook log line
// prints these reversed — a cosmetic bug there; this M0 line is the correct
// one to trust.)
let b = sa.sin_addr.to_le_bytes();
let port = u16::from_be(sa.sin_port);
let is_loopback = b[0] == 127;
let is_lsx = matches!(port, 3216 | 3217); // known-good LSX channel; not Blaze
let mut tag = String::new();
if is_blaze_port(port) {
tag.push_str(" <-- BLAZE-PORT");
}
// A loopback connect on anything other than LSX is the situation-(a) signal.
if is_loopback && !is_lsx {
tag.push_str(" <-- LOOPBACK non-LSX");
}
crate::write_log(&format!(
"TRANSPORT_WATCH: {api} target={}.{}.{}.{}:{port} sock_type={sock_type}{tag}\n",
b[0], b[1], b[2], b[3]
));
}
AF_INET6 => {
if namelen < 28 {
crate::write_log(&format!(
"TRANSPORT_WATCH: {api} family=INET6 (short sockaddr, namelen={namelen})\n"
));
return;
}
// SAFE: family is AF_INET6 and namelen >= 28 == sizeof(sockaddr_in6).
let sa = &*(name as *const SockaddrIn6);
let a = sa.sin6_addr; // 16 bytes, network order
let port = u16::from_be(sa.sin6_port);
// Format as 8 colon-separated hex groups (not compressed — clarity over
// brevity for a log meant to be grepped).
let hex = (0..8)
.map(|i| format!("{:02x}{:02x}", a[i * 2], a[i * 2 + 1]))
.collect::<Vec<_>>()
.join(":");
// ::1 = loopback: first 15 bytes zero, last byte 1.
let is_loopback = a[..15].iter().all(|&x| x == 0) && a[15] == 1;
let mut tag = String::new();
if is_blaze_port(port) {
tag.push_str(" <-- BLAZE-PORT");
}
if is_loopback {
tag.push_str(" <-- IPv6 LOOPBACK (::1)");
}
crate::write_log(&format!(
"TRANSPORT_WATCH: {api} target=[{hex}]:{port} sock_type={sock_type} (IPv6){tag}\n"
));
}
other => {
// AF_UNIX=1 or anything else — where a named-pipe/unix-socket-style local
// Blaze transport would surface.
crate::write_log(&format!(
"TRANSPORT_WATCH: {api} family={other} (non-INET — possible AF_UNIX/pipe-like)\n"
));
}
}
}
+117
View File
@@ -0,0 +1,117 @@
//! Transparent forwarding for the system `version.dll` API.
//!
//! The hook is deployed under the `version.dll` filename, so every VERSION API
//! import must continue to behave exactly as it would without OpenFUT. Resolve
//! the genuine system DLL once during process attach, then tail-jump from each
//! exported stub. A tail jump preserves the caller's complete Windows x64 ABI
//! state, including stack arguments whose signatures differ between exports.
use std::sync::atomic::{AtomicUsize, Ordering};
use windows_sys::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryW};
const EXPORT_COUNT: usize = 16;
/// Keep this list in the same order as the generated stubs below.
const EXPORTS: [&[u8]; EXPORT_COUNT] = [
b"GetFileVersionInfoA\0",
b"GetFileVersionInfoExA\0",
b"GetFileVersionInfoExW\0",
b"GetFileVersionInfoSizeA\0",
b"GetFileVersionInfoSizeExA\0",
b"GetFileVersionInfoSizeExW\0",
b"GetFileVersionInfoSizeW\0",
b"GetFileVersionInfoW\0",
b"VerFindFileA\0",
b"VerFindFileW\0",
b"VerInstallFileA\0",
b"VerInstallFileW\0",
b"VerLanguageNameA\0",
b"VerLanguageNameW\0",
b"VerQueryValueA\0",
b"VerQueryValueW\0",
];
/// Addresses in the genuine system DLL. Atomic storage gives the assembly
/// stubs stable, directly addressable pointer-sized slots without `static mut`.
static REAL: [AtomicUsize; EXPORT_COUNT] = [const { AtomicUsize::new(0) }; EXPORT_COUNT];
macro_rules! proxy_stub {
($index:literal, $name:ident) => {
#[unsafe(no_mangle)]
#[unsafe(naked)]
pub unsafe extern "system" fn $name() {
core::arch::naked_asm!(
"jmp qword ptr [rip + {base} + {offset}]",
base = sym REAL,
offset = const $index * size_of::<usize>(),
);
}
};
}
proxy_stub!(0, GetFileVersionInfoA);
proxy_stub!(1, GetFileVersionInfoExA);
proxy_stub!(2, GetFileVersionInfoExW);
proxy_stub!(3, GetFileVersionInfoSizeA);
proxy_stub!(4, GetFileVersionInfoSizeExA);
proxy_stub!(5, GetFileVersionInfoSizeExW);
proxy_stub!(6, GetFileVersionInfoSizeW);
proxy_stub!(7, GetFileVersionInfoW);
proxy_stub!(8, VerFindFileA);
proxy_stub!(9, VerFindFileW);
proxy_stub!(10, VerInstallFileA);
proxy_stub!(11, VerInstallFileW);
proxy_stub!(12, VerLanguageNameA);
proxy_stub!(13, VerLanguageNameW);
proxy_stub!(14, VerQueryValueA);
proxy_stub!(15, VerQueryValueW);
/// Resolve forwarding targets before returning from `DLL_PROCESS_ATTACH`.
/// Calls into our exports may happen as soon as the loader releases its lock,
/// so deferring this operation to the hook worker would create a race.
pub(crate) unsafe fn resolve() -> bool {
// Loading by absolute path prevents this proxy from recursively loading
// itself. Proton/Wine exposes the Windows system directory at this path.
let path: Vec<u16> = "C:\\Windows\\System32\\version.dll\0"
.encode_utf16()
.collect();
let module = LoadLibraryW(path.as_ptr());
if module.is_null() {
crate::write_log("version_proxy: FATAL: system version.dll load failed\n");
return false;
}
let mut missing = 0;
for (slot, name) in REAL.iter().zip(EXPORTS) {
let address = GetProcAddress(module, name.as_ptr()).map_or(0, |proc| proc as usize);
slot.store(address, Ordering::Release);
if address == 0 {
missing += 1;
}
}
if missing == 0 {
crate::write_log("version_proxy: forwarded all 16 exports\n");
true
} else {
crate::write_log(&format!(
"version_proxy: FATAL: {missing}/16 system exports missing\n"
));
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn export_table_is_complete_and_nul_terminated() {
assert_eq!(EXPORTS.len(), EXPORT_COUNT);
assert!(EXPORTS.iter().all(|name| name.last() == Some(&0)));
assert!(EXPORTS
.iter()
.all(|name| !name[..name.len() - 1].contains(&0)));
}
}
+18
View File
@@ -0,0 +1,18 @@
LIBRARY version
EXPORTS
GetFileVersionInfoA
GetFileVersionInfoExA
GetFileVersionInfoExW
GetFileVersionInfoSizeA
GetFileVersionInfoSizeExA
GetFileVersionInfoSizeExW
GetFileVersionInfoSizeW
GetFileVersionInfoW
VerFindFileA
VerFindFileW
VerInstallFileA
VerInstallFileW
VerLanguageNameA
VerLanguageNameW
VerQueryValueA
VerQueryValueW