openfut-hook: RE instrumentation for the Blaze dial-gate investigation
In-process, read-only probes and transport observation built while closing the online/FUT route from both the memory and network sides. - probe.rs / dial_notification.rs: menu-time ctx dump, connMgr enumerator, synthetic dial-notification + direct-call dial trigger, and the [element+0x40] container write-watchpoint. All env-gated, one-shot, VirtualQuery-guarded; none alter game state by default. - transport_watch.rs + connect/connectex/hooks/lib: M0 transport observation (grep-friendly TRANSPORT_WATCH logging on the existing getaddrinfo/connect/ WSAConnect/ConnectEx detours) and an IPv6 (v4-mapped) EA-redirect so the game's IPv6 :443 dials land on the bridge instead of the dead servers. Findings: the game never initiates a Blaze connection offline; the dial handler is registered by a self-registering, message-driven state machine whose container stays empty with no Blaze exchange. See openfut-bridge docs/closure-and-preservation.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1,6 @@
|
||||
target/
|
||||
|
||||
# runtime SQLite DB (created when services run from this dir)
|
||||
openfut.db
|
||||
openfut.db-shm
|
||||
openfut.db-wal
|
||||
|
||||
@@ -28,6 +28,27 @@ struct SockaddrIn {
|
||||
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)
|
||||
static CONNECT_ADDR: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
@@ -61,40 +82,87 @@ unsafe fn restore_original(target: *mut u8) {
|
||||
VirtualProtect(target as _, 14, old, &mut old);
|
||||
}
|
||||
|
||||
unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u8; 16], i32)> {
|
||||
if namelen < 8 { return None; }
|
||||
let sa = &*(name as *const SockaddrIn);
|
||||
if sa.sin_family != AF_INET { return None; }
|
||||
/// If `name` is an EA-relevant connect target, return a rewritten sockaddr pointing at
|
||||
/// 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];
|
||||
|
||||
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 {
|
||||
PORT_HTTPS_NBO => PORT_BRIDGE_NBO,
|
||||
#[cfg(not(feature = "capture_baseline"))]
|
||||
PORT_LSX_NBO => PORT_LSX_TARGET_NBO,
|
||||
PORT_BLAZE_REDIRECTOR_NBO => PORT_BLAZE_REDIRECTOR_NBO,
|
||||
PORT_BLAZE_MAIN_NBO => PORT_BLAZE_MAIN_NBO,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
crate::write_log(&format!(
|
||||
"connect_hook: {}.{}.{}.{}:{} → 127.0.0.1:{}\n",
|
||||
orig[3], orig[2], orig[1], orig[0], orig_port,
|
||||
u16::from_be(new_port_nbo)
|
||||
));
|
||||
|
||||
let mut buf = [0u8; 16];
|
||||
let out = &mut *(buf.as_mut_ptr() as *mut SockaddrIn);
|
||||
out.sin_family = AF_INET;
|
||||
out.sin_port = new_port_nbo;
|
||||
out.sin_addr = ADDR_LOOPBACK_NBO;
|
||||
Some((buf, 16))
|
||||
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 new_port_nbo = match sa.sin_port {
|
||||
PORT_HTTPS_NBO => PORT_BRIDGE_NBO,
|
||||
#[cfg(not(feature = "capture_baseline"))]
|
||||
PORT_LSX_NBO => PORT_LSX_TARGET_NBO,
|
||||
PORT_BLAZE_REDIRECTOR_NBO => PORT_BLAZE_REDIRECTOR_NBO,
|
||||
PORT_BLAZE_MAIN_NBO => PORT_BLAZE_MAIN_NBO,
|
||||
_ => return None,
|
||||
};
|
||||
// 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!(
|
||||
"connect_hook: v4 {}.{}.{}.{}:{} → 127.0.0.1:{}\n",
|
||||
o[0], o[1], o[2], o[3], u16::from_be(sa.sin_port),
|
||||
u16::from_be(new_port_nbo)
|
||||
));
|
||||
// SAFE: buf is 28 bytes, larger than the 16-byte sockaddr_in we write.
|
||||
let out = &mut *(buf.as_mut_ptr() as *mut SockaddrIn);
|
||||
out.sin_family = AF_INET;
|
||||
out.sin_port = new_port_nbo;
|
||||
out.sin_addr = ADDR_LOOPBACK_NBO;
|
||||
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 {
|
||||
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
|
||||
if namelen >= 8 {
|
||||
let sa = &*(name as *const SockaddrIn);
|
||||
@@ -157,6 +225,8 @@ pub unsafe extern "system" fn hooked_wsa_connect(
|
||||
caller: *const (), callee: *const (),
|
||||
sqos: *const (), gqos: *const (),
|
||||
) -> 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();
|
||||
if let Some((buf, len)) = redirect_if_ea(name, namelen) {
|
||||
real(s, buf.as_ptr(), len, caller, callee, sqos, gqos)
|
||||
|
||||
@@ -6,12 +6,8 @@
|
||||
use core::sync::atomic::{AtomicUsize, Ordering};
|
||||
use core::ffi::c_void;
|
||||
|
||||
const AF_INET: u16 = 2;
|
||||
const PORT_HTTPS_NBO: u16 = 0xBB01; // 443 big-endian
|
||||
const PORT_BRIDGE_NBO: u16 = 0xFB20; // 8443 big-endian
|
||||
const PORT_BLAZE_REDIRECTOR_NBO: u16 = 0x3927; // 10041 big-endian
|
||||
const PORT_BLAZE_MAIN_NBO: u16 = 0x8FA4; // 42127 big-endian
|
||||
const ADDR_LOOPBACK_NBO: u32 = 0x0100_007F; // 127.0.0.1 big-endian
|
||||
// Address rewriting (v4 + v6) is shared from connect_hook::redirect_if_ea, so the port
|
||||
// constants and sockaddr structs no longer live here.
|
||||
|
||||
// SIO_GET_EXTENSION_FUNCTION_POINTER
|
||||
const SIO_GET_EXT_FN: u32 = 0xC8000006;
|
||||
@@ -23,14 +19,6 @@ const CONNECTEX_GUID: [u8; 16] = [
|
||||
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
|
||||
static REAL_CONNECTEX: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
@@ -91,31 +79,13 @@ unsafe extern "system" fn hooked_connectex(
|
||||
) -> i32 {
|
||||
let real_fn: ConnectExFn = core::mem::transmute(REAL_CONNECTEX.load(Ordering::Relaxed));
|
||||
|
||||
if namelen >= 8 {
|
||||
let sa = &*(name as *const SockaddrIn);
|
||||
if sa.sin_family == AF_INET {
|
||||
let o = sa.sin_addr.to_le_bytes();
|
||||
let orig_port = u16::from_be(sa.sin_port);
|
||||
let new_port_nbo = match sa.sin_port {
|
||||
PORT_HTTPS_NBO => PORT_BRIDGE_NBO,
|
||||
PORT_BLAZE_REDIRECTOR_NBO => PORT_BLAZE_REDIRECTOR_NBO,
|
||||
PORT_BLAZE_MAIN_NBO => PORT_BLAZE_MAIN_NBO,
|
||||
_ => 0,
|
||||
};
|
||||
if new_port_nbo != 0 {
|
||||
crate::write_log(&format!(
|
||||
"connectex_hook: {}.{}.{}.{}:{} → 127.0.0.1:{}\n",
|
||||
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);
|
||||
}
|
||||
}
|
||||
// Milestone-0 transport watch (self-gates on OPENFUT_TRANSPORT_WATCH).
|
||||
crate::transport_watch::note_connect("ConnectEx", name, namelen, s);
|
||||
|
||||
// Share the one redirect implementation (v4 + v6) with connect_hook, so ConnectEx
|
||||
// dials get the same IPv6 handling as plain connect().
|
||||
if let Some((buf, len)) = crate::connect_hook::redirect_if_ea(name, namelen) {
|
||||
return real_fn(s, buf.as_ptr(), len, send_buf, send_data_len, bytes_sent, overlapped);
|
||||
}
|
||||
real_fn(s, name, namelen, send_buf, send_data_len, bytes_sent, overlapped)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,8 @@ pub unsafe extern "system" fn hooked_getaddrinfo(
|
||||
if !node_name.is_null() {
|
||||
if let Ok(host) = CStr::from_ptr(node_name as *const i8).to_str() {
|
||||
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) {
|
||||
// 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.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod config;
|
||||
mod connect_hook;
|
||||
mod connectex_hook;
|
||||
mod dial_notification;
|
||||
mod hooks;
|
||||
mod iat;
|
||||
mod origin_spy;
|
||||
@@ -10,6 +11,7 @@ mod probe;
|
||||
mod recv_hook;
|
||||
mod ssl_patch;
|
||||
mod tls_bypass;
|
||||
mod transport_watch;
|
||||
|
||||
use windows_sys::Win32::{
|
||||
Foundation::{BOOL, HMODULE, TRUE},
|
||||
@@ -25,6 +27,18 @@ pub(crate) fn write_log(msg: &str) {
|
||||
{ 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]
|
||||
pub unsafe extern "system" fn DllMain(module: HMODULE, reason: u32, _: *mut ()) -> BOOL {
|
||||
if reason == DLL_PROCESS_ATTACH { install_hooks(module); }
|
||||
@@ -33,6 +47,9 @@ pub unsafe extern "system" fn DllMain(module: HMODULE, reason: u32, _: *mut ())
|
||||
|
||||
unsafe fn install_hooks(module: HMODULE) {
|
||||
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);
|
||||
hooks::set_redirect_ip(ip);
|
||||
|
||||
|
||||
+1246
-12
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,225 @@
|
||||
//! 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"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user