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.
This commit is contained in:
funman300
2026-08-18 05:31:00 +00:00
parent 082246c085
commit 750d6c2e18
17 changed files with 3940 additions and 1 deletions
+477
View File
@@ -0,0 +1,477 @@
//! 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(()));
}
}
+304
View File
@@ -0,0 +1,304 @@
//! Wall-clock `HH:MM:SS` in local time, from std alone.
//!
//! The Python logs `time.strftime('%H:%M:%S')`, i.e. *local* time, and the
//! launcher interleaves these lines with its own log, so UTC would misreport the
//! timestamps by the machine's offset. std has no local-time support, so the
//! UTC offset is taken from the system's TZif database (`TZ` or `/etc/localtime`),
//! parsed here — a bounded, well-specified format (RFC 8536).
//!
//! LIMITATION, stated rather than hidden: only the TZif transition table is
//! evaluated, not the trailing POSIX-TZ footer string. With the "fat" tzdata
//! that Debian-family systems ship, transitions run to 2037, so the offset —
//! including DST — is exact. Two cases fall back to the last known transition's
//! offset (so a DST-observing zone could read one hour off) and one falls back
//! to UTC:
//! * "slim" tzdata, or dates past the last transition -> last transition;
//! * `TZ` holding a bare POSIX rule (`EST5EDT`) with no such zone file, or an
//! unreadable/corrupt zone file -> UTC.
//! The timestamp is diagnostic; no line the launcher parses carries a time.
use std::fs;
use std::sync::LazyLock;
use std::time::{SystemTime, UNIX_EPOCH};
/// Resolved UTC offsets over time: an offset before the first transition, then
/// `(transition instant, offset from that instant on)` in ascending order.
#[derive(Debug, PartialEq, Eq)]
pub struct TzData {
initial: i32,
transitions: Vec<(i64, i32)>,
}
impl TzData {
/// UTC offset in seconds applying at `unix_secs`.
pub fn offset_at(&self, unix_secs: i64) -> i32 {
let idx = self
.transitions
.partition_point(|(start, _)| *start <= unix_secs);
if idx == 0 {
self.initial
} else {
self.transitions[idx - 1].1
}
}
}
/// `[HH:MM:SS]`-worthy time-of-day for a Unix timestamp at a given UTC offset.
pub fn hms(unix_secs: i64, utc_offset_secs: i32) -> (u32, u32, u32) {
let local = unix_secs + i64::from(utc_offset_secs);
// rem_euclid keeps pre-epoch and negative-offset instants on a sane clock.
let day = local.rem_euclid(86_400);
((day / 3600) as u32, (day % 3600 / 60) as u32, (day % 60) as u32)
}
/// Current local time of day, `(hour, minute, second)`.
pub fn now_hms() -> (u32, u32, u32) {
let unix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
// Before 1970 the clock is broken anyway; keep logging rather than panic.
.unwrap_or(0);
hms(unix, local_offset_at(unix))
}
/// UTC offset in seconds for `unix_secs`, or 0 when no zone data is usable.
///
/// The zone file is read and parsed once; the offset is then recomputed per call
/// so a DST transition during a long-running session is picked up.
pub fn local_offset_at(unix_secs: i64) -> i32 {
static TZ: LazyLock<Option<TzData>> = LazyLock::new(load_system_tz);
TZ.as_ref().map_or(0, |tz| tz.offset_at(unix_secs))
}
/// Read and parse the zone file named by `TZ`, else `/etc/localtime`.
fn load_system_tz() -> Option<TzData> {
let path = match std::env::var("TZ") {
Ok(tz) if !tz.is_empty() => {
// glibc accepts a leading ':' and either an absolute path or a name
// relative to the zoneinfo directory.
let name = tz.strip_prefix(':').unwrap_or(&tz);
if name.starts_with('/') {
name.to_string()
} else {
format!("/usr/share/zoneinfo/{name}")
}
}
_ => "/etc/localtime".to_string(),
};
parse_tzif(&fs::read(path).ok()?)
}
/// Parse a TZif (RFC 8536) file into resolved offsets.
///
/// For version 2+ files the 64-bit data block is used; the legacy 32-bit block
/// is skipped, because modern tzdata leaves it minimal.
pub fn parse_tzif(bytes: &[u8]) -> Option<TzData> {
let (version, counts) = parse_header(bytes, 0)?;
if version >= b'2' {
// Skip the v1 header + v1 data block, then re-read the 64-bit header.
let v1_end = 44 + data_block_len(&counts, 4)?;
let (_, counts64) = parse_header(bytes, v1_end)?;
parse_data(bytes, v1_end + 44, &counts64, 8)
} else {
parse_data(bytes, 44, &counts, 4)
}
}
/// `(isutcnt, isstdcnt, leapcnt, timecnt, typecnt, charcnt)`.
type Counts = [u32; 6];
fn parse_header(bytes: &[u8], off: usize) -> Option<(u8, Counts)> {
let head = bytes.get(off..off + 44)?;
if &head[0..4] != b"TZif" {
return None;
}
let version = head[4];
let mut counts = [0u32; 6];
for (i, slot) in counts.iter_mut().enumerate() {
let at = 20 + i * 4;
*slot = u32::from_be_bytes(head[at..at + 4].try_into().ok()?);
}
Some((version, counts))
}
/// Byte length of a data block with `time_len`-wide transition times.
fn data_block_len(counts: &Counts, time_len: usize) -> Option<usize> {
let [isutcnt, isstdcnt, leapcnt, timecnt, typecnt, charcnt] = counts.map(|c| c as usize);
Some(
timecnt * time_len
+ timecnt
+ typecnt * 6
+ charcnt
+ leapcnt * (time_len + 4)
+ isstdcnt
+ isutcnt,
)
}
fn parse_data(bytes: &[u8], off: usize, counts: &Counts, time_len: usize) -> Option<TzData> {
let [_, _, _, timecnt, typecnt, _] = counts.map(|c| c as usize);
if typecnt == 0 {
return None;
}
let block = bytes.get(off..off + data_block_len(counts, time_len)?)?;
let times = block.get(..timecnt * time_len)?;
let type_idx = block.get(timecnt * time_len..timecnt * time_len + timecnt)?;
let ttinfo_at = timecnt * time_len + timecnt;
let ttinfo = block.get(ttinfo_at..ttinfo_at + typecnt * 6)?;
// utoff + isdst per local-time type.
let mut offsets = Vec::with_capacity(typecnt);
for i in 0..typecnt {
let rec = &ttinfo[i * 6..i * 6 + 6];
let utoff = i32::from_be_bytes(rec[0..4].try_into().ok()?);
offsets.push((utoff, rec[4] != 0));
}
// Before the first transition, RFC 8536 says to use the first non-DST type,
// falling back to the first type. This is also the whole answer for a
// fixed-offset zone (typecnt 1, timecnt 0), e.g. Etc/UTC.
let initial = offsets
.iter()
.find(|(_, isdst)| !*isdst)
.unwrap_or(&offsets[0])
.0;
let mut transitions = Vec::with_capacity(timecnt);
for i in 0..timecnt {
let raw = &times[i * time_len..(i + 1) * time_len];
let at = if time_len == 8 {
i64::from_be_bytes(raw.try_into().ok()?)
} else {
i64::from(i32::from_be_bytes(raw.try_into().ok()?))
};
let (utoff, _) = *offsets.get(*type_idx.get(i)? as usize)?;
transitions.push((at, utoff));
}
Some(TzData {
initial,
transitions,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// Minimal TZif builder: `transitions` are `(instant, type index)`.
fn tzif(version: u8, types: &[(i32, bool)], transitions: &[(i64, u8)]) -> Vec<u8> {
fn block(types: &[(i32, bool)], transitions: &[(i64, u8)], time_len: usize) -> Vec<u8> {
let mut out = Vec::new();
for (at, _) in transitions {
if time_len == 8 {
out.extend_from_slice(&at.to_be_bytes());
} else {
out.extend_from_slice(&(*at as i32).to_be_bytes());
}
}
for (_, idx) in transitions {
out.push(*idx);
}
for (utoff, isdst) in types {
out.extend_from_slice(&utoff.to_be_bytes());
out.push(u8::from(*isdst));
out.push(0); // abbreviation index
}
out.push(0); // one NUL abbreviation byte
out
}
fn header(version: u8, types: usize, transitions: usize) -> Vec<u8> {
let mut out = Vec::from(*b"TZif");
out.push(version);
out.extend_from_slice(&[0u8; 15]);
for count in [0u32, 0, 0, transitions as u32, types as u32, 1] {
out.extend_from_slice(&count.to_be_bytes());
}
out
}
let mut out = header(version, types.len(), transitions.len());
if version >= b'2' {
// Modern "slim-ish" shape: an empty v1 block, then the 64-bit block.
out.truncate(0);
out.extend(header(version, types.len(), 0));
out.extend(block(types, &[], 4));
out.extend(header(version, types.len(), transitions.len()));
out.extend(block(types, transitions, 8));
} else {
out.extend(block(types, transitions, 4));
}
out
}
#[test]
fn fixed_offset_zone_has_no_transitions() {
let tz = parse_tzif(&tzif(b'2', &[(0, false)], &[])).unwrap();
assert_eq!(tz.offset_at(0), 0);
assert_eq!(tz.offset_at(1_800_000_000), 0);
let kolkata = parse_tzif(&tzif(b'2', &[(19_800, false)], &[])).unwrap();
assert_eq!(kolkata.offset_at(1_800_000_000), 19_800);
}
#[test]
fn dst_transitions_select_the_right_offset() {
// CET/CEST with two transitions.
let tz = parse_tzif(&tzif(
b'2',
&[(3600, false), (7200, true)],
&[(1_000_000_000, 1), (1_100_000_000, 0)],
))
.unwrap();
assert_eq!(tz.offset_at(999_999_999), 3600); // before the first transition
assert_eq!(tz.offset_at(1_000_000_000), 7200); // exactly at it
assert_eq!(tz.offset_at(1_050_000_000), 7200);
assert_eq!(tz.offset_at(1_100_000_000), 3600);
assert_eq!(tz.offset_at(i64::MAX), 3600); // past the table: last known
}
#[test]
fn version_1_files_parse_from_the_32_bit_block() {
let tz = parse_tzif(&tzif(b'\0', &[(-18_000, false)], &[(100, 0)])).unwrap();
assert_eq!(tz.offset_at(0), -18_000);
assert_eq!(tz.offset_at(1_000), -18_000);
}
#[test]
fn initial_offset_skips_a_leading_dst_type() {
let tz = parse_tzif(&tzif(b'2', &[(7200, true), (3600, false)], &[])).unwrap();
assert_eq!(tz.offset_at(0), 3600);
}
#[test]
fn garbage_is_rejected_rather_than_guessed() {
assert_eq!(parse_tzif(b""), None);
assert_eq!(parse_tzif(b"not a tzif file at all, truncated"), None);
let mut truncated = tzif(b'2', &[(3600, false)], &[(1, 0)]);
truncated.truncate(truncated.len() - 5);
assert_eq!(parse_tzif(&truncated), None);
// Well-formed header claiming zero local-time types is unusable.
assert_eq!(parse_tzif(&tzif(b'2', &[], &[])), None);
}
#[test]
fn hms_matches_known_instants() {
assert_eq!(hms(0, 0), (0, 0, 0));
// 2026-08-18T04:52:08Z
assert_eq!(hms(1_787_028_728, 0), (4, 52, 8));
// …the same instant at +02:00 and at -05:00 (the latter is the day before).
assert_eq!(hms(1_787_028_728, 7200), (6, 52, 8));
assert_eq!(hms(1_787_028_728, -18_000), (23, 52, 8));
// Offsets that cross midnight in either direction stay on the clock.
assert_eq!(hms(86_399, 1), (0, 0, 0));
assert_eq!(hms(0, -1), (23, 59, 59));
}
#[test]
fn the_system_zone_resolves_to_a_plausible_offset() {
// Whatever this machine's zone is, the offset must be a real one.
let offset = local_offset_at(1_786_697_528);
assert!((-50_400..=50_400).contains(&offset), "implausible {offset}");
assert_eq!(offset % 60, 0);
}
}
+103
View File
@@ -0,0 +1,103 @@
//! `[HH:MM:SS] <msg>` to stdout (flushed per line) and appended to the log file.
//!
//! The launcher pipes this process's stdout into its own log buffer and *parses*
//! some of these lines, so the per-line flush and the line shapes are a contract,
//! not cosmetics.
use std::fs::OpenOptions;
use std::io::{self, Write};
use std::path::PathBuf;
use crate::localtime;
use crate::procmem;
/// Environment override for the log file path.
pub const LOG_PATH_ENV: &str = "OPENFUT_AUTOPATCH_LOG";
pub struct Logger {
path: PathBuf,
}
impl Logger {
/// `$OPENFUT_AUTOPATCH_LOG`, defaulting to `/tmp/openfut-autopatch-<uid>.log`.
///
/// Resolved once at startup, exactly like the Python's module-level `LOG`, so
/// a later environment change cannot move the file mid-run.
pub fn from_env() -> io::Result<Self> {
let path = match std::env::var_os(LOG_PATH_ENV) {
Some(path) if !path.is_empty() => PathBuf::from(path),
_ => PathBuf::from(format!(
"/tmp/openfut-autopatch-{}.log",
procmem::current_uid()?
)),
};
Ok(Self { path })
}
pub fn path(&self) -> &std::path::Path {
&self.path
}
/// Emit one line. Timestamped in local time.
pub fn log(&self, msg: &str) {
let (h, m, s) = localtime::now_hms();
let line = format!("[{h:02}:{m:02}:{s:02}] {msg}");
let mut stdout = io::stdout().lock();
// Ignore a broken pipe: the launcher may have stopped reading, and dying
// here would leave FIFA's store patches unenforced.
let _ = writeln!(stdout, "{line}");
let _ = stdout.flush();
drop(stdout);
if let Err(e) = self.append(&line) {
// The Python lets a failing log write kill the process. Patching the
// running client matters more than the transcript, so report once to
// stderr (the launcher captures it too) and carry on.
let _ = writeln!(io::stderr(), "autopatch: cannot append to {}: {e}", self.path.display());
}
}
fn append(&self, line: &str) -> io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open(&self.path)?;
file.write_all(line.as_bytes())?;
file.write_all(b"\n")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn appends_a_timestamped_line_to_the_configured_path() {
let path = std::env::temp_dir().join(format!(
"openfut-autopatch-test-{}.log",
std::process::id()
));
let _ = std::fs::remove_file(&path);
let logger = Logger {
path: path.clone(),
};
logger.log("pid 4242: PATCHED cert gates");
logger.log("second line");
let body = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = body.lines().collect();
assert_eq!(lines.len(), 2);
assert_eq!(&lines[0][..1], "[");
assert_eq!(&lines[0][3..4], ":");
assert_eq!(&lines[0][6..7], ":");
assert_eq!(&lines[0][9..], "] pid 4242: PATCHED cert gates");
assert!(lines[1].ends_with("] second line"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn a_bad_log_path_does_not_kill_the_patcher() {
let logger = Logger {
path: PathBuf::from("/proc/definitely/not/writable.log"),
};
logger.log("still running");
}
}
+97
View File
@@ -0,0 +1,97 @@
//! Watch for a (re)launched FIFA17.exe and auto-apply the ProtoSSL cert patches
//! the moment its unpacked code is mapped, plus the CardsDLL FUT store patches.
//! Idempotent; keeps watching across relaunches.
//!
//! Port of `fifa17-recon/tools/autopatch.py`, tick for tick: cert gates once per
//! pid, store patches re-enforced every second (the game rewrites those sites),
//! then the resolver-guard capability reported once per pid. The passes
//! themselves live in `openfut_autopatch::patch`; this is the loop and the CLI.
use std::collections::HashSet;
use std::process::ExitCode;
use std::thread::sleep;
use std::time::Duration;
use openfut_autopatch::patch::{cert_pass, enforce_store_patches};
use openfut_autopatch::procmem::{self, ProcMem};
use openfut_autopatch::{parse_launcher_pid, Logger};
/// One tick per second, as in the Python.
const TICK: Duration = Duration::from_secs(1);
/// Per-pid bookkeeping so each of these lines is logged exactly once per client
/// process (the Python's three module-level sets).
#[derive(Default)]
struct Seen {
patched: HashSet<u32>,
store_patched: HashSet<u32>,
guard_reported: HashSet<u32>,
}
fn main() -> ExitCode {
let launcher_pid = match parse_launcher_pid(std::env::args().skip(1)) {
Ok(pid) => pid,
Err(()) => {
eprintln!("invalid --launcher-pid");
return ExitCode::FAILURE;
}
};
let logger = match Logger::from_env() {
Ok(logger) => logger,
Err(e) => {
eprintln!("cannot resolve the autopatch log path: {e}");
return ExitCode::FAILURE;
}
};
logger.log("=== AUTOPATCH watching for FIFA17.exe ===");
let mut seen = Seen::default();
loop {
// `if launcher_pid and not os.path.exists(...)`: pid 0 is falsy in the
// Python, so `--launcher-pid 0` parses but is never watched.
if let Some(pid) = launcher_pid {
if pid != 0 && !procmem::pid_alive(pid) {
logger.log(&format!("launcher pid {pid} exited; stopping autopatch"));
break;
}
}
for pid in procmem::find_pids() {
let mem = ProcMem::new(pid);
if !seen.patched.contains(&pid)
&& !cert_pass(&mem, &mut |line| logger.log(line), &mut seen.patched)
{
// Code not mapped yet: nothing else to do for this pid this tick.
continue;
}
// Store patches are enforced on EVERY tick, not once: the game
// rewrites these sites, so a single pass at startup does not hold.
let Some(cbase) = procmem::cardsdll_base(pid) else {
continue;
};
match enforce_store_patches(
&mem,
cbase,
&mut |line| logger.log(line),
&mut seen.guard_reported,
) {
Ok(()) => {
if seen.store_patched.insert(pid) {
logger.log(&format!(
"pid {pid}: PATCHED store gates in CardsDLL @ {cbase:#x}"
));
}
}
Err(e) => logger.log(&format!("pid {pid}: store patch write failed: {e}")),
}
}
sleep(TICK);
}
ExitCode::SUCCESS
}
+478
View File
@@ -0,0 +1,478 @@
//! The two enforcement passes: ProtoSSL cert gates (once per pid) and the
//! CardsDLL store patches (every tick).
//!
//! Both are written against the [`Memory`] trait rather than `/proc` directly, so
//! the log lines and their order — which the launcher reads — are unit-testable
//! without a live FIFA client.
use std::collections::HashSet;
use std::io;
use crate::{
guard_state_after, guarded_action, hex, live_addr, GuardState, GuardedAction,
EMPTY_MYPACKS_RESOLVER_CAPABILITY, EMPTY_MYPACKS_RESOLVER_VERSION, GATE1, GATE1_ORIG,
GATE1_PATCH, GATE2, GATE2_ORIG, GATE2_PATCH, MAX_PATCH_LEN, RESOLVER_GUARD_VA, STORE_PATCHES,
STORE_PATCHES_GUARDED,
};
/// Byte-level access to one client process's address space.
pub trait Memory {
/// The pid being patched; it appears in every log line.
fn pid(&self) -> u32;
/// Fill `buf` from virtual address `va`. An error means "not mapped (yet)".
fn read(&self, va: u64, buf: &mut [u8]) -> io::Result<()>;
/// Write `data` at virtual address `va`.
fn write(&self, va: u64, data: &[u8]) -> io::Result<()>;
}
/// Apply the two ProtoSSL cert gates, once per pid.
///
/// Returns `false` when the gates could not be read — the packer has not mapped
/// that code yet, which is the Python's `continue`, not an error to report.
pub fn cert_pass<M: Memory>(
mem: &M,
log: &mut impl FnMut(&str),
patched: &mut HashSet<u32>,
) -> bool {
let pid = mem.pid();
// Sized from the patterns themselves; the initial contents are overwritten by
// the reads and are never compared unless both reads succeed.
let mut g2 = GATE2_ORIG;
let mut g1 = GATE1_ORIG;
if mem.read(GATE2, &mut g2).is_err() || mem.read(GATE1, &mut g1).is_err() {
return false;
}
if g2 == GATE2_PATCH && g1 == GATE1_PATCH {
log(&format!("pid {pid}: cert gates already patched"));
patched.insert(pid);
} else if g2 == GATE2_ORIG && g1 == GATE1_ORIG {
match mem
.write(GATE2, &GATE2_PATCH)
.and_then(|()| mem.write(GATE1, &GATE1_PATCH))
{
Ok(()) => {
log(&format!("pid {pid}: PATCHED cert gates"));
patched.insert(pid);
}
Err(e) => log(&format!("pid {pid}: cert patch write failed: {e}")),
}
}
// Anything else is a build we do not recognise: left alone, as in the Python.
true
}
/// Re-apply every store patch whose live bytes have drifted, then the guarded
/// patch, then report the resolver-guard capability once per pid.
///
/// An `Err` is a read or write that failed outside the guarded site's own
/// handling; it aborts the rest of this pid's pass for this tick, exactly like
/// the Python's enclosing `try`.
pub fn enforce_store_patches<M: Memory>(
mem: &M,
cbase: u64,
log: &mut impl FnMut(&str),
guard_reported: &mut HashSet<u32>,
) -> io::Result<()> {
let pid = mem.pid();
for (va, data) in STORE_PATCHES {
let live = live_addr(cbase, va);
let mut buf = [0u8; MAX_PATCH_LEN];
let cur = &mut buf[..data.len()];
mem.read(live, cur)?;
if cur != data {
mem.write(live, data)?;
log(&format!("pid {pid}: ENFORCED store patch @ {live:#x}"));
}
}
for (va, orig, patch) in STORE_PATCHES_GUARDED {
let live = live_addr(cbase, va);
let mut before = [0u8; MAX_PATCH_LEN];
mem.read(live, &mut before[..patch.len()])?;
let cur = &before[..patch.len()];
let mut wrote_ok = true;
// The Python starts with `cur_after = cur`, which only matters on the
// branches that never re-read.
let mut after = [0u8; MAX_PATCH_LEN];
after[..patch.len()].copy_from_slice(cur);
let mut after_len = patch.len();
match guarded_action(cur, orig, patch) {
GuardedAction::Patch => {
match mem.write(live, patch) {
Ok(()) => log(&format!(
"pid {pid}: ENFORCED guarded store patch @ {live:#x} (JNZ->JG, empty My Packs)"
)),
Err(e) => {
wrote_ok = false;
log(&format!(
"pid {pid}: guarded patch write failed @ {live:#x}: {e}"
));
}
}
if wrote_ok && mem.read(live, &mut after[..patch.len()]).is_err() {
// The Python's `cur_after = b""`: unverifiable, so not verified.
after_len = 0;
}
}
GuardedAction::Skip => log(&format!(
"pid {pid}: SKIP guarded patch @ {live:#x}: unexpected {} (build mismatch)",
hex(cur)
)),
// Already patched; nothing to write.
GuardedAction::Noop => {}
}
if va == RESOLVER_GUARD_VA && !guard_reported.contains(&pid) {
let state = guard_state_after(cur, orig, patch, wrote_ok, &after[..after_len]);
if state == GuardState::Verified {
// PARSED BY THE LAUNCHER (fifa17_capability::parse_capability_line):
// this line must keep both `verified capability` and the
// `fifa17.empty_mypacks_resolver=<version>` token verbatim.
log(&format!(
"[store-guard] verified capability {EMPTY_MYPACKS_RESOLVER_CAPABILITY}={EMPTY_MYPACKS_RESOLVER_VERSION} fifa_pid={pid}"
));
} else {
log(&format!(
"[store-guard] guard status={state} fifa_pid={pid} (no capability advertised)"
));
}
guard_reported.insert(pid);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
use std::collections::BTreeMap;
const CBASE: u64 = 0x7f2a11c00000;
const GUARD_LIVE: u64 = CBASE + 0x14858;
/// Sparse fake address space: an unmapped byte reads as `NotFound`, mirroring
/// `/proc/<pid>/mem` refusing an address the packer has not produced yet.
struct FakeMemory {
bytes: RefCell<BTreeMap<u64, u8>>,
/// Writes to these addresses fail.
fail_writes: Vec<u64>,
/// Writes to these addresses report success but change nothing (the
/// VERIFY_FAILED shape).
swallow_writes: Vec<u64>,
/// Reads of these addresses fail even when mapped.
fail_reads: Vec<u64>,
}
impl FakeMemory {
fn new() -> Self {
Self {
bytes: RefCell::new(BTreeMap::new()),
fail_writes: Vec::new(),
swallow_writes: Vec::new(),
fail_reads: Vec::new(),
}
}
fn map(self, va: u64, bytes: &[u8]) -> Self {
{
let mut mem = self.bytes.borrow_mut();
for (i, b) in bytes.iter().enumerate() {
mem.insert(va + i as u64, *b);
}
}
self
}
/// Every store-patch site mapped with filler that is neither the patch
/// nor (for the guarded site) the original.
fn with_store_sites(mut self, filler: u8) -> Self {
for (va, data) in STORE_PATCHES {
self = self.map(live_addr(CBASE, va), &vec![filler; data.len()]);
}
for (va, _, patch) in STORE_PATCHES_GUARDED {
self = self.map(live_addr(CBASE, va), &vec![filler; patch.len()]);
}
self
}
fn at(&self, va: u64, len: usize) -> Vec<u8> {
let mem = self.bytes.borrow();
(0..len as u64).map(|i| mem[&(va + i)]).collect()
}
}
impl Memory for FakeMemory {
fn pid(&self) -> u32 {
4242
}
fn read(&self, va: u64, buf: &mut [u8]) -> io::Result<()> {
if self.fail_reads.contains(&va) {
return Err(io::Error::from(io::ErrorKind::PermissionDenied));
}
let mem = self.bytes.borrow();
for (i, slot) in buf.iter_mut().enumerate() {
*slot = *mem
.get(&(va + i as u64))
.ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))?;
}
Ok(())
}
fn write(&self, va: u64, data: &[u8]) -> io::Result<()> {
if self.fail_writes.contains(&va) {
return Err(io::Error::from(io::ErrorKind::PermissionDenied));
}
if self.swallow_writes.contains(&va) {
return Ok(());
}
let mut mem = self.bytes.borrow_mut();
for (i, b) in data.iter().enumerate() {
mem.insert(va + i as u64, *b);
}
Ok(())
}
}
/// Collects log lines so the contract strings can be asserted verbatim.
#[derive(Default)]
struct Lines(Vec<String>);
impl Lines {
fn sink(&mut self) -> impl FnMut(&str) + '_ {
|line: &str| self.0.push(line.to_string())
}
}
#[test]
fn cert_pass_defers_while_the_code_is_not_mapped() {
let mem = FakeMemory::new();
let mut lines = Lines::default();
let mut patched = HashSet::new();
assert!(!cert_pass(&mem, &mut lines.sink(), &mut patched));
assert!(lines.0.is_empty(), "{:?}", lines.0);
assert!(patched.is_empty());
}
#[test]
fn cert_pass_defers_when_only_the_first_gate_is_mapped() {
let mem = FakeMemory::new().map(GATE2, &GATE2_ORIG);
let mut lines = Lines::default();
let mut patched = HashSet::new();
assert!(!cert_pass(&mem, &mut lines.sink(), &mut patched));
assert!(lines.0.is_empty());
assert!(patched.is_empty());
}
#[test]
fn cert_pass_writes_both_gates_once() {
let mem = FakeMemory::new()
.map(GATE2, &GATE2_ORIG)
.map(GATE1, &GATE1_ORIG);
let mut lines = Lines::default();
let mut patched = HashSet::new();
assert!(cert_pass(&mem, &mut lines.sink(), &mut patched));
assert_eq!(lines.0, vec!["pid 4242: PATCHED cert gates"]);
assert_eq!(mem.at(GATE2, 3), GATE2_PATCH);
assert_eq!(mem.at(GATE1, 6), GATE1_PATCH);
assert!(patched.contains(&4242));
}
#[test]
fn cert_pass_recognises_an_already_patched_client() {
let mem = FakeMemory::new()
.map(GATE2, &GATE2_PATCH)
.map(GATE1, &GATE1_PATCH);
let mut lines = Lines::default();
let mut patched = HashSet::new();
assert!(cert_pass(&mem, &mut lines.sink(), &mut patched));
assert_eq!(lines.0, vec!["pid 4242: cert gates already patched"]);
assert!(patched.contains(&4242));
}
#[test]
fn cert_pass_reports_a_write_failure_and_stays_unpatched() {
let mut mem = FakeMemory::new()
.map(GATE2, &GATE2_ORIG)
.map(GATE1, &GATE1_ORIG);
mem.fail_writes.push(GATE2);
let mut lines = Lines::default();
let mut patched = HashSet::new();
assert!(cert_pass(&mem, &mut lines.sink(), &mut patched));
assert_eq!(lines.0.len(), 1);
assert!(
lines.0[0].starts_with("pid 4242: cert patch write failed: "),
"{}",
lines.0[0]
);
// Not recorded as patched, so the next tick tries again.
assert!(patched.is_empty());
assert_eq!(mem.at(GATE2, 3), GATE2_ORIG);
}
#[test]
fn cert_pass_leaves_an_unrecognised_build_alone() {
let mem = FakeMemory::new()
.map(GATE2, &[0x55, 0x48, 0x89])
.map(GATE1, &[0x0f, 0x84, 0x76, 0x01, 0x00, 0x00]);
let mut lines = Lines::default();
let mut patched = HashSet::new();
assert!(cert_pass(&mem, &mut lines.sink(), &mut patched));
assert!(lines.0.is_empty(), "{:?}", lines.0);
assert!(patched.is_empty());
assert_eq!(mem.at(GATE2, 3), [0x55, 0x48, 0x89]);
}
#[test]
fn store_pass_enforces_every_site_in_table_order_then_advertises() {
let mem = FakeMemory::new().with_store_sites(0xcc);
let mut lines = Lines::default();
let mut reported = HashSet::new();
enforce_store_patches(&mem, CBASE, &mut lines.sink(), &mut reported).unwrap();
let mut expected: Vec<String> = STORE_PATCHES
.iter()
.map(|(va, _)| {
let live = live_addr(CBASE, *va);
format!("pid 4242: ENFORCED store patch @ {live:#x}")
})
.collect();
// 0xcc is neither the original nor the patch: fail-closed SKIP, and the
// capability is withheld.
expected.push(format!(
"pid 4242: SKIP guarded patch @ {GUARD_LIVE:#x}: unexpected cccc (build mismatch)"
));
expected.push(
"[store-guard] guard status=UNSUPPORTED_BUILD fifa_pid=4242 (no capability advertised)"
.to_string(),
);
assert_eq!(lines.0, expected);
assert!(reported.contains(&4242));
for (va, data) in STORE_PATCHES {
assert_eq!(mem.at(live_addr(CBASE, va), data.len()), data);
}
// The guarded site was NOT overwritten.
assert_eq!(mem.at(GUARD_LIVE, 2), [0xcc, 0xcc]);
}
#[test]
fn store_pass_patches_the_guard_and_advertises_the_capability() {
let mem = FakeMemory::new()
.with_store_sites(0xcc)
.map(GUARD_LIVE, STORE_PATCHES_GUARDED[0].1);
let mut lines = Lines::default();
let mut reported = HashSet::new();
enforce_store_patches(&mem, CBASE, &mut lines.sink(), &mut reported).unwrap();
assert_eq!(
lines.0[lines.0.len() - 2],
format!(
"pid 4242: ENFORCED guarded store patch @ {GUARD_LIVE:#x} (JNZ->JG, empty My Packs)"
)
);
assert_eq!(
lines.0[lines.0.len() - 1],
"[store-guard] verified capability fifa17.empty_mypacks_resolver=1 fifa_pid=4242"
);
assert_eq!(mem.at(GUARD_LIVE, 2), [0x7f, 0x0f]);
// Second tick: everything already enforced, and the capability is not
// re-advertised.
let mut lines = Lines::default();
enforce_store_patches(&mem, CBASE, &mut lines.sink(), &mut reported).unwrap();
assert!(lines.0.is_empty(), "{:?}", lines.0);
}
#[test]
fn store_pass_verifies_an_already_patched_guard() {
let mem = FakeMemory::new()
.with_store_sites(0xcc)
.map(GUARD_LIVE, STORE_PATCHES_GUARDED[0].2);
let mut lines = Lines::default();
let mut reported = HashSet::new();
enforce_store_patches(&mem, CBASE, &mut lines.sink(), &mut reported).unwrap();
assert_eq!(
lines.0.last().unwrap(),
"[store-guard] verified capability fifa17.empty_mypacks_resolver=1 fifa_pid=4242"
);
}
#[test]
fn store_pass_reports_a_guarded_write_failure_without_aborting_the_tick() {
let mut mem = FakeMemory::new()
.with_store_sites(0xcc)
.map(GUARD_LIVE, STORE_PATCHES_GUARDED[0].1);
mem.fail_writes.push(GUARD_LIVE);
let mut lines = Lines::default();
let mut reported = HashSet::new();
// The guarded write failure is handled inline, so the pass still succeeds.
enforce_store_patches(&mem, CBASE, &mut lines.sink(), &mut reported).unwrap();
assert!(
lines.0[lines.0.len() - 2]
.starts_with(&format!("pid 4242: guarded patch write failed @ {GUARD_LIVE:#x}: ")),
"{}",
lines.0[lines.0.len() - 2]
);
assert_eq!(
lines.0[lines.0.len() - 1],
"[store-guard] guard status=WRITE_FAILED fifa_pid=4242 (no capability advertised)"
);
}
#[test]
fn store_pass_withholds_the_capability_when_verification_fails() {
let mut mem = FakeMemory::new()
.with_store_sites(0xcc)
.map(GUARD_LIVE, STORE_PATCHES_GUARDED[0].1);
// Write reported OK, memory unchanged: the re-read still shows the original.
mem.swallow_writes.push(GUARD_LIVE);
let mut lines = Lines::default();
let mut reported = HashSet::new();
enforce_store_patches(&mem, CBASE, &mut lines.sink(), &mut reported).unwrap();
assert_eq!(
lines.0[lines.0.len() - 1],
"[store-guard] guard status=VERIFY_FAILED fifa_pid=4242 (no capability advertised)"
);
}
#[test]
fn store_pass_aborts_the_tick_when_a_site_is_not_mapped() {
// CardsDLL is mapped but this tick catches a site mid-unpack.
let mem = FakeMemory::new();
let mut lines = Lines::default();
let mut reported = HashSet::new();
let err = enforce_store_patches(&mem, CBASE, &mut lines.sink(), &mut reported).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::NotFound);
assert!(lines.0.is_empty());
// Nothing advertised, so the next tick re-evaluates the guard.
assert!(reported.is_empty());
}
#[test]
fn store_pass_skips_writing_sites_that_already_hold_the_patch() {
let mut mem = FakeMemory::new().with_store_sites(0xcc);
for (va, data) in STORE_PATCHES {
mem = mem.map(live_addr(CBASE, va), data);
}
mem = mem.map(GUARD_LIVE, STORE_PATCHES_GUARDED[0].2);
// Any write at all would fail these sites, proving none is attempted.
mem.fail_writes
.extend(STORE_PATCHES.iter().map(|(va, _)| live_addr(CBASE, *va)));
mem.fail_writes.push(GUARD_LIVE);
let mut lines = Lines::default();
let mut reported = HashSet::new();
enforce_store_patches(&mem, CBASE, &mut lines.sink(), &mut reported).unwrap();
assert_eq!(
lines.0,
vec!["[store-guard] verified capability fifa17.empty_mypacks_resolver=1 fifa_pid=4242"]
);
}
}
+160
View File
@@ -0,0 +1,160 @@
//! `/proc` access: finding the client, locating CardsDLL, and positioned reads
//! and writes against `/proc/<pid>/mem`.
//!
//! Positioned I/O (`pread`/`pwrite`) is used rather than seek+read: a 64-bit
//! virtual address is passed straight through as the file offset, so nothing
//! depends on a shared file cursor.
use std::fs::{self, File, OpenOptions};
use std::io;
use std::os::unix::fs::FileExt;
use crate::patch::Memory;
use crate::{is_client_comm, parse_cardsdll_base, pid_from_proc_entry};
/// Pids whose `comm` is exactly `FIFA17.exe`.
///
/// Sorted ascending so that, when more than one client is somehow running, the
/// per-pid log lines come out in a stable order (the Python inherits readdir
/// order, which is arbitrary).
pub fn find_pids() -> Vec<u32> {
let mut out = Vec::new();
let Ok(entries) = fs::read_dir("/proc") else {
return out;
};
for entry in entries.flatten() {
let name = entry.file_name();
let Some(pid) = name.to_str().and_then(pid_from_proc_entry) else {
continue;
};
// A pid can exit between readdir and this read; that is not an error.
if let Ok(comm) = fs::read_to_string(format!("/proc/{pid}/comm")) {
if is_client_comm(&comm) {
out.push(pid);
}
}
}
out.sort_unstable();
out
}
/// Base address of the mapped CardsDLL, or `None` while it is not mapped.
pub fn cardsdll_base(pid: u32) -> Option<u64> {
let maps = fs::read_to_string(format!("/proc/{pid}/maps")).ok()?;
parse_cardsdll_base(&maps)
}
/// [`Memory`] over one live client process.
pub struct ProcMem {
pid: u32,
}
impl ProcMem {
pub fn new(pid: u32) -> Self {
Self { pid }
}
}
impl Memory for ProcMem {
fn pid(&self) -> u32 {
self.pid
}
/// A failure here normally means the address is not mapped yet — the packer
/// has not unpacked that code — which the watch loop treats as "come back
/// next tick", not as a failure worth reporting. Read-only handle.
fn read(&self, va: u64, buf: &mut [u8]) -> io::Result<()> {
File::open(format!("/proc/{}/mem", self.pid))?.read_exact_at(buf, va)
}
/// Opened read+write like the Python's `r+b`; write-only is not universally
/// accepted for `/proc/<pid>/mem` across kernels.
fn write(&self, va: u64, data: &[u8]) -> io::Result<()> {
OpenOptions::new()
.read(true)
.write(true)
.open(format!("/proc/{}/mem", self.pid))?
.write_all_at(data, va)
}
}
/// Whether `/proc/<pid>` still exists — the launcher-liveness check.
pub fn pid_alive(pid: i64) -> bool {
// Formatted exactly like the Python so a negative or zero pid behaves the
// same way (the path simply does not exist).
fs::metadata(format!("/proc/{pid}")).is_ok()
}
/// Real uid of this process, from the ownership of `/proc/self`.
///
/// std exposes no `getuid`, and this crate takes no dependencies; `/proc` is
/// mandatory for the patcher anyway, so reading it back is not a new assumption.
pub fn current_uid() -> io::Result<u32> {
use std::os::unix::fs::MetadataExt;
Ok(fs::metadata("/proc/self")?.uid())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn our_own_pid_is_alive_and_pid_zero_is_not() {
let me: i64 = fs::read_to_string("/proc/self/stat")
.unwrap()
.split(' ')
.next()
.unwrap()
.parse()
.unwrap();
assert!(pid_alive(me));
// /proc/0 and /proc/-1 never exist, matching the Python's path check.
assert!(!pid_alive(0));
assert!(!pid_alive(-1));
}
#[test]
fn uid_is_readable() {
// Only that it resolves; the value is environment-dependent.
assert!(current_uid().is_ok());
}
#[test]
fn find_pids_scan_is_safe_without_a_client() {
// Deterministic without a client: the scan must not panic and must only
// ever return numeric pids.
for pid in find_pids() {
assert!(pid > 0);
}
}
#[test]
fn positioned_io_round_trips_against_our_own_address_space() {
// Patching FIFA is not testable here, but the /proc/<pid>/mem mechanism
// is: read and write this process's own heap through the same code path.
let me: u32 = fs::read_to_string("/proc/self/stat")
.unwrap()
.split(' ')
.next()
.unwrap()
.parse()
.unwrap();
let mem = ProcMem::new(me);
// black_box throughout: this buffer is mutated by the kernel on our
// behalf, never by Rust code, so the compiler must not assume it is
// unchanged across the write.
let target = std::hint::black_box(vec![0x75u8, 0x0f, 0x11, 0x22]);
let va = target.as_ptr() as u64;
let mut seen = [0u8; 4];
mem.read(va, &mut seen).unwrap();
assert_eq!(seen, *target);
mem.write(va, &[0x7f, 0x0f]).unwrap();
assert_eq!(*std::hint::black_box(&target), [0x7f, 0x0f, 0x11, 0x22]);
// An address that is certainly not mapped reads as an error, which the
// watch loop treats as "not unpacked yet".
assert!(mem.read(0x1000, &mut seen).is_err());
}
}