From f16828a584ceee9bb8b5a9e463d296501b013af3 Mon Sep 17 00:00:00 2001 From: OpenFUT Dev Date: Thu, 20 Aug 2026 09:12:59 -0700 Subject: [PATCH] wip(hook): retained client-hook WIP \u2014 resolver_hook, store_hook, openfut-common crate RETAINED PRE-EXISTING client-hook WIP (brought forward after verification). Adds resolver_hook.rs + store_hook.rs and a new openfut-common crate, plus config/connect/fifa17/hooks/lib refinements. No secrets/staging addresses. Preserved on feat/sbc-hook-tracing so it is recoverable and pushed. --- .gitignore | 3 + openfut-common/Cargo.lock | 7 + openfut-common/Cargo.toml | 11 + openfut-common/src/lib.rs | 479 ++++++++++++++++++++++++++++++ openfut-hook/Cargo.toml | 1 + openfut-hook/src/config.rs | 52 +++- openfut-hook/src/connect_hook.rs | 115 +++++-- openfut-hook/src/fifa17.rs | 109 ++++++- openfut-hook/src/hooks.rs | 28 +- openfut-hook/src/lib.rs | 19 +- openfut-hook/src/resolver_hook.rs | 248 ++++++++++++++++ openfut-hook/src/store_hook.rs | 445 +++++++++++++++++++++++++++ 12 files changed, 1457 insertions(+), 60 deletions(-) create mode 100644 openfut-common/Cargo.lock create mode 100644 openfut-common/Cargo.toml create mode 100644 openfut-common/src/lib.rs create mode 100644 openfut-hook/src/resolver_hook.rs create mode 100644 openfut-hook/src/store_hook.rs diff --git a/.gitignore b/.gitignore index 10dc867..243964c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ target/ openfut.db openfut.db-shm openfut.db-wal + +# hook cross-build test output +target-test/ diff --git a/openfut-common/Cargo.lock b/openfut-common/Cargo.lock new file mode 100644 index 0000000..cb9e157 --- /dev/null +++ b/openfut-common/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "openfut-common" +version = "0.1.0" diff --git a/openfut-common/Cargo.toml b/openfut-common/Cargo.toml new file mode 100644 index 0000000..c62141c --- /dev/null +++ b/openfut-common/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "openfut-common" +version = "0.1.0" +edition = "2021" +description = "Shared OpenFUT server-destination configuration, port mapping, and address conversion used by both the launcher and openfut_hook.dll. Pure std, no dependencies, so it stays small inside the injected DLL and is unit-testable on any host." + +[lib] +# Rlib only. Deliberately dependency-free so linking it into openfut_hook.dll +# (a cdylib) adds no runtime weight and no cross-platform risk. + +[dependencies] diff --git a/openfut-common/src/lib.rs b/openfut-common/src/lib.rs new file mode 100644 index 0000000..2bd5033 --- /dev/null +++ b/openfut-common/src/lib.rs @@ -0,0 +1,479 @@ +//! Shared OpenFUT destination configuration. +//! +//! This crate is the **single source of truth** for where FIFA's intercepted +//! EA traffic is redirected. It is consumed by both the launcher (which writes +//! `openfut.cfg`) and `openfut_hook.dll` (which reads it and rewrites sockets). +//! +//! Design rules enforced here: +//! * There is exactly ONE configured OpenFUT destination (host + ports). +//! * Missing/invalid configuration is a hard error — it NEVER silently +//! degrades to `127.0.0.1`/localhost. Loopback is only ever used if the +//! user explicitly configures it. +//! * The address/port -> WinSock representation helpers live here and are +//! unit-tested on the host, so the byte-order logic the socket hooks depend +//! on is verified without needing WinSock or FIFA. +//! +//! ## EA source ports vs OpenFUT destination ports +//! +//! FIFA connects to a handful of EA endpoints on fixed ports. Those source +//! ports are *signatures* used to recognise traffic that must be intercepted — +//! they are hardcoded on purpose (see [`ea_ports`]). Each recognised EA source +//! port maps to an OpenFUT *destination* port, which comes from configuration +//! ([`OpenFutPorts`]). Source port = fixed EA signature; destination port = +//! configurable OpenFUT service. + +use std::fmt; +use std::net::{Ipv4Addr, ToSocketAddrs}; +use std::str::FromStr; + +/// EA source ports FIFA dials. These are fixed EA endpoint signatures used to +/// recognise traffic for interception — NOT OpenFUT configuration. Do not make +/// these configurable; changing them would stop us recognising EA traffic. +pub mod ea_ports { + /// EA HTTPS (UTAS / EAWebKit / footapi) — the game dials EA hosts on 443. + pub const HTTPS: u16 = 443; + /// EA Blaze redirector (gosredirector) source port. + pub const BLAZE_REDIRECTOR: u16 = 10041; + /// FIFA 17's `winter15.gosredirector.ea.com` endpoint source port. This is + /// an observed, fixed vendor-port signature; it maps to the same configured + /// OpenFUT redirector destination as the newer 10041 endpoint. + pub const FIFA17_BLAZE_REDIRECTOR: u16 = 42230; + /// EA Blaze main server source port. + pub const BLAZE_MAIN: u16 = 42127; +} + +/// Default OpenFUT *destination* ports, derived from the current OpenFUT server +/// implementation (bridge listens on 8443 for HTTPS; Blaze services keep their +/// native ports). The launcher may pre-populate these; the user may change them +/// without recompiling anything. +pub mod default_ports { + /// OpenFUT bridge HTTPS listener (EA :443 is redirected here). + pub const HTTPS: u16 = 8443; + /// OpenFUT FIFA 17 Blaze redirector listener. + pub const BLAZE_REDIRECTOR: u16 = 42127; + /// OpenFUT FIFA 17 Blaze main listener. + pub const BLAZE_MAIN: u16 = 42130; +} + +/// OpenFUT destination ports. Each field is where an intercepted EA source port +/// is redirected. Not collapsed into one port: OpenFUT runs distinct services +/// (bridge HTTPS + two Blaze listeners) that FIFA reaches on distinct ports. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OpenFutPorts { + /// Destination for EA :443 traffic (bridge HTTPS). + pub https: u16, + /// Destination for EA :10041 traffic (Blaze redirector). + pub blaze_redirector: u16, + /// Destination for EA :42127 traffic (Blaze main). + pub blaze_main: u16, +} + +impl Default for OpenFutPorts { + fn default() -> Self { + Self { + https: default_ports::HTTPS, + blaze_redirector: default_ports::BLAZE_REDIRECTOR, + blaze_main: default_ports::BLAZE_MAIN, + } + } +} + +impl OpenFutPorts { + /// Map a recognised EA *source* port to its OpenFUT *destination* port. + /// Returns `None` for ports we don't intercept (the socket hook then leaves + /// the connection untouched). + pub fn map_source_port(&self, ea_source_port: u16) -> Option { + match ea_source_port { + ea_ports::HTTPS => Some(self.https), + ea_ports::BLAZE_REDIRECTOR | ea_ports::FIFA17_BLAZE_REDIRECTOR => { + Some(self.blaze_redirector) + } + ea_ports::BLAZE_MAIN => Some(self.blaze_main), + _ => None, + } + } +} + +/// The parsed OpenFUT server configuration: what host to redirect EA traffic to +/// and which destination ports to use. `host` may be an IPv4 literal or a +/// hostname; resolution to a concrete [`Ipv4Addr`] happens in [`Self::resolve`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServerConfig { + /// User-configured OpenFUT server host (IPv4 literal or hostname). Never + /// defaulted to loopback — an empty host is a [`ConfigError::ServerMissing`]. + pub host: String, + pub ports: OpenFutPorts, +} + +/// A [`ServerConfig`] whose host has been resolved to a concrete IPv4 address. +/// This is what the socket hooks consume — all three interception layers share +/// exactly this resolved destination. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ResolvedServer { + /// The concrete IPv4 the EA connection's destination is rewritten to. + pub redirect_ip: Ipv4Addr, + pub ports: OpenFutPorts, +} + +/// Errors loading/validating OpenFUT server configuration. Every one of these +/// must BLOCK operation — none of them may fall back to loopback. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConfigError { + /// No config file present (e.g. `openfut.cfg` missing). + ConfigMissing, + /// Config present but no server host set. + ServerMissing, + /// Host could not be parsed/resolved to an IPv4 address. + InvalidAddress(String), + /// A port value was not a valid `u16` / was zero. + InvalidPort(String), + /// Config bytes were structurally unparseable. + MalformedConfig(String), +} + +impl fmt::Display for ConfigError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ConfigError::ConfigMissing => { + write!(f, "No OpenFUT server configured (config file missing)") + } + ConfigError::ServerMissing => write!( + f, + "No OpenFUT server configured. Enter the hostname or IP address of your OpenFUT server." + ), + ConfigError::InvalidAddress(a) => write!(f, "Invalid OpenFUT server address: {a}"), + ConfigError::InvalidPort(p) => write!(f, "Invalid OpenFUT port: {p}"), + ConfigError::MalformedConfig(m) => write!(f, "Malformed OpenFUT config: {m}"), + } + } +} + +impl std::error::Error for ConfigError {} + +impl ServerConfig { + /// Parse `openfut.cfg` contents. + /// + /// Two accepted formats: + /// * **Structured** (preferred), one `key=value` per line: + /// ```text + /// host=192.168.1.50 + /// https_port=8443 + /// blaze_redirector_port=10041 + /// blaze_main_port=42127 + /// ``` + /// `host` is required; any omitted port uses its default. + /// * **Legacy**, a single bare line containing just the host + /// (IPv4 or hostname). Ports default. This keeps old deployments working. + /// + /// An empty/whitespace-only body is [`ConfigError::ServerMissing`], NOT a + /// silent loopback fallback. + pub fn parse(contents: &str) -> Result { + let trimmed = contents.trim(); + if trimmed.is_empty() { + return Err(ConfigError::ServerMissing); + } + + // Legacy single-token form: no '=' anywhere and a single line. + let has_kv = trimmed.lines().any(|l| l.contains('=')); + if !has_kv { + let host = trimmed.trim(); + if host.is_empty() { + return Err(ConfigError::ServerMissing); + } + return Ok(Self { + host: host.to_string(), + ports: OpenFutPorts::default(), + }); + } + + let mut host: Option = None; + let mut ports = OpenFutPorts::default(); + + for (lineno, raw) in trimmed.lines().enumerate() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let (key, value) = line.split_once('=').ok_or_else(|| { + ConfigError::MalformedConfig(format!("line {}: expected key=value", lineno + 1)) + })?; + let key = key.trim(); + let value = value.trim(); + match key { + "host" | "server" | "redirect_ip" => { + if value.is_empty() { + return Err(ConfigError::ServerMissing); + } + host = Some(value.to_string()); + } + "https_port" => ports.https = parse_port(value)?, + "blaze_redirector_port" => ports.blaze_redirector = parse_port(value)?, + "blaze_main_port" => ports.blaze_main = parse_port(value)?, + other => { + return Err(ConfigError::MalformedConfig(format!( + "line {}: unknown key '{other}'", + lineno + 1 + ))); + } + } + } + + let host = host.ok_or(ConfigError::ServerMissing)?; + Ok(Self { host, ports }) + } + + /// Serialize to the structured `openfut.cfg` format. + pub fn to_cfg_string(&self) -> String { + format!( + "host={}\nhttps_port={}\nblaze_redirector_port={}\nblaze_main_port={}\n", + self.host, self.ports.https, self.ports.blaze_redirector, self.ports.blaze_main + ) + } + + /// Validate the host is a syntactically acceptable IPv4 literal or hostname. + /// Does NOT perform DNS (no network) — use [`Self::resolve`] for that. + pub fn validate(&self) -> Result<(), ConfigError> { + let host = self.host.trim(); + if host.is_empty() { + return Err(ConfigError::ServerMissing); + } + if Ipv4Addr::from_str(host).is_ok() { + return Ok(()); + } + if is_plausible_hostname(host) { + Ok(()) + } else { + Err(ConfigError::InvalidAddress(host.to_string())) + } + } + + /// Resolve the configured host to a concrete IPv4 destination. + /// + /// This is the single shared resolution path all hooks rely on: an IPv4 + /// literal is used directly; a hostname is resolved via the platform + /// resolver ([`ToSocketAddrs`]). Only IPv4 results are used (WinSock + /// interception here is IPv4). Never falls back to loopback. + pub fn resolve(&self) -> Result { + let host = self.host.trim(); + if host.is_empty() { + return Err(ConfigError::ServerMissing); + } + + // IPv4 literal: no DNS needed. + if let Ok(ip) = Ipv4Addr::from_str(host) { + return Ok(ResolvedServer { + redirect_ip: ip, + ports: self.ports, + }); + } + + // Hostname: resolve via the platform resolver. Port is irrelevant to + // the lookup; we only need an address. Take the first IPv4 answer. + let ip = (host, 0u16) + .to_socket_addrs() + .map_err(|e| ConfigError::InvalidAddress(format!("{host}: {e}")))? + .find_map(|sa| match sa.ip() { + std::net::IpAddr::V4(v4) => Some(v4), + std::net::IpAddr::V6(_) => None, + }) + .ok_or_else(|| ConfigError::InvalidAddress(format!("{host}: no IPv4 address found")))?; + + Ok(ResolvedServer { + redirect_ip: ip, + ports: self.ports, + }) + } +} + +fn parse_port(value: &str) -> Result { + let n: u16 = value + .parse() + .map_err(|_| ConfigError::InvalidPort(value.to_string()))?; + if n == 0 { + return Err(ConfigError::InvalidPort(value.to_string())); + } + Ok(n) +} + +/// Lenient hostname sanity check (RFC-1123-ish). Not a full validator — just +/// enough to reject obvious garbage while accepting `.local`/`.home` names. +fn is_plausible_hostname(host: &str) -> bool { + if host.is_empty() || host.len() > 253 { + return false; + } + host.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') + && !label.starts_with('-') + && !label.ends_with('-') + }) +} + +// ── WinSock representation helpers ─────────────────────────────────────────── +// These convert a resolved destination into the exact in-memory representation +// WinSock's `sockaddr_in` expects. Kept here (not in the DLL) so the byte-order +// contract the socket hooks depend on is unit-tested on the host. + +/// Value to store in `sockaddr_in.sin_addr.S_un.S_addr`. +/// +/// WinSock stores the four IPv4 octets in network byte order in memory. Reading +/// those bytes back as a native-endian `u32` (how the hook's `SockaddrIn.sin_addr` +/// field is typed) is exactly `from_ne_bytes(octets)`. On little-endian x86_64 +/// this yields e.g. `127.0.0.1 -> 0x0100_007F`, matching the value the original +/// hooks hardcoded — confirming the conversion is correct. +pub fn sin_addr_from_ipv4(ip: Ipv4Addr) -> u32 { + u32::from_ne_bytes(ip.octets()) +} + +/// Value to store in `sockaddr_in.sin_port` — the port in network byte order. +pub fn sin_port_nbo(port: u16) -> u16 { + port.to_be() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn configured_ipv4_survives_parsing() { + let c = ServerConfig::parse("host=192.168.1.50\n").unwrap(); + assert_eq!(c.host, "192.168.1.50"); + assert_eq!(c.ports, OpenFutPorts::default()); + } + + #[test] + fn legacy_bare_host_line_parses() { + let c = ServerConfig::parse("10.0.0.7\n").unwrap(); + assert_eq!(c.host, "10.0.0.7"); + assert_eq!(c.ports, OpenFutPorts::default()); + } + + #[test] + fn missing_server_does_not_become_loopback() { + // Empty config is an explicit error, never 127.0.0.1. + assert_eq!( + ServerConfig::parse("").unwrap_err(), + ConfigError::ServerMissing + ); + assert_eq!( + ServerConfig::parse(" \n\n").unwrap_err(), + ConfigError::ServerMissing + ); + assert_eq!( + ServerConfig::parse("https_port=8443\n").unwrap_err(), + ConfigError::ServerMissing + ); + } + + #[test] + fn invalid_server_is_rejected() { + let c = ServerConfig { + host: "not a host!!".into(), + ports: OpenFutPorts::default(), + }; + assert!(matches!(c.validate(), Err(ConfigError::InvalidAddress(_)))); + } + + #[test] + fn configured_port_survives_parsing() { + let c = ServerConfig::parse( + "host=srv.home\nhttps_port=9000\nblaze_redirector_port=11000\nblaze_main_port=42000\n", + ) + .unwrap(); + assert_eq!(c.ports.https, 9000); + assert_eq!(c.ports.blaze_redirector, 11000); + assert_eq!(c.ports.blaze_main, 42000); + } + + #[test] + fn invalid_port_is_rejected() { + assert!(matches!( + ServerConfig::parse("host=x\nhttps_port=0\n"), + Err(ConfigError::InvalidPort(_)) + )); + assert!(matches!( + ServerConfig::parse("host=x\nhttps_port=99999\n"), + Err(ConfigError::InvalidPort(_)) + )); + } + + #[test] + fn unknown_key_is_malformed() { + assert!(matches!( + ServerConfig::parse("host=x\nbogus=1\n"), + Err(ConfigError::MalformedConfig(_)) + )); + } + + #[test] + fn roundtrip_cfg_string() { + let c = ServerConfig { + host: "192.168.1.50".into(), + ports: OpenFutPorts { + https: 8443, + blaze_redirector: 10041, + blaze_main: 42127, + }, + }; + let s = c.to_cfg_string(); + assert_eq!(ServerConfig::parse(&s).unwrap(), c); + } + + #[test] + fn configured_ipv4_becomes_correct_sockaddr() { + // Resolve an IPv4 literal and confirm the sin_addr value. + let c = ServerConfig::parse("host=192.168.1.50\n").unwrap(); + let r = c.resolve().unwrap(); + assert_eq!(r.redirect_ip, Ipv4Addr::new(192, 168, 1, 50)); + // sin_addr is the octets as a native-endian u32. + assert_eq!( + sin_addr_from_ipv4(r.redirect_ip), + u32::from_ne_bytes([192, 168, 1, 50]) + ); + } + + #[test] + fn loopback_sockaddr_matches_legacy_constant() { + // Guards the byte-order contract: the old hooks hardcoded 0x0100_007F + // for 127.0.0.1. Our helper must reproduce it on this (LE) host. + assert_eq!(sin_addr_from_ipv4(Ipv4Addr::new(127, 0, 0, 1)), 0x0100_007F); + } + + #[test] + fn sin_port_is_network_byte_order() { + // 443 -> 0xBB01, 42127 -> 0x8FA4 (matches the legacy hook constants). + assert_eq!(sin_port_nbo(443), 0xBB01); + assert_eq!(sin_port_nbo(42127), 0x8FA4); + assert_eq!(sin_port_nbo(10041), 0x3927); + } + + #[test] + fn port_mapping_source_to_destination() { + let p = OpenFutPorts::default(); + assert_eq!(p.map_source_port(ea_ports::HTTPS), Some(8443)); + assert_eq!(p.map_source_port(ea_ports::BLAZE_REDIRECTOR), Some(42127)); + assert_eq!( + p.map_source_port(ea_ports::FIFA17_BLAZE_REDIRECTOR), + Some(42127) + ); + assert_eq!(p.map_source_port(ea_ports::BLAZE_MAIN), Some(42130)); + assert_eq!(p.map_source_port(12345), None); + } + + #[test] + fn resolve_literal_ipv4_no_dns() { + let c = ServerConfig::parse("host=127.0.0.1\n").unwrap(); + let r = c.resolve().unwrap(); + assert_eq!(r.redirect_ip, Ipv4Addr::LOCALHOST); + } + + #[test] + fn resolve_empty_host_errors() { + let c = ServerConfig { + host: " ".into(), + ports: OpenFutPorts::default(), + }; + assert_eq!(c.resolve().unwrap_err(), ConfigError::ServerMissing); + } +} diff --git a/openfut-hook/Cargo.toml b/openfut-hook/Cargo.toml index fcf2b26..2b9fee1 100644 --- a/openfut-hook/Cargo.toml +++ b/openfut-hook/Cargo.toml @@ -21,6 +21,7 @@ probe = [] fifa17 = [] [dependencies] +openfut-common = { path = "../openfut-common" } windows-sys = { version = "0.59", features = [ "Win32_Foundation", "Win32_System_LibraryLoader", diff --git a/openfut-hook/src/config.rs b/openfut-hook/src/config.rs index f50dc3b..6658a68 100644 --- a/openfut-hook/src/config.rs +++ b/openfut-hook/src/config.rs @@ -1,20 +1,16 @@ -/// Reads openfut.cfg from the same directory as this DLL. -/// -/// The file contains a single line: the IP the hook should redirect EA -/// hostnames to, e.g. "192.168.1.10" or "127.0.0.1". -/// Falls back to 127.0.0.1 if the file is missing or unreadable. +//! Load the configured OpenFUT host and destination ports from `openfut.cfg`. +//! Missing or invalid configuration is a hard error; there is no loopback +//! fallback. Both structured config and the legacy bare-host line are accepted +//! by `openfut-common`. +use openfut_common::{ConfigError, ServerConfig}; use windows_sys::Win32::System::LibraryLoader::GetModuleFileNameA; -pub fn read_redirect_ip(module: windows_sys::Win32::Foundation::HMODULE) -> String { - if let Some(cfg_path) = config_path(module) { - if let Ok(content) = std::fs::read_to_string(&cfg_path) { - let ip = content.trim().to_string(); - if !ip.is_empty() { - return ip; - } - } - } - "127.0.0.1".to_string() +pub fn load_config( + module: windows_sys::Win32::Foundation::HMODULE, +) -> Result { + let cfg_path = config_path(module).ok_or(ConfigError::ConfigMissing)?; + let content = std::fs::read_to_string(&cfg_path).map_err(|_| ConfigError::ConfigMissing)?; + ServerConfig::parse(&content) } fn config_path(module: windows_sys::Win32::Foundation::HMODULE) -> Option { @@ -30,3 +26,29 @@ fn config_path(module: windows_sys::Win32::Foundation::HMODULE) -> Option Option { + let cfg_path = config_path(module)?; + let content = std::fs::read_to_string(&cfg_path).ok()?; + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Some((k, v)) = line.split_once('=') { + if k.trim() == key { + return Some(v.trim().to_string()); + } + } + } + None +} diff --git a/openfut-hook/src/connect_hook.rs b/openfut-hook/src/connect_hook.rs index 34639b7..158ec87 100644 --- a/openfut-hook/src/connect_hook.rs +++ b/openfut-hook/src/connect_hook.rs @@ -1,13 +1,13 @@ /// Hooks ws2_32!connect via inline detour (no iptables needed). /// Uses unhook/rehook pattern: restores original bytes, calls real function, re-installs hook. /// This avoids trampoline RIP-relocation issues entirely. -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicU16, AtomicU32, AtomicUsize, Ordering}; use std::sync::OnceLock; 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_FIFA17_BLAZE_REDIRECTOR_NBO: u16 = 0xF6A4; // 42230 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 @@ -18,7 +18,47 @@ const PORT_BLAZE_MAIN_NBO: u16 = 0x8FA4; // 42127 big-endian 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 +/// Redirect target for rewritten EA connects, stored in **network byte order** +/// (same layout as `sockaddr_in.sin_addr`). Zero means unconfigured and causes +/// redirect_if_ea to leave traffic untouched; there is no loopback fallback. +static TARGET_ADDR_NBO: AtomicU32 = AtomicU32::new(0); +static TARGET_HTTPS_PORT_NBO: AtomicU16 = AtomicU16::new(0); +static TARGET_BLAZE_REDIRECTOR_PORT_NBO: AtomicU16 = AtomicU16::new(0); +static TARGET_BLAZE_MAIN_PORT_NBO: AtomicU16 = AtomicU16::new(0); + +/// Install the single resolved destination shared by every socket path. +pub fn set_server(server: openfut_common::ResolvedServer) { + TARGET_ADDR_NBO.store( + openfut_common::sin_addr_from_ipv4(server.redirect_ip), + Ordering::Relaxed, + ); + TARGET_HTTPS_PORT_NBO.store( + openfut_common::sin_port_nbo(server.ports.https), + Ordering::Relaxed, + ); + TARGET_BLAZE_REDIRECTOR_PORT_NBO.store( + openfut_common::sin_port_nbo(server.ports.blaze_redirector), + Ordering::Relaxed, + ); + TARGET_BLAZE_MAIN_PORT_NBO.store( + openfut_common::sin_port_nbo(server.ports.blaze_main), + Ordering::Relaxed, + ); +} + +/// Current redirect target in network byte order. +fn target_addr_nbo() -> u32 { + TARGET_ADDR_NBO.load(Ordering::Relaxed) +} + +/// Build the 16-byte IPv4-mapped IPv6 address (`::ffff:a.b.c.d`) for the current +/// target, so an AF_INET6 socket reaches the same host as the AF_INET path. +fn target_v4mapped() -> [u8; 16] { + let o = target_addr_nbo().to_ne_bytes(); // a.b.c.d in memory order + [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, o[0], o[1], o[2], o[3], + ] +} #[repr(C)] struct SockaddrIn { @@ -41,12 +81,8 @@ struct SockaddrIn6 { 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]; +/// IPv4-mapped IPv6 loopback is no longer hardcoded — the v4-mapped target is +/// derived from the configurable `TARGET_ADDR_NBO` via `target_v4mapped()`. // Address of ws2_32!connect (set at hook installation) static CONNECT_ADDR: AtomicUsize = AtomicUsize::new(0); @@ -110,30 +146,40 @@ pub(crate) unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u // 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, + PORT_HTTPS_NBO => TARGET_HTTPS_PORT_NBO.load(Ordering::Relaxed), #[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, + PORT_BLAZE_REDIRECTOR_NBO | PORT_FIFA17_BLAZE_REDIRECTOR_NBO => { + TARGET_BLAZE_REDIRECTOR_PORT_NBO.load(Ordering::Relaxed) + } + PORT_BLAZE_MAIN_NBO => TARGET_BLAZE_MAIN_PORT_NBO.load(Ordering::Relaxed), _ => return None, }; + if new_port_nbo == 0 || target_addr_nbo() == 0 { + 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(); + let t = target_addr_nbo().to_ne_bytes(); crate::write_log(&format!( - "connect_hook: v4 {}.{}.{}.{}:{} → 127.0.0.1:{}\n", + "connect_hook: v4 {}.{}.{}.{}:{} → {}.{}.{}.{}:{}\n", o[0], o[1], o[2], o[3], u16::from_be(sa.sin_port), + t[0], + t[1], + t[2], + t[3], 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; + out.sin_addr = target_addr_nbo(); Some((buf, 16)) } AF_INET6 => { @@ -144,11 +190,16 @@ pub(crate) unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u 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, + PORT_HTTPS_NBO => TARGET_HTTPS_PORT_NBO.load(Ordering::Relaxed), + PORT_BLAZE_REDIRECTOR_NBO | PORT_FIFA17_BLAZE_REDIRECTOR_NBO => { + TARGET_BLAZE_REDIRECTOR_PORT_NBO.load(Ordering::Relaxed) + } + PORT_BLAZE_MAIN_NBO => TARGET_BLAZE_MAIN_PORT_NBO.load(Ordering::Relaxed), _ => return None, }; + if new_port_nbo == 0 || target_addr_nbo() == 0 { + return None; + } let a = sa6.sin6_addr; crate::write_log(&format!( "connect_hook: v6 [{:02x}{:02x}:..:{:02x}{:02x}]:{} → ::ffff:127.0.0.1:{}\n", @@ -164,7 +215,7 @@ pub(crate) unsafe fn redirect_if_ea(name: *const u8, namelen: i32) -> Option<([u out.sin6_family = AF_INET6; out.sin6_port = new_port_nbo; out.sin6_flowinfo = 0; - out.sin6_addr = V4MAPPED_LOOPBACK; + out.sin6_addr = target_v4mapped(); out.sin6_scope_id = 0; Some((buf, 28)) } @@ -215,7 +266,17 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen: core::mem::transmute(addr); f(s, buf.as_ptr(), len) }; + let wsa_error = if r != 0 { + use windows_sys::Win32::Networking::WinSock::WSAGetLastError; + WSAGetLastError() + } else { + 0 + }; write_hook(addr, hooked_connect as *const () as u64); + if r != 0 { + use windows_sys::Win32::Networking::WinSock::WSASetLastError; + WSASetLastError(wsa_error); + } return r; } else { (name, namelen) @@ -226,19 +287,23 @@ pub unsafe extern "system" fn hooked_connect(s: usize, name: *const u8, namelen: let f: unsafe extern "system" fn(usize, *const u8, i32) -> i32 = core::mem::transmute(addr); f(s, call_name, call_len) }; + let wsa_error = if r != 0 { + use windows_sys::Win32::Networking::WinSock::WSAGetLastError; + WSAGetLastError() + } else { + 0 + }; write_hook(addr, hooked_connect as *const () as u64); if namelen >= 8 { let sa = &*(call_name as *const SockaddrIn); if sa.sin_family == AF_INET { - let err = if r != 0 { - use windows_sys::Win32::Networking::WinSock::WSAGetLastError; - WSAGetLastError() - } else { - 0 - }; - crate::write_log(&format!("connect_hook: result={r} wsa_err={err}\n")); + crate::write_log(&format!("connect_hook: result={r} wsa_err={wsa_error}\n")); } } + if r != 0 { + use windows_sys::Win32::Networking::WinSock::WSASetLastError; + WSASetLastError(wsa_error); + } r } diff --git a/openfut-hook/src/fifa17.rs b/openfut-hook/src/fifa17.rs index 4119823..6260f9b 100644 --- a/openfut-hook/src/fifa17.rs +++ b/openfut-hook/src/fifa17.rs @@ -2,13 +2,19 @@ //! //! 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). +//! of the FIFA-23 memory-layout-specific logic here (origin_spy, LSX dial, event +//! deserializer probes) — that would at best no-op and at worst crash. //! -//! Everything here is read-only except the (not-yet-enabled) cert-verify patch. +//! What it DOES do: +//! 1. Prove the version.dll hijack loads us into FIFA17.exe (module dump). +//! 2. Install the *generic*, memory-layout-independent network redirect: +//! ws2_32 `getaddrinfo` (EA host → configured server) and an inline +//! `connect` / `WSAConnect` detour (EA ports → bridge, dest → configured +//! server IP). These key on hostnames/ports only, not on FIFA-23 offsets, +//! so they are safe to reuse on FIFA 17. +//! +//! Not yet done (next milestone): DirtySDK/ProtoSSL cert-verify patch for the +//! secure Blaze handshake. The module dump locates the DLL that needs it. use crate::write_log; use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; @@ -70,7 +76,7 @@ unsafe fn dump_modules() { /// 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 { +unsafe extern "system" fn worker(param: *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); @@ -78,6 +84,28 @@ unsafe extern "system" fn worker(_: *mut core::ffi::c_void) -> u32 { "fifa17: main exe base={main_base:#018x} SizeOfImage={img:#x}\n" )); dump_modules(); + + // Install the generic network redirect. `param` carries our own DLL's + // HMODULE so config::load_config can find openfut.cfg beside the DLL. + let dll_module = param as windows_sys::Win32::Foundation::HMODULE; + let server = match crate::config::load_config(dll_module).and_then(|c| c.resolve()) { + Ok(server) => server, + Err(e) => { + write_log(&format!( + "fifa17: invalid/missing openfut.cfg ({e}); network redirect DISABLED\n" + )); + return 0; + } + }; + write_log(&format!( + "fifa17: OpenFUT server={} https={} blaze_redir={} blaze_main={}\n", + server.redirect_ip, + server.ports.https, + server.ports.blaze_redirector, + server.ports.blaze_main + )); + install_network_redirect(server); + 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. @@ -86,19 +114,82 @@ unsafe extern "system" fn worker(_: *mut core::ffi::c_void) -> u32 { // It currently fails closed until safe relocating trampolines are proven. crate::sbc_trace::install(); crate::sbc_request_trace::install(); + // Empty-My-Packs Store fix (inert unless store_mypacks_fix=1 in openfut.cfg). + crate::store_hook::install(dll_module); 0 } +/// Install the generic network redirect (getaddrinfo + connect + WSAConnect). +/// +/// `server` is the resolved host + configured destination ports from openfut.cfg. +/// two independent mechanisms, both keyed only on EA hostnames/ports (no +/// FIFA-version-specific memory layout): +/// - getaddrinfo: EA hostnames resolve to `redirect_ip`. +/// - connect/WSAConnect: EA source ports are remapped to the bridge ports and +/// the destination address is rewritten to `redirect_ip`. +/// +/// If `redirect_ip` parses as an IPv4 literal, the connect detour rewrites the +/// destination directly (no DNS). When it is a hostname, getaddrinfo already +/// resolves it, and the connect detour falls back to leaving the resolved +/// address in place (only remapping the port). +unsafe fn install_network_redirect(server: openfut_common::ResolvedServer) { + let redirect_ip = server.redirect_ip.to_string(); + // Resolver redirect: EA hostnames → configured server. Uses INLINE detours at + // the ws2_32 export addresses (getaddrinfo / GetAddrInfoW / gethostbyname), + // not IAT patching — the IAT approach patched 0 slots on FIFA 17 because the + // game doesn't import the resolver through its import table. + crate::hooks::set_redirect_ip(redirect_ip.clone()); + let (ok, total) = crate::resolver_hook::install_resolver_hooks(); + write_log(&format!( + "fifa17: resolver detours {ok}/{total} installed\n" + )); + + crate::connect_hook::set_server(server); + write_log(&format!( + "fifa17: connect target set to {} (https={} blaze_redir={} blaze_main={})\n", + server.redirect_ip, + server.ports.https, + server.ports.blaze_redirector, + server.ports.blaze_main + )); + + // Inline connect detour (port remap + destination rewrite). + if crate::connect_hook::install_inline_connect_hook() { + write_log("fifa17: connect inline-hooked\n"); + } else { + write_log("fifa17: connect hook FAILED\n"); + } + + // WSAConnect IAT fallback (some EA paths use WSAConnect instead of connect). + let wp = crate::iat::resolve(b"ws2_32.dll\0", b"WSAConnect\0"); + if !wp.is_null() { + let f: unsafe extern "system" fn( + usize, + *const u8, + i32, + *const (), + *const (), + *const (), + *const (), + ) -> i32 = core::mem::transmute(wp); + crate::connect_hook::set_real_wsa_connect(f); + crate::iat::patch_iat(wp, crate::connect_hook::hooked_wsa_connect as *const ()); + write_log("fifa17: WSAConnect IAT patched\n"); + } +} + /// 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() { +/// `module` is our own DLL's HMODULE, passed to the worker so it can locate +/// openfut.cfg beside the DLL. +pub unsafe fn install(module: windows_sys::Win32::Foundation::HMODULE) { 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(), + module as *const core::ffi::c_void, 0, core::ptr::null_mut(), ); diff --git a/openfut-hook/src/hooks.rs b/openfut-hook/src/hooks.rs index c9c028c..90884df 100644 --- a/openfut-hook/src/hooks.rs +++ b/openfut-hook/src/hooks.rs @@ -13,6 +13,7 @@ type GetaddrinfoFn = static REAL: OnceLock = OnceLock::new(); static REDIRECT_IP: OnceLock> = OnceLock::new(); +static REDIRECT_IP_STR: OnceLock = OnceLock::new(); // Flipped to true the first time we successfully apply the runtime cert patch. // The patch is deferred to here (rather than DllMain) because EAWebKit.dll may @@ -24,9 +25,21 @@ pub fn set_real(f: GetaddrinfoFn) { } pub fn set_redirect_ip(ip: String) { - let mut bytes = ip.into_bytes(); + let mut bytes = ip.clone().into_bytes(); bytes.push(0); let _ = REDIRECT_IP.set(bytes); + let _ = REDIRECT_IP_STR.set(ip); +} + +/// The redirect IP as a NUL-terminated C string pointer, or None if unset. +/// Used by the resolver detours to rewrite an EA query's node name. +pub fn redirect_ip_cstr() -> Option<*const u8> { + REDIRECT_IP.get().map(|v| v.as_ptr()) +} + +/// The redirect IP as a Rust &str, or None if unset (for the wide/UTF-16 path). +pub fn redirect_ip_str() -> Option<&'static str> { + REDIRECT_IP_STR.get().map(|s| s.as_str()) } /// Returns true if `host` is an EA / EA-Sports domain that should be redirected @@ -68,12 +81,13 @@ pub unsafe extern "system" fn hooked_getaddrinfo( } } - let redirect = REDIRECT_IP - .get() - .map(|v| v.as_ptr()) - .unwrap_or(b"127.0.0.1\0".as_ptr()); - let real = REAL.get().copied().unwrap_or(sys_getaddrinfo); - return real(redirect, service_name, hints, result); + if let Some(redirect) = REDIRECT_IP.get() { + let real = REAL.get().copied().unwrap_or(sys_getaddrinfo); + return real(redirect.as_ptr(), service_name, hints, result); + } + crate::write_log( + "openfut_hook: EA hostname seen without configured server; not redirecting\n", + ); } } } diff --git a/openfut-hook/src/lib.rs b/openfut-hook/src/lib.rs index 0c20865..abb6371 100644 --- a/openfut-hook/src/lib.rs +++ b/openfut-hook/src/lib.rs @@ -11,12 +11,15 @@ mod origin_spy; mod probe; #[cfg(feature = "capture_baseline")] mod recv_hook; +mod resolver_hook; #[cfg(feature = "fifa17")] mod sbc_hook; #[cfg(feature = "fifa17")] mod sbc_request_trace; #[cfg(feature = "fifa17")] mod sbc_trace; +#[cfg(feature = "fifa17")] +mod store_hook; mod ssl_patch; mod tls_bypass; mod transport_watch; @@ -71,8 +74,7 @@ unsafe fn install_hooks(module: HMODULE) { // FIFA-23-specific hook below (they assume FIFA 23's memory layout). #[cfg(feature = "fifa17")] { - let _ = module; - fifa17::install(); + fifa17::install(module); return; } #[cfg(not(feature = "fifa17"))] @@ -85,8 +87,17 @@ unsafe fn install_hooks_fifa23(module: HMODULE) { // 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); + match config::load_config(module).and_then(|c| c.resolve()) { + Ok(server) => { + hooks::set_redirect_ip(server.redirect_ip.to_string()); + connect_hook::set_server(server); + } + Err(e) => { + write_log(&format!( + "openfut_hook: invalid/missing openfut.cfg ({e}); redirection DISABLED\n" + )); + } + } let ga = iat::resolve(b"ws2_32.dll\0", b"getaddrinfo\0"); if !ga.is_null() { diff --git a/openfut-hook/src/resolver_hook.rs b/openfut-hook/src/resolver_hook.rs new file mode 100644 index 0000000..9fe07e5 --- /dev/null +++ b/openfut-hook/src/resolver_hook.rs @@ -0,0 +1,248 @@ +//! DNS-resolver inline detours (getaddrinfo / GetAddrInfoW / gethostbyname). +//! +//! WHY THIS EXISTS (FIFA 17): the IAT approach in `hooks.rs` patched **0** slots on +//! FIFA 17 (`getaddrinfo IAT patched 0+0`) because the game does not import the +//! resolver through its import table — it resolves EA hostnames via a path the IAT +//! scan never covers (dynamic `GetProcAddress`, a statically-linked DirtySDK +//! resolver, or the legacy `gethostbyname`). An IAT patch can only rewrite callers +//! that go through the table, so it missed every real resolution. +//! +//! FIX: detour the resolver **at its export address** in ws2_32.dll, exactly like +//! `connect_hook` does for `connect`. An inline JMP at the function entry catches +//! *every* caller regardless of how it found the function. We use the same +//! unhook → call real → rehook pattern (no trampoline, no RIP relocation). +//! +//! We cover three resolvers: +//! - `getaddrinfo` (ANSI, modern) +//! - `GetAddrInfoW` (wide, modern) — EAWebKit/WinHTTP often use the W variant +//! - `gethostbyname` (legacy, DirtySDK-era) — returns a `hostent` +//! +//! On an EA hostname we rewrite the query node to the configured redirect IP so the +//! real resolver returns the bridge's address. The redirect IP string is owned by +//! `hooks` (set once via `hooks::set_redirect_ip`); we read it back through +//! `hooks::redirect_ip_cstr()`. + +use std::ffi::CStr; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use windows_sys::Win32::Networking::WinSock::ADDRINFOA; + +// ── EA host classifier (shared logic mirrors hooks::is_ea_host) ──────────────── + +fn is_ea_host(host: &str) -> bool { + let h = host.to_ascii_lowercase(); + h.ends_with(".ea.com") + || h == "ea.com" + || h.ends_with(".easports.com") + || h == "easports.com" + || h.ends_with(".ugc.footapi.com") + || h.ends_with(".footapi.com") + || h.ends_with(".dice.se") +} + +// ── getaddrinfo (ANSI) ──────────────────────────────────────────────────────── + +type GetaddrinfoFn = + unsafe extern "system" fn(*const u8, *const u8, *const ADDRINFOA, *mut *mut ADDRINFOA) -> i32; + +static GAI_ADDR: AtomicUsize = AtomicUsize::new(0); +static mut GAI_ORIG: [u8; 14] = [0u8; 14]; + +// ── GetAddrInfoW (wide) ─────────────────────────────────────────────────────── + +type GetAddrInfoWFn = unsafe extern "system" fn( + *const u16, + *const u16, + *const core::ffi::c_void, + *mut *mut core::ffi::c_void, +) -> i32; + +static GAIW_ADDR: AtomicUsize = AtomicUsize::new(0); +static mut GAIW_ORIG: [u8; 14] = [0u8; 14]; + +// ── gethostbyname (legacy) ──────────────────────────────────────────────────── + +type GethostbynameFn = unsafe extern "system" fn(*const u8) -> *mut core::ffi::c_void; + +static GHBN_ADDR: AtomicUsize = AtomicUsize::new(0); +static mut GHBN_ORIG: [u8; 14] = [0u8; 14]; + +// ── inline-hook primitives (identical pattern to connect_hook) ──────────────── + +unsafe fn write_hook(target: *mut u8, dest: u64) { + use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE}; + let mut old: u32 = 0; + VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old); + // FF 25 00 00 00 00 JMP [rip+0] ; then 8-byte absolute target + target.write(0xFF); + target.add(1).write(0x25); + (target.add(2) as *mut u32).write(0u32); + (target.add(6) as *mut u64).write(dest); + VirtualProtect(target as _, 14, old, &mut old); +} + +unsafe fn restore(target: *mut u8, orig: *const u8) { + use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE}; + let mut old: u32 = 0; + VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old); + core::ptr::copy_nonoverlapping(orig, target, 14); + VirtualProtect(target as _, 14, old, &mut old); +} + +/// Save the first 14 bytes at `addr` into `orig`, store `addr`, and write the JMP. +unsafe fn install_one(addr: *mut u8, orig: *mut u8, slot: &AtomicUsize, hook: *const ()) -> bool { + if addr.is_null() { + return false; + } + core::ptr::copy_nonoverlapping(addr, orig, 14); + slot.store(addr as usize, Ordering::Relaxed); + write_hook(addr, hook as u64); + true +} + +// ── hooked entry points ─────────────────────────────────────────────────────── + +pub unsafe extern "system" fn hooked_getaddrinfo( + node: *const u8, + service: *const u8, + hints: *const ADDRINFOA, + result: *mut *mut ADDRINFOA, +) -> i32 { + let addr = GAI_ADDR.load(Ordering::Relaxed) as *mut u8; + let mut redirected = node; + let redirect_cstr = crate::hooks::redirect_ip_cstr(); + + if !node.is_null() { + if let Ok(host) = CStr::from_ptr(node as *const i8).to_str() { + crate::write_log(&format!("resolver: getaddrinfo({host})\n")); + if is_ea_host(host) { + if let Some(ip) = redirect_cstr { + redirected = ip; + crate::write_log(&format!("resolver: getaddrinfo {host} → redirect\n")); + } + } + } + } + + restore(addr, core::ptr::addr_of!(GAI_ORIG) as *const u8); + let r = { + let f: GetaddrinfoFn = core::mem::transmute(addr); + f(redirected, service, hints, result) + }; + write_hook(addr, hooked_getaddrinfo as *const () as u64); + r +} + +pub unsafe extern "system" fn hooked_getaddrinfo_w( + node: *const u16, + service: *const u16, + hints: *const core::ffi::c_void, + result: *mut *mut core::ffi::c_void, +) -> i32 { + let addr = GAIW_ADDR.load(Ordering::Relaxed) as *mut u8; + + // Decode the wide hostname for classification/logging. + let mut redirected_buf: Vec = Vec::new(); + let mut redirected = node; + if !node.is_null() { + let mut len = 0usize; + while *node.add(len) != 0 { + len += 1; + } + let host = String::from_utf16_lossy(core::slice::from_raw_parts(node, len)); + crate::write_log(&format!("resolver: GetAddrInfoW({host})\n")); + if is_ea_host(&host) { + if let Some(ip) = crate::hooks::redirect_ip_str() { + redirected_buf = ip.encode_utf16().chain(core::iter::once(0)).collect(); + redirected = redirected_buf.as_ptr(); + crate::write_log(&format!("resolver: GetAddrInfoW {host} → redirect\n")); + } + } + } + + restore(addr, core::ptr::addr_of!(GAIW_ORIG) as *const u8); + let r = { + let f: GetAddrInfoWFn = core::mem::transmute(addr); + f(redirected, service, hints, result) + }; + write_hook(addr, hooked_getaddrinfo_w as *const () as u64); + // keep redirected_buf alive until after the call + drop(redirected_buf); + r +} + +pub unsafe extern "system" fn hooked_gethostbyname(name: *const u8) -> *mut core::ffi::c_void { + let addr = GHBN_ADDR.load(Ordering::Relaxed) as *mut u8; + let mut redirected = name; + let redirect_cstr = crate::hooks::redirect_ip_cstr(); + + if !name.is_null() { + if let Ok(host) = CStr::from_ptr(name as *const i8).to_str() { + crate::write_log(&format!("resolver: gethostbyname({host})\n")); + if is_ea_host(host) { + if let Some(ip) = redirect_cstr { + redirected = ip; + crate::write_log(&format!("resolver: gethostbyname {host} → redirect\n")); + } + } + } + } + + restore(addr, core::ptr::addr_of!(GHBN_ORIG) as *const u8); + let r = { + let f: GethostbynameFn = core::mem::transmute(addr); + f(redirected) + }; + write_hook(addr, hooked_gethostbyname as *const () as u64); + r +} + +// ── installer ───────────────────────────────────────────────────────────────── + +/// Install inline detours on all three resolvers. Returns a (ok, total) count for +/// logging. Safe to call once from the fifa17 worker after ws2_32 is loaded. +pub unsafe fn install_resolver_hooks() -> (u32, u32) { + let mut ok = 0u32; + let total = 3u32; + + let gai = crate::iat::resolve(b"ws2_32.dll\0", b"getaddrinfo\0") as *mut u8; + if install_one( + gai, + core::ptr::addr_of_mut!(GAI_ORIG) as *mut u8, + &GAI_ADDR, + hooked_getaddrinfo as *const (), + ) { + ok += 1; + crate::write_log("resolver: getaddrinfo inline-hooked\n"); + } else { + crate::write_log("resolver: getaddrinfo resolve FAILED\n"); + } + + let gaiw = crate::iat::resolve(b"ws2_32.dll\0", b"GetAddrInfoW\0") as *mut u8; + if install_one( + gaiw, + core::ptr::addr_of_mut!(GAIW_ORIG) as *mut u8, + &GAIW_ADDR, + hooked_getaddrinfo_w as *const (), + ) { + ok += 1; + crate::write_log("resolver: GetAddrInfoW inline-hooked\n"); + } else { + crate::write_log("resolver: GetAddrInfoW resolve FAILED\n"); + } + + let ghbn = crate::iat::resolve(b"ws2_32.dll\0", b"gethostbyname\0") as *mut u8; + if install_one( + ghbn, + core::ptr::addr_of_mut!(GHBN_ORIG) as *mut u8, + &GHBN_ADDR, + hooked_gethostbyname as *const (), + ) { + ok += 1; + crate::write_log("resolver: gethostbyname inline-hooked\n"); + } else { + crate::write_log("resolver: gethostbyname resolve FAILED\n"); + } + + (ok, total) +} diff --git a/openfut-hook/src/store_hook.rs b/openfut-hook/src/store_hook.rs new file mode 100644 index 0000000..615a104 --- /dev/null +++ b/openfut-hook/src/store_hook.rs @@ -0,0 +1,445 @@ +//! FIFA 17 empty-"My Packs" client fix (config flag `store_mypacks_fix=1`). +//! +//! ## What this does +//! When the account owns **zero unopened packs**, FIFA 17's Store still selects the +//! "My Packs" category on open. CardsDLL's category resolver (`FUN_1800147f0` → +//! `FUN_180014420`) then looks up the My-Packs group ordinal and, if no such group +//! exists, dereferences a NULL group pointer → crash (`0x180014882`, read of `0x48`). +//! The backend currently avoids this with an active placeholder pack (sentinel 65534) +//! that leaves a fake empty tile. +//! +//! This hook removes the need for that sentinel *for a validated build*: it detours the +//! Store render entry `FUN_18007dab0` and, **only when the requested category is My Packs +//! AND the client's unopened-pack count is 0**, rewrites the requested category id at +//! `screen+0x290` to `0` (list-all = "Browse Packs"). The Store then opens on Browse +//! Packs, never resolves the absent My-Packs group, and neither crashes nor shows a fake +//! tile. With a real unopened pack (count > 0) nothing is changed and My Packs works +//! normally. +//! +//! ## Safety model +//! - **Inert unless enabled**: reads `store_mypacks_fix` from `openfut.cfg`; default OFF. +//! - **Validated build only**: refuses to install unless CardsDLL matches the known FIFA +//! 17 build (PE timestamp + SizeOfImage + a slide-proof control prologue + the target +//! function's own prologue signature). An unknown build → no patch, log, and the +//! backend sentinel remains the fallback. +//! - **Deferred**: CardsDLL loads lazily on entering Ultimate Team, so we poll off the +//! loader lock, exactly like `sbc_hook`. +//! - **Fail-safe count**: if the unopened-pack count cannot be read, we DO NOT redirect +//! (leave the category unchanged and call the original) — never a forced Browse. +//! - **Inline detour**: same proven `unhook → call real → rehook` primitive as +//! `resolver_hook`/`connect_hook` (no trampoline, no RIP relocation). +//! +//! Addresses are RVAs (static VA − image base `0x180000000`); see +//! `docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md` PART II for the disassembly evidence. + +use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + +use windows_sys::Win32::Foundation::HMODULE; +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_READONLY, + PAGE_READWRITE, PAGE_WRITECOPY, +}; + +// ── Build identity (verified against CardsDLL_Win64_retail.dll 4706a881…) ──────── +const IMAGE_BASE: usize = 0x1_8000_0000; +const PE_TIMESTAMP: u32 = 1_497_050_156; // 2017-06-09T23:15:56Z +const SIZE_OF_IMAGE: u32 = 0x31d000; +/// Slide-proof FNV-hasher control prologue at VA 0x180180d00 (same control sbc_hook uses). +const CTRL_RVA: usize = 0x180d00; +const CTRL_BYTES: [u8; 12] = [ + 0x48, 0x83, 0xec, 0x28, 0x48, 0x85, 0xc9, 0x74, 0x50, 0x45, 0x33, 0xc0, +]; + +// ── Target + helper RVAs ───────────────────────────────────────────────────────── +/// FUN_18007dab0 — Store render entry (Flash message 0x753f). arg0 = store screen (RCX). +const RENDER_RVA: usize = 0x7dab0; +/// First 14 bytes of FUN_18007dab0 (PUSH RDI; SUB RSP,0x40; MOV [RSP+0x30],-2 …). +/// Doubles as the target-site signature and the bytes we save/restore for the detour. +const RENDER_PROLOGUE: [u8; 14] = [ + 0x40, 0x57, 0x48, 0x83, 0xec, 0x40, 0x48, 0xc7, 0x44, 0x24, 0x30, 0xfe, 0xff, 0xff, +]; +/// FUN_180014580(store, tab) → category id (1-based group ordinal, or -1 if absent). +const TABMAP_RVA: usize = 0x14580; +/// FUN_1800d7170() → registry (no args). +const REGISTRY_GETTER_RVA: usize = 0xd7170; +/// FUN_180009c80(out, registry, 0, 0) → writes the data-manager singleton into *out. +const MANAGER_GETTER_RVA: usize = 0x9c80; +/// manager->vtbl[+0x4d8]() → unopened-pack count (i32). +const UNOPENED_COUNT_VSLOT: usize = 0x4d8; +/// manager->vtbl[+0x08]() → release. +const RELEASE_VSLOT: usize = 0x08; +/// screen+0x290 = requested CATEGORY_ID (movie-written; the resolver's input). +const SCREEN_CATEGORY_OFF: usize = 0x290; +/// FUN_180014580 tab index for "mypacks". +const MYPACKS_TAB: u32 = 0; +/// Category 0 = list-all group tiles = "Browse Packs". +const CAT_BROWSE: i32 = 0; + +// ── State ──────────────────────────────────────────────────────────────────────── +static ENABLED: AtomicBool = AtomicBool::new(false); +static INSTALLED: AtomicBool = AtomicBool::new(false); +static CARDS_BASE: AtomicUsize = AtomicUsize::new(0); +static RENDER_ADDR: AtomicUsize = AtomicUsize::new(0); +static mut RENDER_ORIG: [u8; 14] = [0u8; 14]; + +// ── Internal CardsDLL function types (MS x64 ABI) ───────────────────────────────── +type RegistryGetterFn = unsafe extern "system" fn() -> usize; +type ManagerGetterFn = unsafe extern "system" fn(*mut usize, usize, usize, usize) -> *mut usize; +type TabMapFn = unsafe extern "system" fn(usize, u32) -> u32; +type CountGetterFn = unsafe extern "system" fn(usize) -> i32; +type ReleaseFn = unsafe extern "system" fn(usize); +type RenderFn = unsafe extern "system" fn(usize) -> usize; + +// ── Pure decision (host-testable; the correctness core) ─────────────────────────── +/// Redirect the Store to Browse Packs iff the feature is enabled, the requested +/// category is exactly the My-Packs category, and the client owns zero unopened packs. +/// A `None` count (read failed) is treated as "do not redirect". +fn should_redirect(enabled: bool, count: Option, requested: i32, mypacks: i32) -> bool { + enabled && requested == mypacks && count == Some(0) +} + +// ── Guarded memory access (no blind dereferences) ───────────────────────────────── +unsafe fn readable(ptr: usize, len: usize) -> bool { + if ptr < 0x1_0000 || len == 0 { + return false; + } + let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); + let n = VirtualQuery( + ptr as _, + &mut mbi, + core::mem::size_of::(), + ); + if n == 0 || mbi.State != MEM_COMMIT { + return false; + } + let prot = mbi.Protect; + if prot & PAGE_GUARD != 0 || prot == PAGE_NOACCESS { + return false; + } + const READABLE: u32 = PAGE_READONLY + | PAGE_READWRITE + | PAGE_WRITECOPY + | PAGE_EXECUTE_READ + | PAGE_EXECUTE_READWRITE + | PAGE_EXECUTE_WRITECOPY; + if prot & READABLE == 0 { + return false; + } + let region_end = mbi.BaseAddress as usize + mbi.RegionSize; + ptr.checked_add(len).is_some_and(|end| end <= region_end) +} + +unsafe fn writable(ptr: usize, len: usize) -> bool { + if ptr < 0x1_0000 || len == 0 { + return false; + } + let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); + let n = VirtualQuery( + ptr as _, + &mut mbi, + core::mem::size_of::(), + ); + if n == 0 || mbi.State != MEM_COMMIT { + return false; + } + let prot = mbi.Protect; + if prot & PAGE_GUARD != 0 { + return false; + } + const WRITABLE: u32 = + PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY; + if prot & WRITABLE == 0 { + return false; + } + let region_end = mbi.BaseAddress as usize + mbi.RegionSize; + ptr.checked_add(len).is_some_and(|end| end <= region_end) +} + +unsafe fn executable(ptr: usize) -> bool { + if ptr < 0x1_0000 { + return false; + } + let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed(); + let n = VirtualQuery( + ptr as _, + &mut mbi, + core::mem::size_of::(), + ); + if n == 0 || mbi.State != MEM_COMMIT { + return false; + } + const EXEC: u32 = PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY; + mbi.Protect & PAGE_GUARD == 0 && mbi.Protect & EXEC != 0 +} + +unsafe fn read_u8(ptr: usize) -> Option { + readable(ptr, 1).then(|| *(ptr as *const u8)) +} + +unsafe fn read_u32(ptr: usize) -> Option { + (ptr & 3 == 0 && readable(ptr, 4)).then(|| *(ptr as *const u32)) +} + +unsafe fn read_ptr(ptr: usize) -> Option { + (ptr & 7 == 0 && readable(ptr, 8)).then(|| *(ptr as *const usize)) +} + +unsafe fn bytes_match(addr: usize, want: &[u8]) -> bool { + want.iter() + .enumerate() + .all(|(i, &b)| read_u8(addr + i) == Some(b)) +} + +// ── Inline-hook primitive (identical to resolver_hook/connect_hook) ─────────────── +unsafe fn write_hook(target: *mut u8, dest: u64) { + let mut old: u32 = 0; + VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old); + // FF 25 00 00 00 00 JMP [rip+0] ; then 8-byte absolute target + target.write(0xFF); + target.add(1).write(0x25); + (target.add(2) as *mut u32).write(0u32); + (target.add(6) as *mut u64).write(dest); + VirtualProtect(target as _, 14, old, &mut old); +} + +unsafe fn restore(target: *mut u8, orig: *const u8) { + let mut old: u32 = 0; + VirtualProtect(target as _, 14, PAGE_EXECUTE_READWRITE, &mut old); + core::ptr::copy_nonoverlapping(orig, target, 14); + VirtualProtect(target as _, 14, old, &mut old); +} + +// ── Runtime helpers ──────────────────────────────────────────────────────────── +unsafe fn resolve_cards_base() -> usize { + let h = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr() as *const u8); + if !h.is_null() { + return h as usize; + } + let h2 = GetModuleHandleA(c"CardsDLL.dll".as_ptr() as *const u8); + if !h2.is_null() { + return h2 as usize; + } + 0 +} + +/// Read the client's unopened-pack count via the data-manager singleton +/// (`registry → manager → vtbl[0x4d8]`), releasing the manager afterwards. Returns +/// `None` on any unreadable pointer/vtable so the caller never redirects on a bad read. +unsafe fn read_unopened_count(base: usize) -> Option { + if !executable(base + REGISTRY_GETTER_RVA) || !executable(base + MANAGER_GETTER_RVA) { + return None; + } + let registry_getter: RegistryGetterFn = core::mem::transmute(base + REGISTRY_GETTER_RVA); + let registry = registry_getter(); + if registry == 0 { + return None; + } + let manager_getter: ManagerGetterFn = core::mem::transmute(base + MANAGER_GETTER_RVA); + let mut out: usize = 0; + manager_getter(&mut out, registry, 0, 0); + let manager = out; + if manager == 0 { + return None; + } + let vtbl = read_ptr(manager)?; + let count_fn = read_ptr(vtbl + UNOPENED_COUNT_VSLOT)?; + let release_fn = read_ptr(vtbl + RELEASE_VSLOT)?; + if !executable(count_fn) || !executable(release_fn) { + return None; + } + let getter: CountGetterFn = core::mem::transmute(count_fn); + let count = getter(manager); + let release: ReleaseFn = core::mem::transmute(release_fn); + release(manager); + Some(count) +} + +/// The redirect decision + write, executed before the original render runs. +unsafe fn maybe_redirect(store: usize) { + if store == 0 { + return; + } + let base = CARDS_BASE.load(Ordering::Relaxed); + if base == 0 { + return; + } + let cat_ptr = store + SCREEN_CATEGORY_OFF; + if !readable(cat_ptr, 4) { + return; + } + let requested = *(cat_ptr as *const i32); + if !executable(base + TABMAP_RVA) { + return; + } + let tabmap: TabMapFn = core::mem::transmute(base + TABMAP_RVA); + let mypacks_id = tabmap(store, MYPACKS_TAB) as i32; + // Only pay for the count read when the requested category is actually My Packs. + if requested != mypacks_id { + return; + } + let count = read_unopened_count(base); + if should_redirect( + ENABLED.load(Ordering::Relaxed), + count, + requested, + mypacks_id, + ) { + if writable(cat_ptr, 4) { + *(cat_ptr as *mut i32) = CAT_BROWSE; + crate::write_log("[store-hook] zero unopened packs: My Packs -> Browse Packs\n"); + } else { + crate::write_log("[store-hook] category slot not writable; left unchanged\n"); + } + } + // requested == mypacks with count > 0 or unknown: leave My Packs unchanged. +} + +pub unsafe extern "system" fn hooked_render(store: usize) -> usize { + let addr = RENDER_ADDR.load(Ordering::Relaxed) as *mut u8; + if addr.is_null() { + return 0; + } + maybe_redirect(store); + restore(addr, core::ptr::addr_of!(RENDER_ORIG) as *const u8); + let r = { + let f: RenderFn = core::mem::transmute(addr as *const ()); + f(store) + }; + write_hook(addr, hooked_render as *const () as u64); + r +} + +// ── Build guard + install ───────────────────────────────────────────────────── +unsafe fn build_supported(base: usize) -> bool { + let fail = |why: &str| { + crate::write_log(&format!( + "[store-hook] CardsDLL build UNSUPPORTED ({why}); not installing (backend sentinel remains)\n" + )); + false + }; + let Some(e_lfanew) = read_u32(base + 0x3c) else { + return fail("PE header unreadable"); + }; + let pe = base + e_lfanew as usize; + if read_u32(pe) != Some(0x0000_4550) { + return fail("PE signature"); + } + if read_u32(pe + 8) != Some(PE_TIMESTAMP) { + return fail("PE timestamp"); + } + if read_u32(pe + 24 + 0x38) != Some(SIZE_OF_IMAGE) { + return fail("SizeOfImage"); + } + if !bytes_match(base + CTRL_RVA, &CTRL_BYTES) { + return fail("control prologue"); + } + if !bytes_match(base + RENDER_RVA, &RENDER_PROLOGUE) { + return fail("FUN_18007dab0 prologue"); + } + true +} + +/// Deferred worker: CardsDLL loads only on entering Ultimate Team, so poll for it +/// (≤5 min) off the loader lock, then validate the build and install the detour 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("[store-hook] CardsDLL never loaded; hook not installed\n"); + return; + } + let slide = base.wrapping_sub(IMAGE_BASE); + crate::write_log(&format!( + "[store-hook] CardsDLL base={base:#x} slide={slide:#x}; validating build\n" + )); + if !build_supported(base) { + return; + } + CARDS_BASE.store(base, Ordering::Relaxed); + let render = base + RENDER_RVA; + core::ptr::copy_nonoverlapping( + render as *const u8, + core::ptr::addr_of_mut!(RENDER_ORIG) as *mut u8, + 14, + ); + RENDER_ADDR.store(render, Ordering::Relaxed); + write_hook(render as *mut u8, hooked_render as *const () as u64); + INSTALLED.store(true, Ordering::Relaxed); + crate::write_log(&format!( + "[store-hook] build supported; installed at CardsDLL+{RENDER_RVA:#x} (VA {render:#x})\n" + )); +} + +/// Public entry, called from `fifa17::worker`. Reads `store_mypacks_fix` from +/// `openfut.cfg`; if enabled, spawns the deferred CardsDLL-load worker. Fully inert +/// otherwise (no thread, no patch). +pub fn install(module: HMODULE) { + let enabled = match crate::config::feature_value(module, "store_mypacks_fix").as_deref() { + Some("1") => true, + Some("0") | None => false, + Some(other) => { + crate::write_log(&format!( + "[store-hook] invalid store_mypacks_fix={other:?}; feature disabled\n" + )); + false + } + }; + ENABLED.store(enabled, Ordering::Relaxed); + if !enabled { + crate::write_log("[store-hook] disabled (set store_mypacks_fix=1 in openfut.cfg)\n"); + return; + } + crate::write_log("[store-hook] enabled; deferring until CardsDLL loads\n"); + std::thread::spawn(|| unsafe { worker() }); +} + +#[cfg(test)] +mod tests { + use super::should_redirect; + + #[test] + fn disabled_never_redirects() { + assert!(!should_redirect(false, Some(0), 3, 3)); + } + + #[test] + fn enabled_zero_mypacks_redirects() { + assert!(should_redirect(true, Some(0), 3, 3)); + } + + #[test] + fn enabled_one_pack_keeps_mypacks() { + assert!(!should_redirect(true, Some(1), 3, 3)); + } + + #[test] + fn enabled_zero_browse_untouched() { + // requested Browse (0) != mypacks ordinal (3) + assert!(!should_redirect(true, Some(0), 0, 3)); + } + + #[test] + fn enabled_zero_other_tab_untouched() { + // e.g. bronze ordinal 4 != mypacks 3 + assert!(!should_redirect(true, Some(0), 4, 3)); + } + + #[test] + fn enabled_unknown_count_does_not_redirect() { + assert!(!should_redirect(true, None, 3, 3)); + } + + #[test] + fn enabled_zero_absent_mypacks_group_redirects() { + // With no sentinel, both the requested id and mypacks id are -1 (group absent). + assert!(should_redirect(true, Some(0), -1, -1)); + } +} -- 2.47.3