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.
This commit is contained in:
@@ -144,6 +144,12 @@ unsafe extern "system" fn worker(_: *mut core::ffi::c_void) -> u32 {
|
|||||||
"fifa17: NO redirect installed (openfut.cfg missing/invalid) — EA traffic left untouched\n",
|
"fifa17: NO redirect installed (openfut.cfg missing/invalid) — EA traffic left untouched\n",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FIFA17 TLS/certificate + store crash-guard compatibility (Milestone B).
|
||||||
|
// Spawns its own bounded polling worker: patches the FIFA17.exe ProtoSSL cert
|
||||||
|
// gates once the packer unpacks them, then the CardsDLL store guard once UT
|
||||||
|
// loads it. Fail-closed and one-shot; replaces the external openfut-autopatch.
|
||||||
|
crate::fifa17_tls::install();
|
||||||
// The promoted SBC dispatch repair (and the evidence traces it decides on) arms
|
// The promoted SBC dispatch repair (and the evidence traces it decides on) arms
|
||||||
// itself from the build; its safety is the runtime signature/evidence gate. The
|
// itself from the build; its safety is the runtime signature/evidence gate. The
|
||||||
// remaining legacy experiment modules stay inert unless their env gate is `1`.
|
// remaining legacy experiment modules stay inert unless their env gate is `1`.
|
||||||
|
|||||||
@@ -0,0 +1,334 @@
|
|||||||
|
//! FIFA 17 in-process TLS/certificate + store crash-guard compatibility.
|
||||||
|
//!
|
||||||
|
//! Ports the *proven* subset of the external `openfut-autopatch` patch set into
|
||||||
|
//! `version.dll`, so the client-local contract no longer needs an external
|
||||||
|
//! `/proc`-writing patcher. Two concerns, both fail-closed and one-shot:
|
||||||
|
//!
|
||||||
|
//! 1. ProtoSSL certificate gates in FIFA17.exe (REQUIRED_FOR_TLS) — let the
|
||||||
|
//! TLS handshake against the OpenFUT bridge cert succeed. Present only after
|
||||||
|
//! the STEAMPUNKS packer maps/decrypts the real code, so they are polled for.
|
||||||
|
//! 2. The empty-"My Packs" store resolver crash-guard in CardsDLL
|
||||||
|
//! (REQUIRED_FOR_STORE_TLS, bug 6c) — CardsDLL loads lazily on entering UT,
|
||||||
|
//! so it is applied once the module appears.
|
||||||
|
//!
|
||||||
|
//! Deliberately NOT ported: the eight unconditional `STORE_PATCHES` from the
|
||||||
|
//! external patcher. They carry no recovered original bytes (cannot be
|
||||||
|
//! fail-closed) and are re-applied every tick (would require the very
|
||||||
|
//! constant-rewrite loop this milestone forbids); the external patcher's own
|
||||||
|
//! source records no rationale for them. See the Vault ADR.
|
||||||
|
//!
|
||||||
|
//! Every address is ASLR-relocated from its preferred image base at runtime
|
||||||
|
//! (`live = module_base + (static_va - preferred_base)`); nothing patches an
|
||||||
|
//! absolute address. Every write goes through [`crate::patch_mem`]'s fail-closed
|
||||||
|
//! primitive: original → write+verify, already-patched → no-op, anything else →
|
||||||
|
//! logged and skipped.
|
||||||
|
|
||||||
|
use crate::patch_mem::{self, ApplyOutcome, Mem, PatchState, WinMem};
|
||||||
|
use crate::write_log;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// FIFA17.exe preferred image base (confirmed: futmem reports the client mapped
|
||||||
|
/// flat at this base; Wine honours it, native Windows ASLR may not — hence the
|
||||||
|
/// runtime-base + RVA model below).
|
||||||
|
const FIFA17_PREFERRED_BASE: u64 = 0x1_4000_0000;
|
||||||
|
/// CardsDLL_Win64_retail.dll preferred image base.
|
||||||
|
const CARDS_PREFERRED_BASE: u64 = 0x1_8000_0000;
|
||||||
|
|
||||||
|
/// Which module a site lives in.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
enum Module {
|
||||||
|
Fifa17Exe,
|
||||||
|
CardsDll,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Module {
|
||||||
|
const fn preferred_base(self) -> u64 {
|
||||||
|
match self {
|
||||||
|
Module::Fifa17Exe => FIFA17_PREFERRED_BASE,
|
||||||
|
Module::CardsDll => CARDS_PREFERRED_BASE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runtime base of the loaded module, or `None` if not mapped yet. FIFA17.exe
|
||||||
|
/// is the main image (null name); CardsDLL is resolved by its retail name.
|
||||||
|
unsafe fn runtime_base(self) -> Option<usize> {
|
||||||
|
match self {
|
||||||
|
Module::Fifa17Exe => patch_mem::module_base(core::ptr::null()),
|
||||||
|
Module::CardsDll => {
|
||||||
|
patch_mem::module_base(c"CardsDLL_Win64_retail.dll".as_ptr().cast())
|
||||||
|
.or_else(|| patch_mem::module_base(c"CardsDLL.dll".as_ptr().cast()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One fail-closed byte patch, expressed as a static VA in its module's preferred
|
||||||
|
/// image so the derivation `RVA = VA - preferred_base` is auditable.
|
||||||
|
struct Site {
|
||||||
|
module: Module,
|
||||||
|
static_va: u64,
|
||||||
|
orig: &'static [u8],
|
||||||
|
patch: &'static [u8],
|
||||||
|
label: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Site {
|
||||||
|
const fn rva(&self) -> u64 {
|
||||||
|
patch_mem::rva(self.static_va, self.module.preferred_base())
|
||||||
|
}
|
||||||
|
fn live_addr(&self, base: usize) -> usize {
|
||||||
|
patch_mem::live_addr(base, self.rva())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ProtoSSL certificate gates (FIFA17.exe) — REQUIRED_FOR_TLS ──────────────────
|
||||||
|
// GATE1: JNZ rel32 -> 6×NOP (fall through the cert-verify failure branch).
|
||||||
|
// GATE2: function prologue -> `xor eax,eax; ret` (cert-verify returns 0/false).
|
||||||
|
// Applied as a pair, exactly like the external patcher: written only when BOTH
|
||||||
|
// read their known original, treated as done when BOTH already hold the patch.
|
||||||
|
const GATE1: Site = Site {
|
||||||
|
module: Module::Fifa17Exe,
|
||||||
|
static_va: 0x1_4613_2548,
|
||||||
|
orig: &[0x0f, 0x85, 0x76, 0x01, 0x00, 0x00],
|
||||||
|
patch: &[0x90, 0x90, 0x90, 0x90, 0x90, 0x90],
|
||||||
|
label: "GATE1",
|
||||||
|
};
|
||||||
|
const GATE2: Site = Site {
|
||||||
|
module: Module::Fifa17Exe,
|
||||||
|
static_va: 0x1_4613_61b0,
|
||||||
|
orig: &[0x48, 0x89, 0x5c],
|
||||||
|
patch: &[0x31, 0xc0, 0xc3],
|
||||||
|
label: "GATE2",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Empty "My Packs" store resolver crash-guard (CardsDLL) — REQUIRED_FOR_STORE_TLS
|
||||||
|
// JNZ 0x14869 (75 0f) -> JG 0x14869 (7f 0f): routes zero/negative store category
|
||||||
|
// ids through the Browse path instead of a NULL deref. Fail-closed one-shot.
|
||||||
|
const STORE_GUARD: Site = Site {
|
||||||
|
module: Module::CardsDll,
|
||||||
|
static_va: 0x1_8001_4858,
|
||||||
|
orig: &[0x75, 0x0f],
|
||||||
|
patch: &[0x7f, 0x0f],
|
||||||
|
label: "empty-mypacks-store-guard",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Poll cadence while waiting for the packer to unpack / CardsDLL to load. Low
|
||||||
|
/// frequency: the thread sleeps between ticks, so idle CPU is negligible.
|
||||||
|
const POLL: Duration = Duration::from_millis(250);
|
||||||
|
/// Upper bound on the whole worker's lifetime so it can never spin forever if the
|
||||||
|
/// user never enters Ultimate Team (CardsDLL never loads).
|
||||||
|
const MAX_WAIT: Duration = Duration::from_secs(15 * 60);
|
||||||
|
|
||||||
|
/// Decision for the FIFA17.exe cert-gate pair.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
enum CertAction {
|
||||||
|
/// Not both readable yet, or a mixed/unrecognised state — keep polling.
|
||||||
|
Wait,
|
||||||
|
/// Both gates hold their known original — safe to apply the pair.
|
||||||
|
Apply,
|
||||||
|
/// Both gates already hold the patch — nothing to do.
|
||||||
|
Done,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure pairing rule (unit-tested): only act when both gates agree.
|
||||||
|
fn cert_action(g1: Option<PatchState>, g2: Option<PatchState>) -> CertAction {
|
||||||
|
match (g1, g2) {
|
||||||
|
(Some(PatchState::AlreadyPatched), Some(PatchState::AlreadyPatched)) => CertAction::Done,
|
||||||
|
(Some(PatchState::Original), Some(PatchState::Original)) => CertAction::Apply,
|
||||||
|
_ => CertAction::Wait,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arm the FIFA17 TLS/store compatibility patcher: spawns a bounded background
|
||||||
|
/// worker so it never touches the loader lock and never blocks `install()`.
|
||||||
|
pub fn install() {
|
||||||
|
std::thread::spawn(|| unsafe { worker() });
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn worker() {
|
||||||
|
write_log("fifa17_tls: patch worker start\n");
|
||||||
|
let mut mem = WinMem;
|
||||||
|
let start = Instant::now();
|
||||||
|
let mut cert_done = false;
|
||||||
|
let mut guard_done = false;
|
||||||
|
// Throttle the "still waiting" diagnostics to one line each.
|
||||||
|
let mut logged_cert_wait = false;
|
||||||
|
let mut logged_guard_wait = false;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if !cert_done {
|
||||||
|
cert_done = try_cert_gates(&mut mem, &mut logged_cert_wait);
|
||||||
|
}
|
||||||
|
if !guard_done {
|
||||||
|
match Module::CardsDll.runtime_base() {
|
||||||
|
Some(cbase) => guard_done = try_store_guard(&mut mem, cbase),
|
||||||
|
None => {
|
||||||
|
if !logged_guard_wait {
|
||||||
|
write_log("fifa17_tls: waiting for CardsDLL (enter Ultimate Team)\n");
|
||||||
|
logged_guard_wait = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cert_done && guard_done {
|
||||||
|
write_log("fifa17_tls: TLS patch set complete\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if start.elapsed() >= MAX_WAIT {
|
||||||
|
write_log(&format!(
|
||||||
|
"fifa17_tls: worker stop (timeout {MAX_WAIT:?}); cert_gates_done={cert_done} store_guard_done={guard_done}\n"
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::thread::sleep(POLL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the FIFA17.exe cert-gate pair. Returns `true` once the pair is settled
|
||||||
|
/// (applied or already patched); `false` while still unpacking / not both ready.
|
||||||
|
unsafe fn try_cert_gates(mem: &mut WinMem, logged_wait: &mut bool) -> bool {
|
||||||
|
let base = match Module::Fifa17Exe.runtime_base() {
|
||||||
|
Some(b) => b,
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
let g1_addr = GATE1.live_addr(base);
|
||||||
|
let g2_addr = GATE2.live_addr(base);
|
||||||
|
let g1 = patch_mem::read_state(mem, g1_addr, GATE1.orig, GATE1.patch);
|
||||||
|
let g2 = patch_mem::read_state(mem, g2_addr, GATE2.orig, GATE2.patch);
|
||||||
|
|
||||||
|
match cert_action(g1, g2) {
|
||||||
|
CertAction::Done => {
|
||||||
|
write_log("fifa17_tls: cert gates already patched\n");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
CertAction::Apply => {
|
||||||
|
let o1 = patch_mem::apply_checked(mem, g1_addr, GATE1.orig, GATE1.patch);
|
||||||
|
let o2 = patch_mem::apply_checked(mem, g2_addr, GATE2.orig, GATE2.patch);
|
||||||
|
if o1.is_patched() && o2.is_patched() {
|
||||||
|
write_log(&format!(
|
||||||
|
"fifa17_tls: PATCHED cert gates ({} @ {g1_addr:#x} {o1:?}; {} @ {g2_addr:#x} {o2:?})\n",
|
||||||
|
GATE1.label, GATE2.label
|
||||||
|
));
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
write_log(&format!(
|
||||||
|
"fifa17_tls: cert gate write FAILED ({} {o1:?}; {} {o2:?}) — TLS NOT installed\n",
|
||||||
|
GATE1.label, GATE2.label
|
||||||
|
));
|
||||||
|
// Terminal: a write/verify failure will not fix itself by retrying.
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CertAction::Wait => {
|
||||||
|
if !*logged_wait {
|
||||||
|
write_log(&format!(
|
||||||
|
"fifa17_tls: cert gates not ready (still unpacking?) {}={g1:?} {}={g2:?}\n",
|
||||||
|
GATE1.label, GATE2.label
|
||||||
|
));
|
||||||
|
*logged_wait = true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the CardsDLL store crash-guard once CardsDLL is mapped. Returns `true`
|
||||||
|
/// once the site is settled (its bytes are final the moment CardsDLL is loaded,
|
||||||
|
/// so any read outcome is a terminal decision — no further polling).
|
||||||
|
unsafe fn try_store_guard(mem: &mut WinMem, cbase: usize) -> bool {
|
||||||
|
let addr = STORE_GUARD.live_addr(cbase);
|
||||||
|
let outcome = patch_mem::apply_checked(mem, addr, STORE_GUARD.orig, STORE_GUARD.patch);
|
||||||
|
match outcome {
|
||||||
|
ApplyOutcome::NotReadable => false, // CardsDLL mapped but this page not yet — retry
|
||||||
|
ApplyOutcome::Applied | ApplyOutcome::AlreadyPatched => {
|
||||||
|
write_log(&format!(
|
||||||
|
"fifa17_tls: store guard {} @ {addr:#x} {outcome:?} (VERIFIED empty-My-Packs)\n",
|
||||||
|
STORE_GUARD.label
|
||||||
|
));
|
||||||
|
true
|
||||||
|
}
|
||||||
|
ApplyOutcome::Mismatch => {
|
||||||
|
let mut cur = [0u8; patch_mem::MAX_PATCH_LEN];
|
||||||
|
let n = STORE_GUARD.patch.len();
|
||||||
|
let seen = if mem.read(addr, &mut cur[..n]) {
|
||||||
|
patch_mem::hex(&cur[..n])
|
||||||
|
} else {
|
||||||
|
"unreadable".into()
|
||||||
|
};
|
||||||
|
write_log(&format!(
|
||||||
|
"fifa17_tls: SKIP store guard @ {addr:#x}: unexpected {seen} (build mismatch)\n"
|
||||||
|
));
|
||||||
|
true
|
||||||
|
}
|
||||||
|
ApplyOutcome::WriteFailed | ApplyOutcome::VerifyFailed => {
|
||||||
|
write_log(&format!(
|
||||||
|
"fifa17_tls: store guard @ {addr:#x} {outcome:?}\n"
|
||||||
|
));
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_site_is_well_formed() {
|
||||||
|
for s in [&GATE1, &GATE2, &STORE_GUARD] {
|
||||||
|
assert_eq!(
|
||||||
|
s.orig.len(),
|
||||||
|
s.patch.len(),
|
||||||
|
"{}: orig/patch length",
|
||||||
|
s.label
|
||||||
|
);
|
||||||
|
assert!(!s.orig.is_empty(), "{}: empty", s.label);
|
||||||
|
assert!(
|
||||||
|
s.patch.len() <= patch_mem::MAX_PATCH_LEN,
|
||||||
|
"{}: exceeds MAX_PATCH_LEN",
|
||||||
|
s.label
|
||||||
|
);
|
||||||
|
assert_ne!(s.orig, s.patch, "{}: orig == patch", s.label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rvas_match_the_recovered_derivation() {
|
||||||
|
assert_eq!(GATE1.rva(), 0x613_2548);
|
||||||
|
assert_eq!(GATE2.rva(), 0x613_61b0);
|
||||||
|
assert_eq!(STORE_GUARD.rva(), 0x1_4858);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn live_addresses_track_the_runtime_base() {
|
||||||
|
// At the preferred base the live address is the recorded static VA.
|
||||||
|
assert_eq!(GATE1.live_addr(0x1_4000_0000), 0x1_4613_2548);
|
||||||
|
assert_eq!(STORE_GUARD.live_addr(0x1_8000_0000), 0x1_8001_4858);
|
||||||
|
// Relocated bases shift every site by the same delta.
|
||||||
|
assert_eq!(GATE1.live_addr(0x3_0000_0000), 0x3_0613_2548);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cert_pair_only_acts_when_both_gates_agree() {
|
||||||
|
use PatchState::*;
|
||||||
|
assert_eq!(
|
||||||
|
cert_action(Some(Original), Some(Original)),
|
||||||
|
CertAction::Apply
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cert_action(Some(AlreadyPatched), Some(AlreadyPatched)),
|
||||||
|
CertAction::Done
|
||||||
|
);
|
||||||
|
// Not yet unpacked / partial / mismatched => never a blind half-write.
|
||||||
|
assert_eq!(cert_action(None, None), CertAction::Wait);
|
||||||
|
assert_eq!(cert_action(Some(Original), None), CertAction::Wait);
|
||||||
|
assert_eq!(
|
||||||
|
cert_action(Some(Original), Some(AlreadyPatched)),
|
||||||
|
CertAction::Wait
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cert_action(Some(Mismatch), Some(Mismatch)),
|
||||||
|
CertAction::Wait
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,10 @@ mod connect_hook;
|
|||||||
mod connectex_hook;
|
mod connectex_hook;
|
||||||
#[cfg(feature = "fifa17")]
|
#[cfg(feature = "fifa17")]
|
||||||
mod fifa17;
|
mod fifa17;
|
||||||
|
#[cfg(feature = "fifa17")]
|
||||||
|
mod fifa17_tls;
|
||||||
mod iat;
|
mod iat;
|
||||||
|
mod patch_mem;
|
||||||
#[cfg(feature = "fifa17")]
|
#[cfg(feature = "fifa17")]
|
||||||
mod sbc_dispatch;
|
mod sbc_dispatch;
|
||||||
#[cfg(feature = "fifa17")]
|
#[cfg(feature = "fifa17")]
|
||||||
|
|||||||
@@ -0,0 +1,358 @@
|
|||||||
|
//! 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
//! flow. Targets are chosen so their copied prologues are position-independent
|
//! flow. Targets are chosen so their copied prologues are position-independent
|
||||||
//! (no rip-relative / rel32 in the copied bytes).
|
//! (no rip-relative / rel32 in the copied bytes).
|
||||||
|
|
||||||
use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
use core::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
use windows_sys::Win32::System::Diagnostics::Debug::FlushInstructionCache;
|
use windows_sys::Win32::System::Diagnostics::Debug::FlushInstructionCache;
|
||||||
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
|
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
|
||||||
@@ -28,8 +28,6 @@ use crate::sbc_trace::{
|
|||||||
use crate::write_log;
|
use crate::write_log;
|
||||||
|
|
||||||
static REPORTS: AtomicUsize = AtomicUsize::new(0);
|
static REPORTS: AtomicUsize = AtomicUsize::new(0);
|
||||||
/// One-shot guard for the staging-only CACHE_PACKNAMES_FAILED -> SUCCESS bypass.
|
|
||||||
static BYPASS_DONE: AtomicBool = AtomicBool::new(false);
|
|
||||||
|
|
||||||
unsafe fn rd_i32(addr: usize) -> Option<i32> {
|
unsafe fn rd_i32(addr: usize) -> Option<i32> {
|
||||||
readable_range(addr, 4).then(|| core::ptr::read_volatile(addr as *const i32))
|
readable_range(addr, 4).then(|| core::ptr::read_volatile(addr as *const i32))
|
||||||
@@ -390,18 +388,6 @@ unsafe extern "system" fn final_completion_wrapper(
|
|||||||
"SEASONS_LOAD_CALLBACK: final kind={kind} result={shown:?} flag={flag:?} ctx={ctx:#x} cbref={cbref:#x}\n"
|
"SEASONS_LOAD_CALLBACK: final kind={kind} result={shown:?} flag={flag:?} ctx={ctx:#x} cbref={cbref:#x}\n"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// Guarded one-shot bypass (staging diagnostic only): rewrite the pack-names
|
|
||||||
// failure to SUCCESS so the offline-season load advances to
|
|
||||||
// LoadCurrentOfflineSeason. Fires only for the exact CACHE_PACKNAMES failure,
|
|
||||||
// once per process; verified by the error string before touching memory.
|
|
||||||
if flag == Some(0)
|
|
||||||
&& errstr.contains("CACHE_PACKNAMES")
|
|
||||||
&& readable_range(result, 1)
|
|
||||||
&& !BYPASS_DONE.swap(true, Ordering::AcqRel)
|
|
||||||
{
|
|
||||||
core::ptr::write_volatile(result as *mut u8, 1u8); // take the SUCCESS branch
|
|
||||||
write_log("SEASONS_BYPASS: forced CACHE_PACKNAMES_FAILED -> SUCCESS (one-shot, staging)\n");
|
|
||||||
}
|
|
||||||
let t = FINAL_COMPLETION_TRAMP.load(Ordering::Acquire);
|
let t = FINAL_COMPLETION_TRAMP.load(Ordering::Acquire);
|
||||||
if t == 0 {
|
if t == 0 {
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user