Files
openfut-launcher/openfut-hook/src/patch_mem.rs
T
funman300 b098617573 feat(fifa17-hook): patch FIFA17 TLS gates in-process
Milestone B: move FIFA17 ProtoSSL certificate compatibility into version.dll so
the client-local contract is openfut.cfg + LSX + version.dll with no external
/proc-writing patcher. The proven external openfut-autopatch remains the oracle
and is NOT removed; this reaches behavioral parity for the fail-closed patches.

Patch set (ASLR-relocated at runtime; fail-closed byte-verified; one-shot):
- FIFA17.exe ProtoSSL cert gates (REQUIRED_FOR_TLS), preferred base 0x140000000:
    GATE1 rva 0x6132548  0f85 76010000 (JNZ) -> 90*6 (NOP)
    GATE2 rva 0x61361b0  48 89 5c (prologue) -> 31 c0 c3 (xor eax,eax; ret)
  Applied as a pair only when BOTH read their known original, exactly like the
  external patcher's cert_pass; polled until the STEAMPUNKS packer unpacks them.
- CardsDLL empty-My-Packs store crash-guard (REQUIRED_FOR_STORE_TLS, bug 6c),
  preferred base 0x180000000: rva 0x14858  75 0f (JNZ) -> 7f 0f (JG). Applied once
  CardsDLL maps (module-late).

Deliberately NOT ported: the external patcher's 8 unconditional STORE_PATCHES.
They carry no recovered original bytes (cannot be fail-closed) and are re-applied
every tick (would require the constant-rewrite loop this milestone forbids); the
external source records no rationale for them. Documented in the Vault ADR.

Architecture:
- patch_mem.rs: generic fail-closed primitive over a Mem trait — classify
  (ORIGINAL/ALREADY_PATCHED/MISMATCH), apply_checked (read->classify->write only on
  ORIGINAL->reread verify), VirtualQuery-guarded read + VirtualProtect/Flush write
  (WinMem). Trait abstraction makes every outcome host-testable without FIFA.
- fifa17_tls.rs: FIFA17-specific patch table + bounded poll worker (250ms, 15min
  cap, no busy-spin) started from fifa17::install() after the network redirect.
  Never patches an absolute address; never blind-writes on mismatch; a write/verify
  failure is reported, never pretended.

Phase 11: removed the season_trace CACHE_PACKNAMES_FAILED->SUCCESS force-success
bypass (a staging-only behavior-changer that was armed unconditionally in the
candidate); season_trace is now genuinely read-only passive tracing. sbc_dispatch
and store_entry remain the intended REPAIR_PROMOTED fixes.

Tests: 39 hook tests (26 baseline + 13 new: classify states, apply/idempotence,
no-blind-write on mismatch, unreadable-module wait, write-failure reporting, RVA/
live-addr relocation across bases, cert-gate pairing, patch-table integrity).
clippy --features fifa17 -D warnings clean; fmt clean; x86_64-pc-windows-gnu
cross-build. No network-config authority added (routing stays Milestone A).

Runtime validation (x64dbg site check + Windows/Linux retail) still outstanding.
2026-08-20 21:15:27 +00:00

359 lines
13 KiB
Rust

//! Generic, fail-closed byte-patch primitive shared by per-game compatibility
//! patch tables (currently FIFA 17's TLS/store gates in [`crate::fifa17_tls`]).
//!
//! The decision logic is expressed against the [`Mem`] trait rather than raw
//! process memory, so every outcome — ORIGINAL / ALREADY_PATCHED / MISMATCH and
//! the write/verify path — is unit-testable on the host without a live client.
//! [`WinMem`] is the in-process Windows implementation used at runtime.
//!
//! FAIL-CLOSED INVARIANT: a site is written only when its live bytes are *exactly*
//! the known original. Already-patched is an idempotent no-op; anything else is
//! reported and left untouched — an unrecognised or not-yet-unpacked build is
//! never blindly overwritten.
/// Longest patch payload across all tables (FIFA17 GATE1 is 6 bytes). Sizes the
/// fixed stack buffers so no slicing panic is reachable from the patch logic.
pub const MAX_PATCH_LEN: usize = 6;
/// Byte-level access to the target's address space.
pub trait Mem {
/// Fill `buf` from `addr`. `false` = not readable yet (page uncommitted /
/// module not mapped / not unpacked) — the caller waits, it is not an error.
fn read(&self, addr: usize, buf: &mut [u8]) -> bool;
/// Write `data` at `addr`. `false` = the write could not be performed.
fn write(&mut self, addr: usize, data: &[u8]) -> bool;
}
/// Fail-closed classification of live bytes against a site's original/replacement.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PatchState {
/// Live bytes are the known original — safe to patch.
Original,
/// Live bytes already equal the replacement — idempotent.
AlreadyPatched,
/// Neither — unrecognised/not-yet-ready build; must be left untouched.
Mismatch,
}
/// Pure classification (no memory access).
pub fn classify(cur: &[u8], orig: &[u8], patch: &[u8]) -> PatchState {
if cur == patch {
PatchState::AlreadyPatched
} else if cur == orig {
PatchState::Original
} else {
PatchState::Mismatch
}
}
/// Outcome of a checked patch attempt at one site.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApplyOutcome {
/// Bytes were the original and were written and re-read as the replacement.
Applied,
/// Bytes already equalled the replacement; nothing written.
AlreadyPatched,
/// Bytes were neither original nor replacement; nothing written.
Mismatch,
/// Bytes could not be read yet (module/page not available) — retry later.
NotReadable,
/// The write itself failed (protection change or copy).
WriteFailed,
/// Wrote, but the re-read did not equal the replacement.
VerifyFailed,
}
impl ApplyOutcome {
/// Whether the site now holds the replacement (freshly or already).
pub fn is_patched(self) -> bool {
matches!(self, ApplyOutcome::Applied | ApplyOutcome::AlreadyPatched)
}
}
/// Read → classify → (only on ORIGINAL) write → re-read verify. Never writes on
/// MISMATCH; treats ALREADY_PATCHED as success. `orig`/`patch` must be equal,
/// non-empty and within [`MAX_PATCH_LEN`].
pub fn apply_checked<M: Mem>(mem: &mut M, addr: usize, orig: &[u8], patch: &[u8]) -> ApplyOutcome {
debug_assert_eq!(orig.len(), patch.len());
debug_assert!(!patch.is_empty() && patch.len() <= MAX_PATCH_LEN);
let n = patch.len();
let mut cur = [0u8; MAX_PATCH_LEN];
if !mem.read(addr, &mut cur[..n]) {
return ApplyOutcome::NotReadable;
}
match classify(&cur[..n], orig, patch) {
PatchState::AlreadyPatched => ApplyOutcome::AlreadyPatched,
PatchState::Mismatch => ApplyOutcome::Mismatch,
PatchState::Original => {
if !mem.write(addr, patch) {
return ApplyOutcome::WriteFailed;
}
let mut after = [0u8; MAX_PATCH_LEN];
if !mem.read(addr, &mut after[..n]) || &after[..n] != patch {
return ApplyOutcome::VerifyFailed;
}
ApplyOutcome::Applied
}
}
}
/// RVA of a static VA relative to an image's preferred base (pure).
pub const fn rva(static_va: u64, preferred_base: u64) -> u64 {
static_va - preferred_base
}
/// Read and classify a site without writing (`None` = not readable yet). Used to
/// decide multi-site patches (e.g. apply a gate pair only when both are original).
pub fn read_state<M: Mem>(mem: &M, addr: usize, orig: &[u8], patch: &[u8]) -> Option<PatchState> {
let n = patch.len();
let mut cur = [0u8; MAX_PATCH_LEN];
if !mem.read(addr, &mut cur[..n]) {
return None;
}
Some(classify(&cur[..n], orig, patch))
}
/// Live in-process address of an image-relative site given the module's runtime base.
pub const fn live_addr(module_base: usize, rva: u64) -> usize {
module_base + rva as usize
}
/// Lowercase, unseparated hex for diagnostics (matches the autopatch SKIP line).
pub fn hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push(char::from_digit((b >> 4) as u32, 16).unwrap());
s.push(char::from_digit((b & 0xf) as u32, 16).unwrap());
}
s
}
// ─── In-process Windows memory (runtime only; not exercised by host tests) ──────
/// In-process implementation of [`Mem`] over this (FIFA17.exe) address space.
pub struct WinMem;
impl Mem for WinMem {
fn read(&self, addr: usize, buf: &mut [u8]) -> bool {
unsafe { guarded_read(addr, buf) }
}
fn write(&mut self, addr: usize, data: &[u8]) -> bool {
unsafe { protected_write(addr, data) }
}
}
/// Resolve a loaded module's runtime base by name, or `None` if not loaded.
pub unsafe fn module_base(name: *const u8) -> Option<usize> {
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
let h = GetModuleHandleA(name);
if h.is_null() {
None
} else {
Some(h as usize)
}
}
/// Read `buf.len()` bytes from `addr` only if the whole range is committed and
/// readable (VirtualQuery-guarded), so a wrong base/RVA can never fault.
unsafe fn guarded_read(addr: usize, buf: &mut [u8]) -> bool {
use windows_sys::Win32::System::Memory::{
VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READ,
PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOACCESS, PAGE_READONLY,
PAGE_READWRITE, PAGE_WRITECOPY,
};
if addr == 0 || buf.is_empty() {
return false;
}
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
let want = core::mem::size_of::<MEMORY_BASIC_INFORMATION>();
if VirtualQuery(addr as _, &mut mbi, want) != want {
return false;
}
if mbi.State != MEM_COMMIT {
return false;
}
let readable = PAGE_READONLY
| PAGE_READWRITE
| PAGE_WRITECOPY
| PAGE_EXECUTE_READ
| PAGE_EXECUTE_READWRITE
| PAGE_EXECUTE_WRITECOPY;
if mbi.Protect & readable == 0 || mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS) != 0 {
return false;
}
// The full range must fit inside this single committed region.
let region_end = (mbi.BaseAddress as usize).wrapping_add(mbi.RegionSize);
if addr.checked_add(buf.len()).is_none_or(|e| e > region_end) {
return false;
}
core::ptr::copy_nonoverlapping(addr as *const u8, buf.as_mut_ptr(), buf.len());
true
}
/// Make `[addr, addr+data.len())` writable, copy `data`, flush the instruction
/// cache, then restore the original protection. `false` if protection could not
/// be changed. Verification is the caller's re-read (see [`apply_checked`]).
unsafe fn protected_write(addr: usize, data: &[u8]) -> bool {
use windows_sys::Win32::System::Diagnostics::Debug::FlushInstructionCache;
use windows_sys::Win32::System::Memory::{VirtualProtect, PAGE_EXECUTE_READWRITE};
use windows_sys::Win32::System::Threading::GetCurrentProcess;
if addr == 0 || data.is_empty() {
return false;
}
let mut old: u32 = 0;
if VirtualProtect(addr as _, data.len(), PAGE_EXECUTE_READWRITE, &mut old) == 0 {
return false;
}
core::ptr::copy_nonoverlapping(data.as_ptr(), addr as *mut u8, data.len());
FlushInstructionCache(GetCurrentProcess(), addr as _, data.len());
// Best-effort restore of the original page protection.
let mut restored: u32 = 0;
VirtualProtect(addr as _, data.len(), old, &mut restored);
true
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
/// Deterministic fake address space for the pure patch logic.
struct FakeMem {
cells: HashMap<usize, u8>,
readable: bool,
writable: bool,
}
impl FakeMem {
fn with(addr: usize, bytes: &[u8]) -> Self {
let mut cells = HashMap::new();
for (i, b) in bytes.iter().enumerate() {
cells.insert(addr + i, *b);
}
Self {
cells,
readable: true,
writable: true,
}
}
}
impl Mem for FakeMem {
fn read(&self, addr: usize, buf: &mut [u8]) -> bool {
if !self.readable {
return false;
}
for (i, slot) in buf.iter_mut().enumerate() {
match self.cells.get(&(addr + i)) {
Some(b) => *slot = *b,
None => return false,
}
}
true
}
fn write(&mut self, addr: usize, data: &[u8]) -> bool {
if !self.writable {
return false;
}
for (i, b) in data.iter().enumerate() {
self.cells.insert(addr + i, *b);
}
true
}
}
const ORIG: [u8; 2] = [0x75, 0x0f];
const PATCH: [u8; 2] = [0x7f, 0x0f];
#[test]
fn classify_recognises_all_three_states() {
assert_eq!(classify(&ORIG, &ORIG, &PATCH), PatchState::Original);
assert_eq!(classify(&PATCH, &ORIG, &PATCH), PatchState::AlreadyPatched);
assert_eq!(classify(&[0x12, 0x34], &ORIG, &PATCH), PatchState::Mismatch);
}
#[test]
fn original_bytes_are_applied_and_verified() {
let mut m = FakeMem::with(0x1000, &ORIG);
assert_eq!(
apply_checked(&mut m, 0x1000, &ORIG, &PATCH),
ApplyOutcome::Applied
);
// Memory now holds the replacement.
let mut got = [0u8; 2];
assert!(m.read(0x1000, &mut got));
assert_eq!(got, PATCH);
}
#[test]
fn already_patched_is_idempotent_noop() {
let mut m = FakeMem::with(0x2000, &PATCH);
assert_eq!(
apply_checked(&mut m, 0x2000, &ORIG, &PATCH),
ApplyOutcome::AlreadyPatched
);
}
#[test]
fn mismatch_never_writes() {
let junk = [0xde, 0xad];
let mut m = FakeMem::with(0x3000, &junk);
assert_eq!(
apply_checked(&mut m, 0x3000, &ORIG, &PATCH),
ApplyOutcome::Mismatch
);
// Untouched.
let mut got = [0u8; 2];
assert!(m.read(0x3000, &mut got));
assert_eq!(got, junk);
}
#[test]
fn unreadable_module_waits_without_crashing() {
let mut m = FakeMem::with(0x4000, &ORIG);
m.readable = false;
let out = apply_checked(&mut m, 0x4000, &ORIG, &PATCH);
assert_eq!(out, ApplyOutcome::NotReadable);
assert!(!out.is_patched());
}
#[test]
fn write_failure_is_reported_not_pretended() {
let mut m = FakeMem::with(0x5000, &ORIG);
m.writable = false;
assert_eq!(
apply_checked(&mut m, 0x5000, &ORIG, &PATCH),
ApplyOutcome::WriteFailed
);
}
#[test]
fn running_twice_does_not_corrupt() {
let mut m = FakeMem::with(0x6000, &ORIG);
assert_eq!(
apply_checked(&mut m, 0x6000, &ORIG, &PATCH),
ApplyOutcome::Applied
);
// Second pass sees the replacement and is a no-op.
assert_eq!(
apply_checked(&mut m, 0x6000, &ORIG, &PATCH),
ApplyOutcome::AlreadyPatched
);
let mut got = [0u8; 2];
assert!(m.read(0x6000, &mut got));
assert_eq!(got, PATCH);
}
#[test]
fn rva_and_live_addr_relocate_across_bases() {
// GATE1 example: preferred 0x140000000, VA 0x146132548.
assert_eq!(rva(0x1_4613_2548, 0x1_4000_0000), 0x613_2548);
// Applied at the preferred base gives the static VA back.
assert_eq!(live_addr(0x1_4000_0000, 0x613_2548), 0x1_4613_2548);
// Applied at a relocated (ASLR) base tracks the base exactly.
assert_eq!(live_addr(0x2_0000_0000, 0x613_2548), 0x2_0613_2548);
}
#[test]
fn hex_is_lowercase_unseparated() {
assert_eq!(hex(&[0x0f, 0x85, 0xde]), "0f85de");
}
}