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.
335 lines
12 KiB
Rust
335 lines
12 KiB
Rust
//! 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
|
||
);
|
||
}
|
||
}
|