b098617573
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.
359 lines
13 KiB
Rust
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");
|
|
}
|
|
}
|