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:
funman300
2026-08-20 21:15:27 +00:00
parent 00ad631034
commit b098617573
5 changed files with 702 additions and 15 deletions
+6
View File
@@ -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 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
// 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`.
+334
View File
@@ -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
);
}
}
+3
View File
@@ -15,7 +15,10 @@ mod connect_hook;
mod connectex_hook;
#[cfg(feature = "fifa17")]
mod fifa17;
#[cfg(feature = "fifa17")]
mod fifa17_tls;
mod iat;
mod patch_mem;
#[cfg(feature = "fifa17")]
mod sbc_dispatch;
#[cfg(feature = "fifa17")]
+358
View File
@@ -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");
}
}
+1 -15
View File
@@ -12,7 +12,7 @@
//! flow. Targets are chosen so their copied prologues are position-independent
//! (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::LibraryLoader::GetModuleHandleA;
@@ -28,8 +28,6 @@ use crate::sbc_trace::{
use crate::write_log;
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> {
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"
));
}
// 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);
if t == 0 {
return 0;