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:
@@ -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