94feaec63f
Retail Gates A-G passed on the pinned CardsDLL build (4706a881), and the repair has been live-proven repeatedly, so it is now a promoted feature. Arming it from OPENFUT_SBC_DISPATCH meant any launch that did not export it (Steam, the launcher Launch button, a bare umu-run) silently lost the SBC screen to the known response-to-deserializer dispatch defect, leaving a harness script as the only working entry point. REPAIR_PROMOTED is now a build constant with a compile-time contract, and both install sites derive from it: sbc_dispatch::install always arms, and sbc_trace::install derives the parser/notifier/controller-registration traces from it because those traces ARE the repair decision inputs, not optional diagnostics. Promotion weakens no check. Safety stays in the runtime evidence gate rather than a flag: the worker still validates the exact CardsDLL signatures before installing a detour, and decide() still requires the transport sentinel status, the pinned category-response vtable captured while the response object was provably live, balanced parser counts on the one parser thread, this generation notifier having entered AND returned, the captured controller/model identity, and one repair per deserializer generation. An unrecognised build leaves native execution untouched. Rollback is a file swap (restore the previous version.dll via the hook harness backup), the documented client rollback path, deliberately not an env kill-switch. fmt clean, strict clippy clean on default and fifa17 features, 22 tests pass, release cross-build to x86_64-pc-windows-gnu produces artifact 3641d581.
1250 lines
47 KiB
Rust
1250 lines
47 KiB
Rust
//! Passive, behavior-preserving trace hooks for FIFA 17's SBC category response.
|
|
|
|
use core::ffi::c_void;
|
|
use core::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
|
use windows_sys::Win32::Foundation::{
|
|
CloseHandle, GetLastError, ERROR_NO_MORE_FILES, HANDLE, INVALID_HANDLE_VALUE,
|
|
};
|
|
use windows_sys::Win32::System::Diagnostics::Debug::{
|
|
FlushInstructionCache, GetThreadContext, CONTEXT, CONTEXT_CONTROL_AMD64,
|
|
};
|
|
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
|
|
CreateToolhelp32Snapshot, Thread32First, Thread32Next, TH32CS_SNAPTHREAD, THREADENTRY32,
|
|
};
|
|
use windows_sys::Win32::System::LibraryLoader::{
|
|
GetModuleHandleA, GetModuleHandleExA, GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS,
|
|
GET_MODULE_HANDLE_EX_FLAG_PIN,
|
|
};
|
|
use windows_sys::Win32::System::Memory::{
|
|
VirtualAlloc, VirtualFree, VirtualProtect, VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT,
|
|
MEM_RELEASE, MEM_RESERVE, PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY,
|
|
PAGE_GUARD, PAGE_NOACCESS, PAGE_READWRITE,
|
|
};
|
|
use windows_sys::Win32::System::Threading::{
|
|
GetCurrentProcess, GetCurrentProcessId, GetCurrentThreadId, OpenThread, ResumeThread,
|
|
SuspendThread, THREAD_GET_CONTEXT, THREAD_SUSPEND_RESUME,
|
|
};
|
|
|
|
pub(crate) const CATEGORY_FACTORY_RVA: usize = 0x17aa10;
|
|
pub(crate) const CATEGORY_DESERIALIZER_RVA: usize = 0x17b2b0;
|
|
const COPY_LEN: usize = 19;
|
|
const ABS_JUMP_LEN: usize = 14;
|
|
const TRAMPOLINE_LEN: usize = COPY_LEN + ABS_JUMP_LEN;
|
|
const NOTIFIER_RVA: usize = 0x17aa80;
|
|
const NOTIFIER_COPY_LEN: usize = 15;
|
|
const CONTROLLER_REGISTER_RVA: usize = 0x1a4a70;
|
|
const CONTROLLER_REGISTER_COPY_LEN: usize = 15;
|
|
const FUT_SBS_CATEGORIES_EVENT: u32 = 0x756c;
|
|
const CONTROLLER_REGISTER_SIGNATURE: [u8; CONTROLLER_REGISTER_COPY_LEN] = [
|
|
0x89, 0x54, 0x24, 0x10, 0x48, 0x83, 0xec, 0x28, 0x4c, 0x8d, 0x81, 0xc0, 0x00, 0x00, 0x00,
|
|
];
|
|
const NOTIFIER_SIGNATURE: [u8; 32] = [
|
|
0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x74, 0x24, 0x10, 0x57, 0x48, 0x83, 0xec, 0x20, 0x48,
|
|
0x8b, 0x59, 0x58, 0x48, 0x8b, 0x71, 0x60, 0x33, 0xff, 0x48, 0x2b, 0xf3, 0xc6, 0x81, 0x88, 0x00,
|
|
];
|
|
const MAX_PEERS: usize = 512;
|
|
const MAX_QUIESCE_PASSES: usize = 8;
|
|
const CONTROL_RVA: usize = 0x180d00;
|
|
const CONTROL_SIGNATURE: [u8; 12] = [
|
|
0x48, 0x83, 0xec, 0x28, 0x48, 0x85, 0xc9, 0x74, 0x50, 0x45, 0x33, 0xc0,
|
|
];
|
|
|
|
const FACTORY_SIGNATURE: [u8; 32] = [
|
|
0x48, 0x89, 0x4c, 0x24, 0x08, 0x53, 0x48, 0x83, 0xec, 0x30, 0x48, 0xc7, 0x44, 0x24, 0x20, 0xfe,
|
|
0xff, 0xff, 0xff, 0x33, 0xc0, 0x89, 0x44, 0x24, 0x48, 0x48, 0x8d, 0x44, 0x24, 0x48, 0x48, 0x89,
|
|
];
|
|
const DESERIALIZER_SIGNATURE: [u8; 32] = [
|
|
0x48, 0x8b, 0xc4, 0x55, 0x41, 0x54, 0x41, 0x55, 0x41, 0x56, 0x41, 0x57, 0x48, 0x8d, 0xa8, 0xe8,
|
|
0xfd, 0xff, 0xff, 0x48, 0x81, 0xec, 0xf0, 0x02, 0x00, 0x00, 0x48, 0xc7, 0x45, 0x98, 0xfe, 0xff,
|
|
];
|
|
|
|
type FactoryFn = unsafe extern "system" fn(*mut c_void) -> *mut c_void;
|
|
type DeserializerFn = unsafe extern "system" fn(*mut c_void, *mut c_void) -> u8;
|
|
|
|
static ENABLED: AtomicBool = AtomicBool::new(false);
|
|
static STATE: AtomicUsize = AtomicUsize::new(TraceState::Disabled as usize);
|
|
static FACTORY_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
|
|
static DESERIALIZER_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
|
|
static FACTORY_ENTRIES: AtomicU64 = AtomicU64::new(0);
|
|
static FACTORY_EXITS: AtomicU64 = AtomicU64::new(0);
|
|
static FACTORY_LAST_THIS: AtomicUsize = AtomicUsize::new(0);
|
|
static FACTORY_LAST_RESULT: AtomicUsize = AtomicUsize::new(0);
|
|
static FACTORY_LAST_THREAD: AtomicUsize = AtomicUsize::new(0);
|
|
static DESERIALIZER_ENTRIES: AtomicU64 = AtomicU64::new(0);
|
|
static DESERIALIZER_EXITS: AtomicU64 = AtomicU64::new(0);
|
|
static DESERIALIZER_LAST_THIS: AtomicUsize = AtomicUsize::new(0);
|
|
static DESERIALIZER_LAST_READER: AtomicUsize = AtomicUsize::new(0);
|
|
static DESERIALIZER_LAST_RESULT: AtomicBool = AtomicBool::new(false);
|
|
static DESERIALIZER_LAST_THREAD: AtomicUsize = AtomicUsize::new(0);
|
|
static TRACE_BASE: AtomicUsize = AtomicUsize::new(0);
|
|
static DESERIALIZER_EXIT_M: AtomicUsize = AtomicUsize::new(0);
|
|
static DESERIALIZER_EXIT_COUNT: AtomicUsize = AtomicUsize::new(0);
|
|
static DESERIALIZER_EXIT_B_READY: AtomicUsize = AtomicUsize::new(usize::MAX);
|
|
static DESERIALIZER_EXIT_RESPONSE_VTABLE: AtomicUsize = AtomicUsize::new(0);
|
|
static NOTIFIER_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
|
|
static CONTROLLER_REGISTER_TRAMPOLINE: AtomicUsize = AtomicUsize::new(0);
|
|
static NOTIFIER_ENTRIES: AtomicU64 = AtomicU64::new(0);
|
|
static NOTIFIER_EXITS: AtomicU64 = AtomicU64::new(0);
|
|
static NOTIFIER_CTX: AtomicUsize = AtomicUsize::new(0);
|
|
static NOTIFIER_BYTE_BEFORE: AtomicUsize = AtomicUsize::new(usize::MAX);
|
|
static NOTIFIER_BYTE_AFTER: AtomicUsize = AtomicUsize::new(usize::MAX);
|
|
static NOTIFIER_BEGIN: AtomicUsize = AtomicUsize::new(0);
|
|
static NOTIFIER_END: AtomicUsize = AtomicUsize::new(0);
|
|
static NOTIFIER_COUNT: AtomicUsize = AtomicUsize::new(usize::MAX);
|
|
static PATCH_INSTALLER_BUSY: AtomicBool = AtomicBool::new(false);
|
|
static CODE_PATCH_PENDING: AtomicUsize = AtomicUsize::new(0);
|
|
|
|
pub(crate) struct PatchInstallerGate;
|
|
|
|
impl Drop for PatchInstallerGate {
|
|
fn drop(&mut self) {
|
|
PATCH_INSTALLER_BUSY.store(false, Ordering::Release);
|
|
}
|
|
}
|
|
|
|
pub(crate) fn acquire_patch_installer_gate() -> Option<PatchInstallerGate> {
|
|
for _ in 0..200 {
|
|
if PATCH_INSTALLER_BUSY
|
|
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
|
.is_ok()
|
|
{
|
|
return Some(PatchInstallerGate);
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
|
}
|
|
None
|
|
}
|
|
|
|
pub(crate) struct CodeInstallerPending;
|
|
|
|
impl Drop for CodeInstallerPending {
|
|
fn drop(&mut self) {
|
|
CODE_PATCH_PENDING.fetch_sub(1, Ordering::AcqRel);
|
|
}
|
|
}
|
|
|
|
pub(crate) fn code_patch_installers_ready() -> bool {
|
|
CODE_PATCH_PENDING.load(Ordering::Acquire) == 0
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
#[repr(usize)]
|
|
enum TraceState {
|
|
Disabled,
|
|
Requested,
|
|
Installed,
|
|
Failed,
|
|
DegradedHookActive,
|
|
DegradedProcessState,
|
|
DegradedHookAndProcess,
|
|
}
|
|
|
|
pub(crate) fn env_enabled(value: Option<&str>) -> bool {
|
|
matches!(value, Some("1"))
|
|
}
|
|
|
|
pub(crate) fn target_va(base: usize, rva: usize) -> Option<usize> {
|
|
base.checked_add(rva)
|
|
}
|
|
|
|
pub(crate) fn absolute_jump(destination: usize) -> [u8; ABS_JUMP_LEN] {
|
|
let mut jump = [0u8; ABS_JUMP_LEN];
|
|
jump[..6].copy_from_slice(&[0xff, 0x25, 0, 0, 0, 0]);
|
|
jump[6..].copy_from_slice(&(destination as u64).to_le_bytes());
|
|
jump
|
|
}
|
|
|
|
fn instruction_pointer_in_span(rip: usize, target: usize) -> bool {
|
|
target
|
|
.checked_add(COPY_LEN)
|
|
.map(|end| rip >= target && rip < end)
|
|
.unwrap_or(true)
|
|
}
|
|
|
|
pub(crate) struct SuspendedPeers {
|
|
handles: [HANDLE; MAX_PEERS],
|
|
tids: [u32; MAX_PEERS],
|
|
count: usize,
|
|
}
|
|
|
|
impl SuspendedPeers {
|
|
fn empty() -> Self {
|
|
Self {
|
|
handles: [core::ptr::null_mut(); MAX_PEERS],
|
|
tids: [0; MAX_PEERS],
|
|
count: 0,
|
|
}
|
|
}
|
|
|
|
fn contains_tid(&self, tid: u32) -> bool {
|
|
self.tids[..self.count].contains(&tid)
|
|
}
|
|
|
|
pub(crate) unsafe fn resume_all(&mut self) -> bool {
|
|
let mut all_resumed = true;
|
|
for index in (0..self.count).rev() {
|
|
let handle = self.handles[index];
|
|
if handle.is_null() {
|
|
continue;
|
|
}
|
|
if ResumeThread(handle) == u32::MAX {
|
|
all_resumed = false;
|
|
continue;
|
|
}
|
|
CloseHandle(handle);
|
|
self.handles[index] = core::ptr::null_mut();
|
|
}
|
|
all_resumed
|
|
}
|
|
}
|
|
|
|
impl Drop for SuspendedPeers {
|
|
fn drop(&mut self) {
|
|
// Emergency retry only. A handle whose thread still cannot be resumed is
|
|
// intentionally leaked rather than closed while its thread is suspended.
|
|
unsafe {
|
|
let _ = self.resume_all();
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub(crate) enum QuiesceFailure {
|
|
Acquire,
|
|
Resume,
|
|
}
|
|
|
|
/// Stop and inspect every peer thread before touching either entry point. Any
|
|
/// incomplete enumeration/access/context operation fails the transaction closed.
|
|
pub(crate) unsafe fn suspend_peers(
|
|
factory: usize,
|
|
deserializer: usize,
|
|
) -> Result<SuspendedPeers, QuiesceFailure> {
|
|
let process_id = GetCurrentProcessId();
|
|
let current_thread_id = GetCurrentThreadId();
|
|
let mut peers = SuspendedPeers::empty();
|
|
for _ in 0..MAX_QUIESCE_PASSES {
|
|
// Enumerate into fixed stack storage before suspending anything found in
|
|
// this pass. Later passes close the thread-creation race to a fixed point.
|
|
let mut candidates = [0u32; MAX_PEERS];
|
|
let mut candidate_count = 0usize;
|
|
let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
|
|
if snapshot == INVALID_HANDLE_VALUE {
|
|
break;
|
|
}
|
|
let mut entry: THREADENTRY32 = core::mem::zeroed();
|
|
entry.dwSize = core::mem::size_of::<THREADENTRY32>() as u32;
|
|
let mut available = Thread32First(snapshot, &mut entry) != 0;
|
|
if !available {
|
|
CloseHandle(snapshot);
|
|
break;
|
|
}
|
|
let mut enumeration_ok = true;
|
|
while available {
|
|
let tid = entry.th32ThreadID;
|
|
if entry.th32OwnerProcessID == process_id
|
|
&& tid != current_thread_id
|
|
&& !peers.contains_tid(tid)
|
|
{
|
|
if peers.count + candidate_count >= MAX_PEERS {
|
|
enumeration_ok = false;
|
|
break;
|
|
}
|
|
if !candidates[..candidate_count].contains(&tid) {
|
|
candidates[candidate_count] = tid;
|
|
candidate_count += 1;
|
|
}
|
|
}
|
|
entry.dwSize = core::mem::size_of::<THREADENTRY32>() as u32;
|
|
available = Thread32Next(snapshot, &mut entry) != 0;
|
|
if !available && GetLastError() != ERROR_NO_MORE_FILES {
|
|
enumeration_ok = false;
|
|
}
|
|
}
|
|
CloseHandle(snapshot);
|
|
if !enumeration_ok {
|
|
return Err(if peers.resume_all() {
|
|
QuiesceFailure::Acquire
|
|
} else {
|
|
QuiesceFailure::Resume
|
|
});
|
|
}
|
|
if candidate_count == 0 {
|
|
return Ok(peers);
|
|
}
|
|
for &tid in &candidates[..candidate_count] {
|
|
let handle = OpenThread(THREAD_SUSPEND_RESUME | THREAD_GET_CONTEXT, 0, tid);
|
|
if handle.is_null() || SuspendThread(handle) == u32::MAX {
|
|
if !handle.is_null() {
|
|
CloseHandle(handle);
|
|
}
|
|
return Err(if peers.resume_all() {
|
|
QuiesceFailure::Acquire
|
|
} else {
|
|
QuiesceFailure::Resume
|
|
});
|
|
}
|
|
peers.handles[peers.count] = handle;
|
|
peers.tids[peers.count] = tid;
|
|
peers.count += 1;
|
|
let mut context: CONTEXT = core::mem::zeroed();
|
|
context.ContextFlags = CONTEXT_CONTROL_AMD64;
|
|
if GetThreadContext(handle, &mut context) == 0
|
|
|| instruction_pointer_in_span(context.Rip as usize, factory)
|
|
|| instruction_pointer_in_span(context.Rip as usize, deserializer)
|
|
{
|
|
return Err(if peers.resume_all() {
|
|
QuiesceFailure::Acquire
|
|
} else {
|
|
QuiesceFailure::Resume
|
|
});
|
|
}
|
|
}
|
|
}
|
|
Err(if peers.resume_all() {
|
|
QuiesceFailure::Acquire
|
|
} else {
|
|
QuiesceFailure::Resume
|
|
})
|
|
}
|
|
|
|
unsafe fn executable_range(address: usize, length: usize) -> bool {
|
|
let Some(end) = address.checked_add(length) else {
|
|
return false;
|
|
};
|
|
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
|
|
if VirtualQuery(
|
|
address as _,
|
|
&mut mbi,
|
|
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
|
|
) == 0
|
|
|| mbi.State != MEM_COMMIT
|
|
|| mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS) != 0
|
|
{
|
|
return false;
|
|
}
|
|
let protection = mbi.Protect & 0xff;
|
|
matches!(
|
|
protection,
|
|
PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY
|
|
) && end <= mbi.BaseAddress as usize + mbi.RegionSize
|
|
}
|
|
|
|
pub(crate) unsafe fn executable_range_in_image(base: usize, address: usize, length: usize) -> bool {
|
|
let Some(end) = address.checked_add(length) else {
|
|
return false;
|
|
};
|
|
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
|
|
if VirtualQuery(
|
|
address as _,
|
|
&mut mbi,
|
|
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
|
|
) == 0
|
|
{
|
|
return false;
|
|
}
|
|
executable_range(address, length)
|
|
&& mbi.AllocationBase as usize == base
|
|
&& end <= mbi.BaseAddress as usize + mbi.RegionSize
|
|
}
|
|
|
|
pub(crate) unsafe fn readable_range(address: usize, length: usize) -> bool {
|
|
let Some(end) = address.checked_add(length) else {
|
|
return false;
|
|
};
|
|
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
|
|
VirtualQuery(
|
|
address as _,
|
|
&mut mbi,
|
|
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
|
|
) != 0
|
|
&& mbi.State == MEM_COMMIT
|
|
&& mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS) == 0
|
|
&& end <= mbi.BaseAddress as usize + mbi.RegionSize
|
|
}
|
|
|
|
pub(crate) unsafe fn guarded_usize(address: usize) -> Option<usize> {
|
|
(address & 7 == 0 && readable_range(address, 8))
|
|
.then(|| core::ptr::read_volatile(address as *const usize))
|
|
}
|
|
|
|
pub(crate) unsafe fn guarded_u16(address: usize) -> Option<u16> {
|
|
readable_range(address, 2).then(|| core::ptr::read_volatile(address as *const u16))
|
|
}
|
|
|
|
pub(crate) unsafe fn guarded_u8(address: usize) -> Option<u8> {
|
|
readable_range(address, 1).then(|| core::ptr::read_volatile(address as *const u8))
|
|
}
|
|
|
|
pub(crate) unsafe fn valid_cards_image(base: usize) -> bool {
|
|
let Some(control) = base.checked_add(CONTROL_RVA) else {
|
|
return false;
|
|
};
|
|
if !readable_range(base, 0x1000) || !executable_range(control, CONTROL_SIGNATURE.len()) {
|
|
return false;
|
|
}
|
|
if *(base as *const u16) != 0x5a4d {
|
|
return false;
|
|
}
|
|
let pe_off = *((base + 0x3c) as *const u32) as usize;
|
|
if pe_off > 0xf00 {
|
|
return false;
|
|
}
|
|
let Some(pe) = base.checked_add(pe_off) else {
|
|
return false;
|
|
};
|
|
if *(pe as *const u32) != 0x0000_4550 {
|
|
return false;
|
|
}
|
|
let Some(size_field) = pe.checked_add(24 + 0x38) else {
|
|
return false;
|
|
};
|
|
let size = *(size_field as *const u32) as usize;
|
|
let required = CATEGORY_DESERIALIZER_RVA + DESERIALIZER_SIGNATURE.len();
|
|
size > required
|
|
&& core::slice::from_raw_parts(control as *const u8, CONTROL_SIGNATURE.len())
|
|
== CONTROL_SIGNATURE
|
|
}
|
|
|
|
pub(crate) unsafe fn validate_cards_build(base: usize) -> bool {
|
|
valid_cards_image(base)
|
|
}
|
|
|
|
unsafe fn signature_matches(target: usize, signature: &[u8; 32]) -> bool {
|
|
core::slice::from_raw_parts(target as *const u8, signature.len()) == signature
|
|
}
|
|
|
|
pub(crate) unsafe fn allocate_trampoline(target: usize, copy_len: usize) -> Option<usize> {
|
|
let trampoline_len = copy_len.checked_add(ABS_JUMP_LEN)?;
|
|
let memory = VirtualAlloc(
|
|
core::ptr::null(),
|
|
trampoline_len,
|
|
MEM_COMMIT | MEM_RESERVE,
|
|
PAGE_READWRITE,
|
|
) as usize;
|
|
if memory == 0 {
|
|
return None;
|
|
}
|
|
core::ptr::copy_nonoverlapping(target as *const u8, memory as *mut u8, copy_len);
|
|
let Some(resume) = target.checked_add(copy_len) else {
|
|
VirtualFree(memory as _, 0, MEM_RELEASE);
|
|
return None;
|
|
};
|
|
let jump = absolute_jump(resume);
|
|
core::ptr::copy_nonoverlapping(jump.as_ptr(), (memory + copy_len) as *mut u8, ABS_JUMP_LEN);
|
|
let mut old = 0u32;
|
|
if VirtualProtect(memory as _, trampoline_len, PAGE_EXECUTE_READ, &mut old) == 0
|
|
|| FlushInstructionCache(GetCurrentProcess(), memory as _, trampoline_len) == 0
|
|
{
|
|
VirtualFree(memory as _, 0, MEM_RELEASE);
|
|
return None;
|
|
}
|
|
Some(memory)
|
|
}
|
|
|
|
unsafe fn write_entry(
|
|
target: usize,
|
|
destination: usize,
|
|
original: &[u8; COPY_LEN],
|
|
published: &mut bool,
|
|
) -> Result<(), bool> {
|
|
let mut patch = [0x90u8; COPY_LEN];
|
|
patch[..ABS_JUMP_LEN].copy_from_slice(&absolute_jump(destination));
|
|
let mut old = 0u32;
|
|
if VirtualProtect(target as _, COPY_LEN, PAGE_EXECUTE_READWRITE, &mut old) == 0 {
|
|
return Err(true);
|
|
}
|
|
// From this point onward another processor may observe the detour, even if a
|
|
// later cache/protection operation fails and rollback restores original bytes.
|
|
*published = true;
|
|
core::ptr::copy_nonoverlapping(patch.as_ptr(), target as *mut u8, COPY_LEN);
|
|
let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, COPY_LEN) != 0;
|
|
let mut ignored = 0u32;
|
|
let restored = VirtualProtect(target as _, COPY_LEN, old, &mut ignored) != 0;
|
|
if flushed && restored {
|
|
Ok(())
|
|
} else {
|
|
// `true` means the caller may safely free the trampoline. A false value
|
|
// means rollback itself failed, so executable backing must be retained.
|
|
Err(restore_entry(target, original))
|
|
}
|
|
}
|
|
|
|
unsafe fn restore_entry(target: usize, original: &[u8; COPY_LEN]) -> bool {
|
|
let mut old = 0u32;
|
|
if VirtualProtect(target as _, COPY_LEN, PAGE_EXECUTE_READWRITE, &mut old) == 0 {
|
|
return false;
|
|
}
|
|
core::ptr::copy_nonoverlapping(original.as_ptr(), target as *mut u8, COPY_LEN);
|
|
let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, COPY_LEN) != 0;
|
|
let mut ignored = 0u32;
|
|
let restored = VirtualProtect(target as _, COPY_LEN, old, &mut ignored) != 0;
|
|
flushed && restored
|
|
}
|
|
|
|
unsafe fn restore_notifier_entry(target: usize) -> bool {
|
|
let original: [u8; NOTIFIER_COPY_LEN] =
|
|
NOTIFIER_SIGNATURE[..NOTIFIER_COPY_LEN].try_into().unwrap();
|
|
let mut old = 0u32;
|
|
if VirtualProtect(
|
|
target as _,
|
|
NOTIFIER_COPY_LEN,
|
|
PAGE_EXECUTE_READWRITE,
|
|
&mut old,
|
|
) == 0
|
|
{
|
|
return false;
|
|
}
|
|
core::ptr::copy_nonoverlapping(original.as_ptr(), target as *mut u8, NOTIFIER_COPY_LEN);
|
|
let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, NOTIFIER_COPY_LEN) != 0;
|
|
let mut ignored = 0u32;
|
|
flushed && VirtualProtect(target as _, NOTIFIER_COPY_LEN, old, &mut ignored) != 0
|
|
}
|
|
|
|
unsafe fn write_notifier_entry(
|
|
target: usize,
|
|
destination: usize,
|
|
published: &mut bool,
|
|
) -> Result<(), bool> {
|
|
let mut patch = [0x90u8; NOTIFIER_COPY_LEN];
|
|
patch[..ABS_JUMP_LEN].copy_from_slice(&absolute_jump(destination));
|
|
let mut old = 0u32;
|
|
if VirtualProtect(
|
|
target as _,
|
|
NOTIFIER_COPY_LEN,
|
|
PAGE_EXECUTE_READWRITE,
|
|
&mut old,
|
|
) == 0
|
|
{
|
|
return Err(true);
|
|
}
|
|
*published = true;
|
|
core::ptr::copy_nonoverlapping(patch.as_ptr(), target as *mut u8, NOTIFIER_COPY_LEN);
|
|
let flushed = FlushInstructionCache(GetCurrentProcess(), target as _, NOTIFIER_COPY_LEN) != 0;
|
|
let mut ignored = 0u32;
|
|
if flushed && VirtualProtect(target as _, NOTIFIER_COPY_LEN, old, &mut ignored) != 0 {
|
|
Ok(())
|
|
} else {
|
|
Err(restore_notifier_entry(target))
|
|
}
|
|
}
|
|
|
|
unsafe fn restore_controller_register_entry(target: usize) -> bool {
|
|
let mut old = 0u32;
|
|
if VirtualProtect(
|
|
target as _,
|
|
CONTROLLER_REGISTER_COPY_LEN,
|
|
PAGE_EXECUTE_READWRITE,
|
|
&mut old,
|
|
) == 0
|
|
{
|
|
return false;
|
|
}
|
|
core::ptr::copy_nonoverlapping(
|
|
CONTROLLER_REGISTER_SIGNATURE.as_ptr(),
|
|
target as *mut u8,
|
|
CONTROLLER_REGISTER_COPY_LEN,
|
|
);
|
|
let flushed = FlushInstructionCache(
|
|
GetCurrentProcess(),
|
|
target as _,
|
|
CONTROLLER_REGISTER_COPY_LEN,
|
|
) != 0;
|
|
let mut ignored = 0u32;
|
|
flushed && VirtualProtect(target as _, CONTROLLER_REGISTER_COPY_LEN, old, &mut ignored) != 0
|
|
}
|
|
|
|
unsafe fn write_controller_register_entry(
|
|
target: usize,
|
|
destination: usize,
|
|
published: &mut bool,
|
|
) -> Result<(), bool> {
|
|
let mut patch = [0x90u8; CONTROLLER_REGISTER_COPY_LEN];
|
|
patch[..ABS_JUMP_LEN].copy_from_slice(&absolute_jump(destination));
|
|
let mut old = 0u32;
|
|
if VirtualProtect(
|
|
target as _,
|
|
CONTROLLER_REGISTER_COPY_LEN,
|
|
PAGE_EXECUTE_READWRITE,
|
|
&mut old,
|
|
) == 0
|
|
{
|
|
return Err(true);
|
|
}
|
|
*published = true;
|
|
core::ptr::copy_nonoverlapping(
|
|
patch.as_ptr(),
|
|
target as *mut u8,
|
|
CONTROLLER_REGISTER_COPY_LEN,
|
|
);
|
|
let flushed = FlushInstructionCache(
|
|
GetCurrentProcess(),
|
|
target as _,
|
|
CONTROLLER_REGISTER_COPY_LEN,
|
|
) != 0;
|
|
let mut ignored = 0u32;
|
|
if flushed && VirtualProtect(target as _, CONTROLLER_REGISTER_COPY_LEN, old, &mut ignored) != 0
|
|
{
|
|
Ok(())
|
|
} else {
|
|
Err(restore_controller_register_entry(target))
|
|
}
|
|
}
|
|
|
|
unsafe extern "system" fn controller_register_wrapper(controller: *mut c_void, event: u32) {
|
|
let original: unsafe extern "system" fn(*mut c_void, u32) =
|
|
core::mem::transmute(CONTROLLER_REGISTER_TRAMPOLINE.load(Ordering::Acquire));
|
|
original(controller, event);
|
|
if event == FUT_SBS_CATEGORIES_EVENT {
|
|
crate::sbc_dispatch::note_sbc_controller(
|
|
controller as usize,
|
|
TRACE_BASE.load(Ordering::Acquire),
|
|
);
|
|
}
|
|
}
|
|
|
|
unsafe extern "system" fn notifier_wrapper(ctx: *mut c_void) {
|
|
NOTIFIER_ENTRIES.fetch_add(1, Ordering::Relaxed);
|
|
let address = ctx as usize;
|
|
NOTIFIER_CTX.store(address, Ordering::Relaxed);
|
|
NOTIFIER_BYTE_BEFORE.store(
|
|
address
|
|
.checked_add(0x88)
|
|
.and_then(|p| guarded_u8(p))
|
|
.map(usize::from)
|
|
.unwrap_or(usize::MAX),
|
|
Ordering::Relaxed,
|
|
);
|
|
let begin = address
|
|
.checked_add(0x58)
|
|
.and_then(|p| guarded_usize(p))
|
|
.unwrap_or(0);
|
|
let end = address
|
|
.checked_add(0x60)
|
|
.and_then(|p| guarded_usize(p))
|
|
.unwrap_or(0);
|
|
let count = if end >= begin && (end - begin) & 7 == 0 {
|
|
(end - begin) / 8
|
|
} else {
|
|
usize::MAX
|
|
};
|
|
NOTIFIER_BEGIN.store(begin, Ordering::Relaxed);
|
|
NOTIFIER_END.store(end, Ordering::Relaxed);
|
|
NOTIFIER_COUNT.store(count, Ordering::Relaxed);
|
|
let original: unsafe extern "system" fn(*mut c_void) =
|
|
core::mem::transmute(NOTIFIER_TRAMPOLINE.load(Ordering::Acquire));
|
|
original(ctx);
|
|
NOTIFIER_BYTE_AFTER.store(
|
|
address
|
|
.checked_add(0x88)
|
|
.and_then(|p| guarded_u8(p))
|
|
.map(usize::from)
|
|
.unwrap_or(usize::MAX),
|
|
Ordering::Relaxed,
|
|
);
|
|
NOTIFIER_EXITS.fetch_add(1, Ordering::Release);
|
|
}
|
|
|
|
unsafe extern "system" fn factory_wrapper(this: *mut c_void) -> *mut c_void {
|
|
FACTORY_ENTRIES.fetch_add(1, Ordering::Relaxed);
|
|
FACTORY_LAST_THREAD.store(GetCurrentThreadId() as usize, Ordering::Relaxed);
|
|
FACTORY_LAST_THIS.store(this as usize, Ordering::Relaxed);
|
|
let address = FACTORY_TRAMPOLINE.load(Ordering::Acquire);
|
|
let original: FactoryFn = core::mem::transmute(address);
|
|
let result = original(this);
|
|
FACTORY_LAST_RESULT.store(result as usize, Ordering::Release);
|
|
FACTORY_EXITS.fetch_add(1, Ordering::Release);
|
|
result
|
|
}
|
|
|
|
unsafe extern "system" fn deserializer_wrapper(this: *mut c_void, reader: *mut c_void) -> u8 {
|
|
DESERIALIZER_ENTRIES.fetch_add(1, Ordering::Relaxed);
|
|
DESERIALIZER_LAST_THREAD.store(GetCurrentThreadId() as usize, Ordering::Relaxed);
|
|
DESERIALIZER_LAST_THIS.store(this as usize, Ordering::Relaxed);
|
|
DESERIALIZER_LAST_READER.store(reader as usize, Ordering::Relaxed);
|
|
let address = DESERIALIZER_TRAMPOLINE.load(Ordering::Acquire);
|
|
let original: DeserializerFn = core::mem::transmute(address);
|
|
let result = original(this, reader);
|
|
DESERIALIZER_LAST_RESULT.store(result != 0, Ordering::Release);
|
|
let base = TRACE_BASE.load(Ordering::Acquire);
|
|
let a = base
|
|
.checked_add(0x2e6398)
|
|
.and_then(|slot| guarded_usize(slot))
|
|
.unwrap_or(0);
|
|
let m = a
|
|
.checked_add(0x20a68)
|
|
.and_then(|slot| guarded_usize(slot))
|
|
.unwrap_or(0);
|
|
let count = m
|
|
.checked_add(0x50)
|
|
.and_then(|slot| guarded_u16(slot))
|
|
.map(usize::from)
|
|
.unwrap_or(usize::MAX);
|
|
let ready = a
|
|
.checked_add(0x1f9d8 + 0x28)
|
|
.and_then(|slot| guarded_u8(slot))
|
|
.map(usize::from)
|
|
.unwrap_or(usize::MAX);
|
|
let response_vtable = guarded_usize(this as usize).unwrap_or(0);
|
|
DESERIALIZER_EXIT_M.store(m, Ordering::Relaxed);
|
|
DESERIALIZER_EXIT_COUNT.store(count, Ordering::Relaxed);
|
|
DESERIALIZER_EXIT_B_READY.store(ready, Ordering::Relaxed);
|
|
DESERIALIZER_EXIT_RESPONSE_VTABLE.store(response_vtable, Ordering::Relaxed);
|
|
DESERIALIZER_EXITS.fetch_add(1, Ordering::Release);
|
|
result
|
|
}
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub(crate) struct DispatchEvidence {
|
|
pub(crate) base: usize,
|
|
pub(crate) factory_entries: u64,
|
|
pub(crate) factory_exits: u64,
|
|
pub(crate) factory_result: usize,
|
|
pub(crate) factory_thread: usize,
|
|
pub(crate) deserializer_entries: u64,
|
|
pub(crate) deserializer_exits: u64,
|
|
pub(crate) deserializer_this: usize,
|
|
pub(crate) deserializer_reader: usize,
|
|
pub(crate) deserializer_result: bool,
|
|
pub(crate) deserializer_thread: usize,
|
|
pub(crate) response_vtable: usize,
|
|
pub(crate) model: usize,
|
|
pub(crate) category_count: usize,
|
|
pub(crate) notifier_entries: u64,
|
|
pub(crate) notifier_exits: u64,
|
|
}
|
|
|
|
pub(crate) fn dispatch_evidence() -> DispatchEvidence {
|
|
DispatchEvidence {
|
|
base: TRACE_BASE.load(Ordering::Acquire),
|
|
factory_entries: FACTORY_ENTRIES.load(Ordering::Acquire),
|
|
factory_exits: FACTORY_EXITS.load(Ordering::Acquire),
|
|
factory_result: FACTORY_LAST_RESULT.load(Ordering::Acquire),
|
|
factory_thread: FACTORY_LAST_THREAD.load(Ordering::Relaxed),
|
|
deserializer_entries: DESERIALIZER_ENTRIES.load(Ordering::Acquire),
|
|
deserializer_exits: DESERIALIZER_EXITS.load(Ordering::Acquire),
|
|
deserializer_this: DESERIALIZER_LAST_THIS.load(Ordering::Relaxed),
|
|
deserializer_reader: DESERIALIZER_LAST_READER.load(Ordering::Relaxed),
|
|
deserializer_result: DESERIALIZER_LAST_RESULT.load(Ordering::Acquire),
|
|
deserializer_thread: DESERIALIZER_LAST_THREAD.load(Ordering::Relaxed),
|
|
response_vtable: DESERIALIZER_EXIT_RESPONSE_VTABLE.load(Ordering::Relaxed),
|
|
model: DESERIALIZER_EXIT_M.load(Ordering::Relaxed),
|
|
category_count: DESERIALIZER_EXIT_COUNT.load(Ordering::Relaxed),
|
|
notifier_entries: NOTIFIER_ENTRIES.load(Ordering::Acquire),
|
|
notifier_exits: NOTIFIER_EXITS.load(Ordering::Acquire),
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum InstallOutcome {
|
|
Installed,
|
|
PrePatchCleanFailure,
|
|
PostPatchCleanFailure,
|
|
DegradedHookActive,
|
|
DegradedProcessState,
|
|
DegradedHookAndProcess,
|
|
}
|
|
|
|
fn may_free_trampolines(outcome: InstallOutcome) -> bool {
|
|
outcome == InstallOutcome::PrePatchCleanFailure
|
|
}
|
|
|
|
unsafe fn install_pair(base: usize) -> InstallOutcome {
|
|
let Some(factory) = target_va(base, CATEGORY_FACTORY_RVA) else {
|
|
return InstallOutcome::PrePatchCleanFailure;
|
|
};
|
|
let Some(deserializer) = target_va(base, CATEGORY_DESERIALIZER_RVA) else {
|
|
return InstallOutcome::PrePatchCleanFailure;
|
|
};
|
|
TRACE_BASE.store(base, Ordering::Release);
|
|
// Preparation phase: validate the PE/ranges/signatures and allocate executable
|
|
// backing before suspending any peer.
|
|
if !valid_cards_image(base)
|
|
|| !executable_range(factory, FACTORY_SIGNATURE.len())
|
|
|| !executable_range(deserializer, DESERIALIZER_SIGNATURE.len())
|
|
|| !signature_matches(factory, &FACTORY_SIGNATURE)
|
|
|| !signature_matches(deserializer, &DESERIALIZER_SIGNATURE)
|
|
{
|
|
return InstallOutcome::PrePatchCleanFailure;
|
|
}
|
|
// Pin before quiescence so the image cannot unload for the lifetime of any
|
|
// published trampoline. This loader operation is intentionally outside the
|
|
// suspended phase.
|
|
let mut pinned = core::ptr::null_mut();
|
|
if GetModuleHandleExA(
|
|
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN,
|
|
factory as *const u8,
|
|
&mut pinned,
|
|
) == 0
|
|
|| pinned as usize != base
|
|
{
|
|
return InstallOutcome::PrePatchCleanFailure;
|
|
}
|
|
let factory_original: [u8; COPY_LEN] = FACTORY_SIGNATURE[..COPY_LEN].try_into().unwrap();
|
|
let deserializer_original: [u8; COPY_LEN] =
|
|
DESERIALIZER_SIGNATURE[..COPY_LEN].try_into().unwrap();
|
|
let Some(factory_trampoline) = allocate_trampoline(factory, COPY_LEN) else {
|
|
return InstallOutcome::PrePatchCleanFailure;
|
|
};
|
|
let Some(deserializer_trampoline) = allocate_trampoline(deserializer, COPY_LEN) else {
|
|
VirtualFree(factory_trampoline as _, 0, MEM_RELEASE);
|
|
return InstallOutcome::PrePatchCleanFailure;
|
|
};
|
|
FACTORY_TRAMPOLINE.store(factory_trampoline, Ordering::Release);
|
|
DESERIALIZER_TRAMPOLINE.store(deserializer_trampoline, Ordering::Release);
|
|
|
|
let Some(_installer_gate) = acquire_patch_installer_gate() else {
|
|
VirtualFree(factory_trampoline as _, 0, MEM_RELEASE);
|
|
VirtualFree(deserializer_trampoline as _, 0, MEM_RELEASE);
|
|
FACTORY_TRAMPOLINE.store(0, Ordering::Release);
|
|
DESERIALIZER_TRAMPOLINE.store(0, Ordering::Release);
|
|
return InstallOutcome::PrePatchCleanFailure;
|
|
};
|
|
|
|
// Quiesced phase: fixed-capacity bookkeeping and Win32 code-page operations
|
|
// only. No Rust allocation, freeing, formatting, or file logging is permitted.
|
|
let mut peers = match suspend_peers(factory, deserializer) {
|
|
Ok(peers) => peers,
|
|
Err(failure) => {
|
|
return if failure == QuiesceFailure::Resume {
|
|
InstallOutcome::DegradedProcessState
|
|
} else {
|
|
VirtualFree(factory_trampoline as _, 0, MEM_RELEASE);
|
|
VirtualFree(deserializer_trampoline as _, 0, MEM_RELEASE);
|
|
FACTORY_TRAMPOLINE.store(0, Ordering::Release);
|
|
DESERIALIZER_TRAMPOLINE.store(0, Ordering::Release);
|
|
InstallOutcome::PrePatchCleanFailure
|
|
};
|
|
}
|
|
};
|
|
|
|
// Final no-allocation TOCTOU gate, immediately before the first write.
|
|
// The image was pinned by address before quiescence. Avoid loader APIs here:
|
|
// a suspended peer may own the loader lock. Page and byte identity checks are
|
|
// sufficient to reject any target change immediately before publication.
|
|
let final_valid = valid_cards_image(base)
|
|
&& executable_range(factory, FACTORY_SIGNATURE.len())
|
|
&& executable_range(deserializer, DESERIALIZER_SIGNATURE.len())
|
|
&& signature_matches(factory, &FACTORY_SIGNATURE)
|
|
&& signature_matches(deserializer, &DESERIALIZER_SIGNATURE);
|
|
let mut published = false;
|
|
let transaction = if !final_valid {
|
|
InstallOutcome::PrePatchCleanFailure
|
|
} else if let Err(clean) = write_entry(
|
|
factory,
|
|
factory_wrapper as *const () as usize,
|
|
&factory_original,
|
|
&mut published,
|
|
) {
|
|
if clean {
|
|
if published {
|
|
InstallOutcome::PostPatchCleanFailure
|
|
} else {
|
|
InstallOutcome::PrePatchCleanFailure
|
|
}
|
|
} else {
|
|
InstallOutcome::DegradedHookActive
|
|
}
|
|
} else if let Err(second_clean) = write_entry(
|
|
deserializer,
|
|
deserializer_wrapper as *const () as usize,
|
|
&deserializer_original,
|
|
&mut published,
|
|
) {
|
|
let first_clean = restore_entry(factory, &factory_original);
|
|
if first_clean && second_clean {
|
|
InstallOutcome::PostPatchCleanFailure
|
|
} else {
|
|
InstallOutcome::DegradedHookActive
|
|
}
|
|
} else {
|
|
InstallOutcome::Installed
|
|
};
|
|
|
|
let resumed = peers.resume_all();
|
|
let outcome = if resumed {
|
|
transaction
|
|
} else if transaction == InstallOutcome::DegradedHookActive
|
|
|| transaction == InstallOutcome::Installed
|
|
{
|
|
InstallOutcome::DegradedHookAndProcess
|
|
} else {
|
|
InstallOutcome::DegradedProcessState
|
|
};
|
|
// Post-resume phase: freeing is safe only when no hook can reference backing.
|
|
if may_free_trampolines(outcome) {
|
|
VirtualFree(factory_trampoline as _, 0, MEM_RELEASE);
|
|
VirtualFree(deserializer_trampoline as _, 0, MEM_RELEASE);
|
|
FACTORY_TRAMPOLINE.store(0, Ordering::Release);
|
|
DESERIALIZER_TRAMPOLINE.store(0, Ordering::Release);
|
|
}
|
|
outcome
|
|
}
|
|
|
|
unsafe fn worker() {
|
|
let _pending = CodeInstallerPending;
|
|
let mut base = 0usize;
|
|
for _ in 0..600u32 {
|
|
base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize;
|
|
if base != 0 {
|
|
break;
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
|
}
|
|
if base == 0 {
|
|
STATE.store(TraceState::Failed as usize, Ordering::Release);
|
|
crate::write_log("SBC_TRACE: CardsDLL unavailable; tracing inactive\n");
|
|
return;
|
|
}
|
|
let outcome = install_pair(base);
|
|
drop(_pending);
|
|
match outcome {
|
|
InstallOutcome::Installed => {
|
|
STATE.store(TraceState::Installed as usize, Ordering::Release);
|
|
crate::write_log("SBC_TRACE: factory+deserializer passive hooks installed\n");
|
|
}
|
|
InstallOutcome::PrePatchCleanFailure => {
|
|
STATE.store(TraceState::Failed as usize, Ordering::Release);
|
|
crate::write_log("SBC_TRACE: pre-patch clean failure; tracing inactive\n");
|
|
return;
|
|
}
|
|
InstallOutcome::PostPatchCleanFailure => {
|
|
STATE.store(TraceState::Failed as usize, Ordering::Release);
|
|
crate::write_log("SBC_TRACE: post-publication rollback completed; trampolines retained for process lifetime; tracing inactive\n");
|
|
return;
|
|
}
|
|
InstallOutcome::DegradedHookActive => {
|
|
STATE.store(TraceState::DegradedHookActive as usize, Ordering::Release);
|
|
crate::write_log("SBC_TRACE: DEGRADED HOOK MAY BE ACTIVE; trampolines retained; terminate game now\n");
|
|
return;
|
|
}
|
|
InstallOutcome::DegradedProcessState => {
|
|
STATE.store(TraceState::DegradedProcessState as usize, Ordering::Release);
|
|
crate::write_log("SBC_TRACE: DEGRADED THREAD RESUME FAILURE; terminate game now\n");
|
|
return;
|
|
}
|
|
InstallOutcome::DegradedHookAndProcess => {
|
|
STATE.store(
|
|
TraceState::DegradedHookAndProcess as usize,
|
|
Ordering::Release,
|
|
);
|
|
crate::write_log(
|
|
"SBC_TRACE: DEGRADED HOOK ACTIVE AND THREAD RESUME FAILURE; terminate game now\n",
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
let mut seen_factory = 0u64;
|
|
let mut seen_deserializer = 0u64;
|
|
let mut reports = 0u8;
|
|
while reports < 32 {
|
|
std::thread::sleep(std::time::Duration::from_millis(250));
|
|
let factory_count = FACTORY_ENTRIES.load(Ordering::Acquire);
|
|
let deserializer_count = DESERIALIZER_ENTRIES.load(Ordering::Acquire);
|
|
if factory_count != seen_factory || deserializer_count != seen_deserializer {
|
|
crate::write_log(&format!(
|
|
"SBC_TRACE: factory entry={} exit={} tid={} this={:#x} result={:#x}; deser entry={} exit={} tid={} this={:#x} reader={:#x} result={} M={:#x} count={} Bready={}\n",
|
|
factory_count,
|
|
FACTORY_EXITS.load(Ordering::Acquire),
|
|
FACTORY_LAST_THREAD.load(Ordering::Relaxed),
|
|
FACTORY_LAST_THIS.load(Ordering::Relaxed),
|
|
FACTORY_LAST_RESULT.load(Ordering::Acquire),
|
|
deserializer_count,
|
|
DESERIALIZER_EXITS.load(Ordering::Acquire),
|
|
DESERIALIZER_LAST_THREAD.load(Ordering::Relaxed),
|
|
DESERIALIZER_LAST_THIS.load(Ordering::Relaxed),
|
|
DESERIALIZER_LAST_READER.load(Ordering::Relaxed),
|
|
DESERIALIZER_LAST_RESULT.load(Ordering::Acquire),
|
|
DESERIALIZER_EXIT_M.load(Ordering::Relaxed),
|
|
DESERIALIZER_EXIT_COUNT.load(Ordering::Relaxed),
|
|
DESERIALIZER_EXIT_B_READY.load(Ordering::Relaxed),
|
|
));
|
|
seen_factory = factory_count;
|
|
seen_deserializer = deserializer_count;
|
|
reports += 1;
|
|
}
|
|
}
|
|
crate::write_log("SBC_TRACE: report cap reached; hooks remain passive\n");
|
|
}
|
|
|
|
unsafe fn notifier_worker() {
|
|
let _pending = CodeInstallerPending;
|
|
let mut base = 0usize;
|
|
for _ in 0..600u32 {
|
|
base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize;
|
|
if base != 0 {
|
|
break;
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
|
}
|
|
let Some(target) = target_va(base, NOTIFIER_RVA) else {
|
|
crate::write_log("SBC_NOTIFIER_TRACE: target resolution failed; inactive\n");
|
|
return;
|
|
};
|
|
if base == 0
|
|
|| !valid_cards_image(base)
|
|
|| !executable_range_in_image(base, target, NOTIFIER_SIGNATURE.len())
|
|
|| !signature_matches(target, &NOTIFIER_SIGNATURE)
|
|
{
|
|
crate::write_log("SBC_NOTIFIER_TRACE: PE/signature validation failed; inactive\n");
|
|
return;
|
|
}
|
|
let mut pinned = core::ptr::null_mut();
|
|
if GetModuleHandleExA(
|
|
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN,
|
|
target as *const u8,
|
|
&mut pinned,
|
|
) == 0
|
|
|| pinned as usize != base
|
|
{
|
|
crate::write_log("SBC_NOTIFIER_TRACE: module pin failed; inactive\n");
|
|
return;
|
|
}
|
|
let Some(trampoline) = allocate_trampoline(target, NOTIFIER_COPY_LEN) else {
|
|
crate::write_log("SBC_NOTIFIER_TRACE: trampoline allocation failed; inactive\n");
|
|
return;
|
|
};
|
|
NOTIFIER_TRAMPOLINE.store(trampoline, Ordering::Release);
|
|
let Some(_installer_gate) = acquire_patch_installer_gate() else {
|
|
VirtualFree(trampoline as _, 0, MEM_RELEASE);
|
|
NOTIFIER_TRAMPOLINE.store(0, Ordering::Release);
|
|
crate::write_log("SBC_NOTIFIER_TRACE: installer gate timeout; inactive\n");
|
|
return;
|
|
};
|
|
let mut peers = match suspend_peers(target, target) {
|
|
Ok(peers) => peers,
|
|
Err(_) => {
|
|
// A failed resume can leave peers suspended, so conservatively retain
|
|
// executable backing and require termination.
|
|
crate::write_log("SBC_NOTIFIER_TRACE: quiescence failed; trampoline retained; terminate game if unresponsive\n");
|
|
return;
|
|
}
|
|
};
|
|
let final_valid = pinned as usize == base
|
|
&& valid_cards_image(base)
|
|
&& executable_range_in_image(base, target, NOTIFIER_SIGNATURE.len())
|
|
&& signature_matches(target, &NOTIFIER_SIGNATURE);
|
|
let mut published = false;
|
|
let installed = final_valid
|
|
&& write_notifier_entry(
|
|
target,
|
|
notifier_wrapper as *const () as usize,
|
|
&mut published,
|
|
)
|
|
.is_ok();
|
|
let resumed = peers.resume_all();
|
|
drop(_installer_gate);
|
|
drop(_pending);
|
|
if !installed || !resumed {
|
|
if !published && resumed {
|
|
VirtualFree(trampoline as _, 0, MEM_RELEASE);
|
|
NOTIFIER_TRAMPOLINE.store(0, Ordering::Release);
|
|
crate::write_log("SBC_NOTIFIER_TRACE: clean install failure; inactive\n");
|
|
} else {
|
|
crate::write_log("SBC_NOTIFIER_TRACE: DEGRADED hook/thread state; backing retained; terminate game now\n");
|
|
}
|
|
return;
|
|
}
|
|
crate::write_log("SBC_NOTIFIER_TRACE: category success notifier hook installed\n");
|
|
let mut seen = 0u64;
|
|
let mut reports = 0u8;
|
|
while reports < 32 {
|
|
std::thread::sleep(std::time::Duration::from_millis(250));
|
|
let entries = NOTIFIER_ENTRIES.load(Ordering::Acquire);
|
|
if entries != seen {
|
|
crate::write_log(&format!(
|
|
"SBC_NOTIFIER_TRACE: entry={} exit={} ctx={:#x} byte88={}->{} handlers={:#x}..{:#x} count={}\n",
|
|
entries,
|
|
NOTIFIER_EXITS.load(Ordering::Acquire),
|
|
NOTIFIER_CTX.load(Ordering::Relaxed),
|
|
NOTIFIER_BYTE_BEFORE.load(Ordering::Relaxed),
|
|
NOTIFIER_BYTE_AFTER.load(Ordering::Relaxed),
|
|
NOTIFIER_BEGIN.load(Ordering::Relaxed),
|
|
NOTIFIER_END.load(Ordering::Relaxed),
|
|
NOTIFIER_COUNT.load(Ordering::Relaxed),
|
|
));
|
|
seen = entries;
|
|
reports += 1;
|
|
}
|
|
}
|
|
crate::write_log("SBC_NOTIFIER_TRACE: report cap reached; hook remains passive\n");
|
|
}
|
|
|
|
unsafe fn controller_register_worker() {
|
|
let _pending = CodeInstallerPending;
|
|
let mut base = 0usize;
|
|
for _ in 0..600u32 {
|
|
base = GetModuleHandleA(c"CardsDLL_Win64_retail.dll".as_ptr().cast()) as usize;
|
|
if base != 0 {
|
|
break;
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
|
}
|
|
let Some(target) = target_va(base, CONTROLLER_REGISTER_RVA) else {
|
|
crate::write_log("SBC_CONTROLLER_TRACE: target resolution failed; inactive\n");
|
|
return;
|
|
};
|
|
if base == 0
|
|
|| !valid_cards_image(base)
|
|
|| !executable_range_in_image(base, target, CONTROLLER_REGISTER_SIGNATURE.len())
|
|
|| core::slice::from_raw_parts(target as *const u8, CONTROLLER_REGISTER_SIGNATURE.len())
|
|
!= CONTROLLER_REGISTER_SIGNATURE
|
|
{
|
|
crate::write_log("SBC_CONTROLLER_TRACE: PE/signature validation failed; inactive\n");
|
|
return;
|
|
}
|
|
let Some(trampoline) = allocate_trampoline(target, CONTROLLER_REGISTER_COPY_LEN) else {
|
|
crate::write_log("SBC_CONTROLLER_TRACE: trampoline allocation failed; inactive\n");
|
|
return;
|
|
};
|
|
CONTROLLER_REGISTER_TRAMPOLINE.store(trampoline, Ordering::Release);
|
|
let Some(_installer_gate) = acquire_patch_installer_gate() else {
|
|
VirtualFree(trampoline as _, 0, MEM_RELEASE);
|
|
CONTROLLER_REGISTER_TRAMPOLINE.store(0, Ordering::Release);
|
|
crate::write_log("SBC_CONTROLLER_TRACE: installer gate timeout; inactive\n");
|
|
return;
|
|
};
|
|
let mut peers = match suspend_peers(target, target) {
|
|
Ok(peers) => peers,
|
|
Err(_) => {
|
|
crate::write_log(
|
|
"SBC_CONTROLLER_TRACE: quiescence failed; terminate game if unresponsive\n",
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
let mut published = false;
|
|
let installed = write_controller_register_entry(
|
|
target,
|
|
controller_register_wrapper as *const () as usize,
|
|
&mut published,
|
|
)
|
|
.is_ok();
|
|
let resumed = peers.resume_all();
|
|
drop(_installer_gate);
|
|
drop(_pending);
|
|
if !installed || !resumed {
|
|
if !published && resumed {
|
|
VirtualFree(trampoline as _, 0, MEM_RELEASE);
|
|
CONTROLLER_REGISTER_TRAMPOLINE.store(0, Ordering::Release);
|
|
crate::write_log("SBC_CONTROLLER_TRACE: clean install failure; inactive\n");
|
|
} else {
|
|
crate::write_log("SBC_CONTROLLER_TRACE: DEGRADED state; terminate game now\n");
|
|
}
|
|
return;
|
|
}
|
|
crate::write_log("SBC_CONTROLLER_TRACE: category controller registration hook installed\n");
|
|
}
|
|
|
|
fn install_notifier(enabled: bool) {
|
|
if enabled {
|
|
crate::write_log("SBC_NOTIFIER_TRACE: requested; deferred install starting\n");
|
|
std::thread::spawn(|| unsafe { notifier_worker() });
|
|
crate::write_log("SBC_CONTROLLER_TRACE: requested; deferred install starting\n");
|
|
std::thread::spawn(|| unsafe { controller_register_worker() });
|
|
} else {
|
|
crate::write_log("SBC_NOTIFIER_TRACE: disabled\n");
|
|
}
|
|
}
|
|
|
|
pub(crate) fn install() {
|
|
// The repair's evidence traces (parser, notifier, controller registration) are
|
|
// its decision inputs, so they follow the promoted repair, not an env var.
|
|
let dispatch_repair = crate::sbc_dispatch::REPAIR_PROMOTED;
|
|
let dispatch_trace =
|
|
dispatch_repair || env_enabled(std::env::var("OPENFUT_SBC_DISPATCH_TRACE").ok().as_deref());
|
|
let enabled =
|
|
dispatch_repair || env_enabled(std::env::var("OPENFUT_SBC_TRACE").ok().as_deref());
|
|
let notifier_enabled =
|
|
dispatch_repair || env_enabled(std::env::var("OPENFUT_SBC_NOTIFIER_TRACE").ok().as_deref());
|
|
CODE_PATCH_PENDING.store(
|
|
enabled as usize + (notifier_enabled as usize * 2) + dispatch_trace as usize,
|
|
Ordering::Release,
|
|
);
|
|
install_notifier(notifier_enabled);
|
|
ENABLED.store(enabled, Ordering::Release);
|
|
if !enabled {
|
|
STATE.store(TraceState::Disabled as usize, Ordering::Release);
|
|
crate::write_log("SBC_TRACE: disabled (set OPENFUT_SBC_TRACE=1 to enable)\n");
|
|
return;
|
|
}
|
|
STATE.store(TraceState::Requested as usize, Ordering::Release);
|
|
crate::write_log("SBC_TRACE: requested; deferred signature validation starting\n");
|
|
std::thread::spawn(|| unsafe { worker() });
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn trace_gate_is_exact_and_default_off() {
|
|
assert!(!env_enabled(None));
|
|
assert!(!env_enabled(Some("0")));
|
|
assert!(!env_enabled(Some("true")));
|
|
assert!(env_enabled(Some("1")));
|
|
}
|
|
|
|
#[test]
|
|
fn target_resolution_checks_overflow() {
|
|
assert_eq!(target_va(0x1000, CATEGORY_FACTORY_RVA), Some(0x17ba10));
|
|
assert_eq!(target_va(usize::MAX, CATEGORY_FACTORY_RVA), None);
|
|
}
|
|
|
|
#[test]
|
|
fn absolute_jump_has_indirect_rip_encoding_and_exact_destination() {
|
|
let jump = absolute_jump(0x1234_5678_9abc_def0);
|
|
assert_eq!(&jump[..6], &[0xff, 0x25, 0, 0, 0, 0]);
|
|
assert_eq!(
|
|
u64::from_le_bytes(jump[6..].try_into().unwrap()),
|
|
0x1234_5678_9abc_def0
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn relocation_spans_end_on_proven_instruction_boundaries() {
|
|
assert_eq!(
|
|
&FACTORY_SIGNATURE[..COPY_LEN],
|
|
&[
|
|
0x48, 0x89, 0x4c, 0x24, 0x08, 0x53, 0x48, 0x83, 0xec, 0x30, 0x48, 0xc7, 0x44, 0x24,
|
|
0x20, 0xfe, 0xff, 0xff, 0xff,
|
|
]
|
|
);
|
|
assert_eq!(
|
|
&NOTIFIER_SIGNATURE[..NOTIFIER_COPY_LEN],
|
|
&[
|
|
0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x74, 0x24, 0x10, 0x57, 0x48, 0x83, 0xec,
|
|
0x20
|
|
]
|
|
);
|
|
assert_eq!(
|
|
&DESERIALIZER_SIGNATURE[..COPY_LEN],
|
|
&[
|
|
0x48, 0x8b, 0xc4, 0x55, 0x41, 0x54, 0x41, 0x55, 0x41, 0x56, 0x41, 0x57, 0x48, 0x8d,
|
|
0xa8, 0xe8, 0xfd, 0xff, 0xff,
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn instruction_span_is_half_open_and_overflow_fails_closed() {
|
|
assert!(instruction_pointer_in_span(0x1000, 0x1000));
|
|
assert!(instruction_pointer_in_span(0x1012, 0x1000));
|
|
assert!(!instruction_pointer_in_span(0x1013, 0x1000));
|
|
assert!(!instruction_pointer_in_span(0x0fff, 0x1000));
|
|
assert!(instruction_pointer_in_span(usize::MAX, usize::MAX));
|
|
}
|
|
|
|
#[test]
|
|
fn only_clean_failure_allows_trampoline_free() {
|
|
assert!(may_free_trampolines(InstallOutcome::PrePatchCleanFailure));
|
|
assert!(!may_free_trampolines(InstallOutcome::PostPatchCleanFailure));
|
|
assert!(!may_free_trampolines(InstallOutcome::Installed));
|
|
assert!(!may_free_trampolines(InstallOutcome::DegradedHookActive));
|
|
assert!(!may_free_trampolines(InstallOutcome::DegradedProcessState));
|
|
assert!(!may_free_trampolines(
|
|
InstallOutcome::DegradedHookAndProcess
|
|
));
|
|
}
|
|
}
|