//! FIFA 17 client patcher — the ProtoSSL cert gates and the CardsDLL FUT store //! patches, applied to a running `FIFA17.exe` through `/proc//mem`. //! //! Port of `fifa17-recon/tools/autopatch.py`; that file is the specification and //! every address, byte pattern and log line here is reproduced from it verbatim. //! The store patches are re-applied on every tick because the game rewrites //! those sites; the cert gates are applied once per pid. //! //! This module holds the constants, the two pure decision functions and the //! parsers. The enforcement passes live in [`patch`], process access in //! [`procmem`], timestamping in [`localtime`], and the watch loop in `main.rs`. pub mod localtime; pub mod logging; pub mod patch; pub mod procmem; use std::fmt; pub use logging::Logger; /// `/proc//comm` of the client we patch (exact match after trimming). pub const CLIENT_COMM: &str = "FIFA17.exe"; /// Substring identifying the CardsDLL mapping in `/proc//maps`. pub const CARDSDLL_MARKER: &str = "CardsDLL"; // --------------------------------------------------------------------------- // ProtoSSL certificate gates (absolute VAs in the unpacked FIFA17.exe image; // present only once the packer has mapped the real code, hence the watch loop). // --------------------------------------------------------------------------- pub const GATE2: u64 = 0x1461361b0; pub const GATE2_ORIG: [u8; 3] = [0x48, 0x89, 0x5c]; pub const GATE2_PATCH: [u8; 3] = [0x31, 0xc0, 0xc3]; pub const GATE1: u64 = 0x146132548; pub const GATE1_ORIG: [u8; 6] = [0x0f, 0x85, 0x76, 0x01, 0x00, 0x00]; pub const GATE1_PATCH: [u8; 6] = [0x90; 6]; // --------------------------------------------------------------------------- // FUT store patches, expressed against the CardsDLL preferred image base and // relocated to the live mapping base at runtime. // --------------------------------------------------------------------------- /// `mov eax, 1; ret` — force a predicate true. pub const RET_TRUE: [u8; 6] = [0xb8, 0x01, 0x00, 0x00, 0x00, 0xc3]; /// Two `nop`s. pub const NOP2: [u8; 2] = [0x90, 0x90]; /// `jmp +0x3f`, the one non-uniform store patch (site `0x180017543`). pub const SHORT_JMP_3F: [u8; 2] = [0xeb, 0x3f]; /// CardsDLL's preferred image base; live address = `cbase + (va - IMG_BASE)`. pub const IMG_BASE: u64 = 0x180000000; /// Unconditional store patches, in the Python's insertion order — the order /// decides the order of the `ENFORCED store patch` log lines. /// /// The Python carries no per-site rationale for these eight sites, so none is /// invented here; the addresses and bytes are reproduced verbatim. pub const STORE_PATCHES: [(u64, &[u8]); 8] = [ (0x1800f7fb0, &RET_TRUE), (0x1800fb850, &RET_TRUE), (0x180100500, &RET_TRUE), (0x180013cf0, &RET_TRUE), (0x180017543, &SHORT_JMP_3F), (0x180017487, &NOP2), (0x180017490, &NOP2), (0x1800175aa, &NOP2), ]; /// Store resolver crash-guard for the empty "My Packs" case (bug 6c; PROVEN R1 on the /// tested FIFA 17 build -- see docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md PART IV and /// docs/evidence/FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md). /// /// When no `mypacks` group exists, FIFA's Store resolver receives category id -1. CardsDLL /// FUN_1800147f0 @ 0x180014858 is `JNZ 0x14869` (75 0f): the original treats every non-zero /// category (including -1) as resolvable, calls FUN_180014420, gets NULL, and crashes at the /// \[NULL+0x48\] deref in FUN_1800147f0 (0x180014882). Changing JNZ->JG (7f 0f) preserves /// positive-category resolution (EDI>0 branch) while routing zero/negative categories through /// the existing Browse/list-all path -> no NULL lookup, no crash, Store opens on Browse Packs. /// /// CAVEAT: this guards the category SIGN only. It does NOT protect a stale *positive* invalid /// ordinal produced by changing the Store group topology (sentinel-present <-> sentinel-absent) /// DURING one running FIFA process -- that reproduced the same crash in the confounded run F3. /// The empty-My-Packs representation MUST stay stable for a FIFA session (see the SESSION-STABLE /// invariant in the client-fix plan). /// /// Orig-verified / fail-closed: applied only when the live bytes are the known original (75 0f); /// already-patched (7f 0f) is a no-op; anything else is logged and SKIPPED (never blindly /// overwritten), so an unrecognised CardsDLL build is not patched. /// /// Tuple layout: `(va, orig, patch)`. `JNZ 0x14869` -> `JG 0x14869`. pub const STORE_PATCHES_GUARDED: [(u64, &[u8], &[u8]); 1] = [(0x180014858, &[0x75, 0x0f], &[0x7f, 0x0f])]; /// Capability advertised to the launcher/backend once the resolver guard is VERIFIED /// live in a specific FIFA process (docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md #3/#4). pub const EMPTY_MYPACKS_RESOLVER_CAPABILITY: &str = "fifa17.empty_mypacks_resolver"; pub const EMPTY_MYPACKS_RESOLVER_VERSION: u32 = 1; /// The guarded site whose verified enforcement backs the capability above. pub const RESOLVER_GUARD_VA: u64 = 0x180014858; /// Longest patch payload, so the watch loop can compare live bytes on the stack. pub const MAX_PATCH_LEN: usize = RET_TRUE.len(); // Compile-time proof that the watch loop's stack buffers are large enough, so no // slicing panic is reachable from the patch tables. const _: () = { let mut i = 0; while i < STORE_PATCHES.len() { assert!(STORE_PATCHES[i].1.len() <= MAX_PATCH_LEN); i += 1; } let mut i = 0; while i < STORE_PATCHES_GUARDED.len() { assert!(STORE_PATCHES_GUARDED[i].1.len() <= MAX_PATCH_LEN); assert!(STORE_PATCHES_GUARDED[i].2.len() <= MAX_PATCH_LEN); assert!(STORE_PATCHES_GUARDED[i].1.len() == STORE_PATCHES_GUARDED[i].2.len()); i += 1; } }; /// Fail-closed decision for a guarded byte patch (see [`STORE_PATCHES_GUARDED`]). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GuardedAction { /// Live bytes are already the patch; nothing to write. Noop, /// Live bytes are the known original; safe to apply. Patch, /// Unrecognised CardsDLL build — never blindly overwritten. Skip, } /// Per-FIFA-pid guard status (fail-closed; FIFA17_PATCHED_CLIENT_CAPABILITY.md #4). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GuardState { /// CardsDLL not mapped / guard not yet evaluated. NotAttempted, /// Live bytes == patch after enforcement (patch or noop). Verified, /// Neither original nor patched ([`GuardedAction::Skip`]). UnsupportedBuild, /// The `/proc//mem` write failed. WriteFailed, /// Post-write re-read != patch. VerifyFailed, } impl GuardState { /// Wire spelling used in the `[store-guard] guard status=…` line the launcher reads. pub fn as_str(self) -> &'static str { match self { GuardState::NotAttempted => "NOT_ATTEMPTED", GuardState::Verified => "VERIFIED", GuardState::UnsupportedBuild => "UNSUPPORTED_BUILD", GuardState::WriteFailed => "WRITE_FAILED", GuardState::VerifyFailed => "VERIFY_FAILED", } } } impl fmt::Display for GuardState { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) } } /// Fail-closed decision for a guarded byte patch. /// /// [`GuardedAction::Noop`] when the live bytes are already patched, /// [`GuardedAction::Patch`] when they are the known original (safe to apply), or /// [`GuardedAction::Skip`] for anything else — an unrecognised CardsDLL build /// that must never be blindly overwritten. pub fn guarded_action(cur: &[u8], orig: &[u8], patch: &[u8]) -> GuardedAction { if cur == patch { return GuardedAction::Noop; } if cur == orig { return GuardedAction::Patch; } GuardedAction::Skip } /// Map a guarded-patch enforcement outcome to a per-pid guard STATE (pure). /// /// Mirrors [`guarded_action`]'s decision, extended with post-write verification so the /// caller advertises the capability only on VERIFIED. No `/proc` access — unit-testable. /// /// - `cur_before == patch` -> VERIFIED (already patched; [`GuardedAction::Noop`]) /// - `cur_before == orig` -> WRITE_FAILED if the write raised, else VERIFIED when the /// re-read is patch, else VERIFY_FAILED ([`GuardedAction::Patch`]) /// - otherwise -> UNSUPPORTED_BUILD ([`GuardedAction::Skip`]) /// /// [`GuardState::NotAttempted`] is never returned: it is the state a pid carries before /// the guard is evaluated at all (CardsDLL not mapped yet), and this function is only /// reached once live bytes have been read. pub fn guard_state_after( cur_before: &[u8], orig: &[u8], patch: &[u8], wrote_ok: bool, cur_after: &[u8], ) -> GuardState { if cur_before == patch { return GuardState::Verified; } if cur_before == orig { if !wrote_ok { return GuardState::WriteFailed; } if cur_after == patch { return GuardState::Verified; } return GuardState::VerifyFailed; } GuardState::UnsupportedBuild } /// Live address of an image-relative patch site inside the mapped CardsDLL. pub fn live_addr(cbase: u64, va: u64) -> u64 { cbase + (va - IMG_BASE) } /// Lowercase, unseparated hex — the spelling of `bytes.hex()` in the SKIP log line. pub fn hex(bytes: &[u8]) -> String { let mut out = String::with_capacity(bytes.len() * 2); for b in bytes { // Two nibbles, no separator, no allocation per byte. const DIGITS: &[u8; 16] = b"0123456789abcdef"; out.push(DIGITS[(b >> 4) as usize] as char); out.push(DIGITS[(b & 0x0f) as usize] as char); } out } /// True when a `/proc//comm` body names the FIFA 17 client. pub fn is_client_comm(comm: &str) -> bool { comm.trim() == CLIENT_COMM } /// Pid from a `/proc` directory entry name. /// /// The Python globs `/proc/[0-9]*` and then `int()`s the name inside a bare /// `except`, so a name has to start with a digit *and* be entirely numeric. pub fn pid_from_proc_entry(name: &str) -> Option { if !name.as_bytes().first().is_some_and(u8::is_ascii_digit) { return None; } name.parse::().ok() } /// Base address of the first `CardsDLL` mapping in a `/proc//maps` body. /// /// Only the first matching line is considered, and a line whose base does not /// parse yields `None` rather than falling through to the next mapping — the /// Python's `int(...)` raises inside the `try` that returns `None`. pub fn parse_cardsdll_base(maps: &str) -> Option { let line = maps.lines().find(|line| line.contains(CARDSDLL_MARKER))?; u64::from_str_radix(line.split('-').next()?, 16).ok() } /// `--launcher-pid ` out of the argument list. /// /// `Ok(None)` when the flag is absent, `Err(())` when it is present with a /// missing or non-numeric value (the Python raises `SystemExit`). The value is /// kept signed and un-clamped so the liveness check behaves exactly like the /// Python's `os.path.exists(f"/proc/{launcher_pid}")` for odd inputs. #[allow(clippy::result_unit_err)] pub fn parse_launcher_pid(args: I) -> Result, ()> where I: IntoIterator, S: AsRef, { let args: Vec = args.into_iter().collect(); let Some(idx) = args.iter().position(|a| a.as_ref() == "--launcher-pid") else { return Ok(None); }; let value = args.get(idx + 1).ok_or(())?; value.as_ref().trim().parse::().map(Some).map_err(|_| ()) } #[cfg(test)] mod tests { use super::*; const ORIG: &[u8] = &[0x75, 0x0f]; const PATCH: &[u8] = &[0x7f, 0x0f]; #[test] fn constants_match_the_python_spec() { assert_eq!(GATE2, 0x1461361b0); assert_eq!(GATE2_ORIG, [0x48, 0x89, 0x5c]); assert_eq!(GATE2_PATCH, [0x31, 0xc0, 0xc3]); assert_eq!(GATE1, 0x146132548); assert_eq!(GATE1_ORIG, [0x0f, 0x85, 0x76, 0x01, 0x00, 0x00]); assert_eq!(GATE1_PATCH, [0x90, 0x90, 0x90, 0x90, 0x90, 0x90]); assert_eq!(IMG_BASE, 0x180000000); assert_eq!(hex(&RET_TRUE), "b801000000c3"); assert_eq!(hex(&NOP2), "9090"); // Site order is part of the log contract, so assert the whole table. let sites: Vec<(u64, String)> = STORE_PATCHES .iter() .map(|(va, data)| (*va, hex(data))) .collect(); assert_eq!( sites, vec![ (0x1800f7fb0, "b801000000c3".to_string()), (0x1800fb850, "b801000000c3".to_string()), (0x180100500, "b801000000c3".to_string()), (0x180013cf0, "b801000000c3".to_string()), (0x180017543, "eb3f".to_string()), (0x180017487, "9090".to_string()), (0x180017490, "9090".to_string()), (0x1800175aa, "9090".to_string()), ] ); assert_eq!(STORE_PATCHES_GUARDED.len(), 1); let (va, orig, patch) = STORE_PATCHES_GUARDED[0]; assert_eq!(va, 0x180014858); assert_eq!(hex(orig), "750f"); assert_eq!(hex(patch), "7f0f"); assert_eq!(RESOLVER_GUARD_VA, va); assert_eq!(EMPTY_MYPACKS_RESOLVER_CAPABILITY, "fifa17.empty_mypacks_resolver"); assert_eq!(EMPTY_MYPACKS_RESOLVER_VERSION, 1); assert_eq!(MAX_PATCH_LEN, 6); } #[test] fn guarded_action_noops_when_already_patched() { assert_eq!(guarded_action(PATCH, ORIG, PATCH), GuardedAction::Noop); } #[test] fn guarded_action_patches_the_known_original() { assert_eq!(guarded_action(ORIG, ORIG, PATCH), GuardedAction::Patch); } #[test] fn guarded_action_skips_an_unrecognised_build() { // Fail-closed: an unknown CardsDLL build is never overwritten. assert_eq!(guarded_action(&[0x74, 0x0f], ORIG, PATCH), GuardedAction::Skip); assert_eq!(guarded_action(&[], ORIG, PATCH), GuardedAction::Skip); assert_eq!(guarded_action(&[0x75], ORIG, PATCH), GuardedAction::Skip); } #[test] fn guard_state_verified_when_already_patched() { // wrote_ok / cur_after are irrelevant on this branch. assert_eq!( guard_state_after(PATCH, ORIG, PATCH, false, &[]), GuardState::Verified ); } #[test] fn guard_state_verified_after_a_successful_write() { assert_eq!( guard_state_after(ORIG, ORIG, PATCH, true, PATCH), GuardState::Verified ); } #[test] fn guard_state_write_failed() { assert_eq!( guard_state_after(ORIG, ORIG, PATCH, false, ORIG), GuardState::WriteFailed ); } #[test] fn guard_state_verify_failed() { // Write reported success but the re-read still shows the original … assert_eq!( guard_state_after(ORIG, ORIG, PATCH, true, ORIG), GuardState::VerifyFailed ); // … or could not be re-read at all (the Python's `cur_after = b""`). assert_eq!( guard_state_after(ORIG, ORIG, PATCH, true, &[]), GuardState::VerifyFailed ); } #[test] fn guard_state_unsupported_build() { assert_eq!( guard_state_after(&[0x74, 0x0f], ORIG, PATCH, true, PATCH), GuardState::UnsupportedBuild ); } #[test] fn guard_state_spellings_are_the_launcher_contract() { assert_eq!(GuardState::NotAttempted.to_string(), "NOT_ATTEMPTED"); assert_eq!(GuardState::Verified.to_string(), "VERIFIED"); assert_eq!(GuardState::UnsupportedBuild.to_string(), "UNSUPPORTED_BUILD"); assert_eq!(GuardState::WriteFailed.to_string(), "WRITE_FAILED"); assert_eq!(GuardState::VerifyFailed.to_string(), "VERIFY_FAILED"); } #[test] fn live_addr_relocates_against_the_image_base() { assert_eq!(live_addr(0x7f0000000000, 0x180014858), 0x7f0000014858); assert_eq!(live_addr(IMG_BASE, RESOLVER_GUARD_VA), RESOLVER_GUARD_VA); } #[test] fn hex_is_lowercase_and_unseparated() { assert_eq!(hex(&[0x00, 0x0f, 0xa5, 0xff]), "000fa5ff"); assert_eq!(hex(&[]), ""); } #[test] fn comm_matches_only_the_client() { assert!(is_client_comm("FIFA17.exe\n")); assert!(is_client_comm("FIFA17.exe")); assert!(!is_client_comm("fifa17.exe\n")); assert!(!is_client_comm("FIFA17.exe.bak\n")); assert!(!is_client_comm("wineserver\n")); assert!(!is_client_comm("")); } #[test] fn proc_entry_names_yield_only_numeric_pids() { assert_eq!(pid_from_proc_entry("1"), Some(1)); assert_eq!(pid_from_proc_entry("41234"), Some(41234)); assert_eq!(pid_from_proc_entry("self"), None); assert_eq!(pid_from_proc_entry("1abc"), None); assert_eq!(pid_from_proc_entry("+7"), None); assert_eq!(pid_from_proc_entry(""), None); } #[test] fn cardsdll_base_is_the_first_matching_mapping() { let maps = concat!( "140000000-140001000 r--p 00000000 08:02 12 /home/u/FIFA17.exe\n", "7f2a11c00000-7f2a11c9c000 r-xp 00000000 08:02 44 /home/u/CardsDLL_Win64_retail.dll\n", "7f2a12000000-7f2a12001000 r--p 00000000 08:02 45 /home/u/CardsDLL_second.dll\n", ); assert_eq!(parse_cardsdll_base(maps), Some(0x7f2a11c00000)); } #[test] fn cardsdll_base_absent_or_unparsable() { assert_eq!(parse_cardsdll_base(""), None); let no_dll = "140000000-140001000 r--p 00000000 08:02 12 /home/u/FIFA17.exe\n"; assert_eq!(parse_cardsdll_base(no_dll), None); // A CardsDLL line whose base is not hex: fail, do not fall through. let broken = concat!( "zzzz-140001000 r--p 00000000 08:02 12 /home/u/CardsDLL.dll\n", "7f2a11c00000-7f2a11c9c000 r-xp 0 08:02 44 /home/u/CardsDLL.dll\n", ); assert_eq!(parse_cardsdll_base(broken), None); } #[test] fn launcher_pid_parsing() { assert_eq!(parse_launcher_pid(Vec::::new()), Ok(None)); assert_eq!(parse_launcher_pid(["--other", "3"]), Ok(None)); assert_eq!(parse_launcher_pid(["--launcher-pid", "4242"]), Ok(Some(4242))); assert_eq!( parse_launcher_pid(["-x", "--launcher-pid", "7", "--launcher-pid", "9"]), Ok(Some(7)) ); // Falsy in the Python (`if launcher_pid and ...`): parsed, never watched. assert_eq!(parse_launcher_pid(["--launcher-pid", "0"]), Ok(Some(0))); assert_eq!(parse_launcher_pid(["--launcher-pid", "-5"]), Ok(Some(-5))); assert_eq!(parse_launcher_pid(["--launcher-pid"]), Err(())); assert_eq!(parse_launcher_pid(["--launcher-pid", "abc"]), Err(())); assert_eq!(parse_launcher_pid(["--launcher-pid", ""]), Err(())); } }