Files
OpenFUT/openfut-autopatch/src/lib.rs
T
funman300 750d6c2e18 feat(companions): port the launcher's two Python services to Rust
The launcher spawned `python3 lsx_responder_v2.py` and `python3 autopatch.py`. Both are
now Rust workspace crates, and the launcher spawns the binaries (gitlink 1cd4f18).

openfut-lsx (2244 lines, 57 tests) — EA Origin LSX emulator on loopback 4216.
Dependency-light on purpose: `aes` for the one security-shaped primitive, parking_lot
per the project lock rule. AES-128-ECB is the whole cipher requirement, so the
surrounding framing (PKCS7, lowercase hex, NUL-termination) stays explicit and separate
because it is protocol, not cryptography.

openfut-autopatch (43 tests) — ProtoSSL cert gates plus the CardsDLL store patches,
applied over /proc/<pid>/mem. Deliberately dependency-free: a tool that writes another
process's memory should be auditable end to end without a dependency tree. std has no
getuid and no local-time formatting, so it carries a small TZif reader rather than
pulling in chrono to reproduce Python's strftime('%H:%M:%S').

The Python remains in fifa17-recon/tools. It is NOT dead: the docker entrypoint,
client_arm.sh, the runbooks and test_autopatch_guard.py still use it. Only the
launcher's dependency on Python is gone, which is what was asked for; deleting the
recon toolchain's implementation would have broken unrelated workflows.

VERIFICATION — the ports are checked against the Python, not against themselves:

* Crypto parity across THREE implementations. The Rust tests assert the Rust's own
  constants, which proves consistency, not parity, and the Python cannot run here
  (pycryptodome absent) with the client host unreachable. So the LCG and key derivation
  were transcribed from the Python and run as plain arithmetic, and every AES value came
  from the openssl CLI. All agree: msvcr_rand(7)==61, _TAIL_CONST
  954f64f2e4e86e9eee82d20216684899, the 96-hex emu challenge shape, the derived session
  key 6a9da3e78615153cc2f10eec25ae6382, the framing rule at both boundaries (an aligned
  payload gains a whole block), and the port's pinned 4-block login-frame ciphertext.
* LSX end to end on the real port. 4216 here is a docker forward into the production
  netns, so the smoke test runs under `unshare -n` — the real binary on the port the
  client actually dials, with no port-override hack and no risk to production. A
  hand-written client read the unprompted <Challenge>, completed the handshake, and
  decrypted the GetProfileResponse (PersonaId 33068179, Persona CAGE) with a session key
  derived INDEPENDENTLY of the Rust, then observed the Login pushes across all three
  candidate senders.
* autopatch behaviourally. The startup banner, the --launcher-pid watchdog exiting with
  the exact Python message, dual stdout+logfile output, and a missing value rejected
  with Python's own "invalid --launcher-pid". The subagent additionally cross-checked
  every constant by executing the Python module and drove the binary against a synthetic
  client (correct comm, a CardsDLL mapping, gates mmapped at their absolute VAs),
  confirming all eleven patches byte-exact in table order.
* The `[store-guard] verified capability …` line is byte-identical to openfut-launcher's
  own parser fixture, so backend capability registration still works.

Workspace builds; openfut-lsx 57, openfut-autopatch 43, openfut-launcher 74 tests green.
2026-08-18 05:31:00 +00:00

478 lines
18 KiB
Rust

//! FIFA 17 client patcher — the ProtoSSL cert gates and the CardsDLL FUT store
//! patches, applied to a running `FIFA17.exe` through `/proc/<pid>/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/<pid>/comm` of the client we patch (exact match after trimming).
pub const CLIENT_COMM: &str = "FIFA17.exe";
/// Substring identifying the CardsDLL mapping in `/proc/<pid>/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/<pid>/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/<pid>/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<u32> {
if !name.as_bytes().first().is_some_and(u8::is_ascii_digit) {
return None;
}
name.parse::<u32>().ok()
}
/// Base address of the first `CardsDLL` mapping in a `/proc/<pid>/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<u64> {
let line = maps.lines().find(|line| line.contains(CARDSDLL_MARKER))?;
u64::from_str_radix(line.split('-').next()?, 16).ok()
}
/// `--launcher-pid <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<I, S>(args: I) -> Result<Option<i64>, ()>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let args: Vec<S> = 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::<i64>().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::<String>::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(()));
}
}