Files
OpenFUT/openfut-hook/src/sbc_request_trace.rs
T
funman300 958ff24546 feat(hook): add SBC request tracing instrumentation
- sbc_hook.rs: trace SBC submission/response flow with request IDs
- sbc_request_trace.rs: capture request/response bodies for analysis
- sbc_trace.rs: runtime trace buffer with structured logging

Work in progress - needs validation against live FIFA 17 client
2026-08-08 17:49:00 -07:00

479 lines
18 KiB
Rust

//! Optional category-request callback tracing through its class-unique vtable.
//!
//! Unlike the entry trampolines, these probes atomically replace two aligned
//! pointer slots. The branchy callback dispatcher at 0x180154830 is never patched.
use core::ffi::c_void;
use core::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
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::{
VirtualProtect, VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_GUARD, PAGE_NOACCESS,
PAGE_READWRITE,
};
use windows_sys::Win32::System::Threading::GetCurrentThreadId;
const REQUEST_VTABLE_RVA: usize = 0x22e5c0;
const SLOT_88: usize = 0x88;
const SLOT_90: usize = 0x90;
const ORIGINAL_88_RVA: usize = 0x1631e0;
const ORIGINAL_90_RVA: usize = 0x154830;
const ORIGINAL_88_SIGNATURE: [u8; 16] = [
0x48, 0x89, 0x5c, 0x24, 0x08, 0x48, 0x89, 0x74, 0x24, 0x10, 0x57, 0x48, 0x83, 0xec, 0x20, 0x48,
];
const ORIGINAL_90_SIGNATURE: [u8; 16] = [
0x4c, 0x8b, 0x81, 0x90, 0x00, 0x00, 0x00, 0x4d, 0x85, 0xc0, 0x74, 0x0a, 0x48, 0x81, 0xc1, 0x90,
];
type Callback88 = unsafe extern "system" fn(*mut c_void, *mut c_void);
type Callback90 = unsafe extern "system" fn(*mut c_void, *mut c_void);
static ENABLED: AtomicBool = AtomicBool::new(false);
static INSTALLED: AtomicBool = AtomicBool::new(false);
static ORIGINAL_88: AtomicUsize = AtomicUsize::new(0);
static ORIGINAL_90: AtomicUsize = AtomicUsize::new(0);
static ENTER_88: AtomicU64 = AtomicU64::new(0);
static EXIT_88: AtomicU64 = AtomicU64::new(0);
static ENTER_90: AtomicU64 = AtomicU64::new(0);
static EXIT_90: AtomicU64 = AtomicU64::new(0);
static LAST_REQUEST_88: AtomicUsize = AtomicUsize::new(0);
static LAST_ARGUMENT_88: AtomicUsize = AtomicUsize::new(0);
static LAST_THREAD_88: AtomicUsize = AtomicUsize::new(0);
static LAST_REQUEST_90: AtomicUsize = AtomicUsize::new(0);
static LAST_ARGUMENT_90: AtomicUsize = AtomicUsize::new(0);
static LAST_THREAD_90: AtomicUsize = AtomicUsize::new(0);
static CALLBACK_90: AtomicUsize = AtomicUsize::new(0);
static CALLBACK_98: AtomicUsize = AtomicUsize::new(0);
static CALLBACK_A0: AtomicUsize = AtomicUsize::new(0);
static CALLBACK_A8: AtomicUsize = AtomicUsize::new(0);
static SELECTED_90: AtomicUsize = AtomicUsize::new(0);
static OWNER_88: AtomicUsize = AtomicUsize::new(0);
static OWNER_VTABLE_88: AtomicUsize = AtomicUsize::new(0);
static CONSUMER_88: AtomicUsize = AtomicUsize::new(0);
static RESPONSE_VTABLE_88: AtomicUsize = AtomicUsize::new(0);
static OWNER_SLOT_BEFORE_88: AtomicUsize = AtomicUsize::new(0);
static OWNER_SLOT_AFTER_88: AtomicUsize = AtomicUsize::new(0);
static OWNER_INNER_88: AtomicUsize = AtomicUsize::new(0);
static OWNER_STATE_BEFORE_88: AtomicUsize = AtomicUsize::new(usize::MAX);
static OWNER_STATE_AFTER_88: AtomicUsize = AtomicUsize::new(usize::MAX);
static OWNER_FLAGS_88: AtomicUsize = AtomicUsize::new(usize::MAX);
static OWNER_MANAGER_88: AtomicUsize = AtomicUsize::new(0);
static OWNER_MANAGER_STATE_88: AtomicUsize = AtomicUsize::new(usize::MAX);
fn enabled(value: Option<&str>) -> bool {
matches!(value, Some("1"))
}
fn checked_va(base: usize, rva: usize) -> Option<usize> {
base.checked_add(rva)
}
fn image_range_covered(size: usize, rva: usize, length: usize) -> bool {
rva.checked_add(length)
.map(|end| end <= size)
.unwrap_or(false)
}
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
}
unsafe fn range_in_image_allocation(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();
VirtualQuery(
address as _,
&mut mbi,
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
) != 0
&& mbi.AllocationBase as usize == base
&& mbi.State == MEM_COMMIT
&& mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS) == 0
&& end <= mbi.BaseAddress as usize + mbi.RegionSize
}
unsafe fn image_size(base: usize) -> Option<usize> {
if !readable_range(base, 0x1000) || *(base as *const u16) != 0x5a4d {
return None;
}
let pe_offset = *((base + 0x3c) as *const u32) as usize;
if pe_offset > 0xf00 {
return None;
}
let pe = base.checked_add(pe_offset)?;
if *(pe as *const u32) != 0x0000_4550 {
return None;
}
let size_field = pe.checked_add(24 + 0x38)?;
Some(*(size_field as *const u32) as usize)
}
unsafe fn signature_matches(address: usize, expected: &[u8]) -> bool {
readable_range(address, expected.len())
&& core::slice::from_raw_parts(address as *const u8, expected.len()) == expected
}
unsafe fn guarded_ptr(address: usize) -> usize {
if address & 7 == 0 && readable_range(address, 8) {
core::ptr::read_volatile(address as *const usize)
} else {
0
}
}
unsafe fn guarded_u32(address: usize) -> Option<u32> {
if readable_range(address, 4) {
Some(core::ptr::read_volatile(address as *const u32))
} else {
None
}
}
unsafe fn guarded_u8(address: usize) -> Option<u8> {
if readable_range(address, 1) {
Some(core::ptr::read_volatile(address as *const u8))
} else {
None
}
}
unsafe fn field_ptr(object: usize, offset: usize) -> usize {
object
.checked_add(offset)
.map(|address| guarded_ptr(address))
.unwrap_or(0)
}
unsafe extern "system" fn wrapper_88(request: *mut c_void, argument: *mut c_void) {
ENTER_88.fetch_add(1, Ordering::Relaxed);
LAST_REQUEST_88.store(request as usize, Ordering::Relaxed);
LAST_ARGUMENT_88.store(argument as usize, Ordering::Relaxed);
LAST_THREAD_88.store(GetCurrentThreadId() as usize, Ordering::Relaxed);
let request_address = request as usize;
let argument_address = argument as usize;
let owner = field_ptr(request_address, 8);
let owner_vtable = guarded_ptr(owner);
let consumer = field_ptr(owner_vtable, 0x18);
let owner_slot_before = guarded_ptr(argument_address);
let response_vtable = guarded_ptr(owner_slot_before);
let owner_inner = field_ptr(owner, 8);
let owner_state_before = owner_inner
.checked_add(8)
.and_then(|p| guarded_u32(p))
.map(|v| v as usize)
.unwrap_or(usize::MAX);
let owner_flags = owner_inner
.checked_add(0x0c)
.and_then(|p| guarded_u8(p))
.map(|v| v as usize)
.unwrap_or(usize::MAX);
let owner_manager = field_ptr(owner_inner, 0x14d0);
let owner_manager_state = owner_manager
.checked_add(0x1dc0)
.and_then(|p| guarded_u32(p))
.map(|v| v as usize)
.unwrap_or(usize::MAX);
OWNER_88.store(owner, Ordering::Relaxed);
OWNER_VTABLE_88.store(owner_vtable, Ordering::Relaxed);
CONSUMER_88.store(consumer, Ordering::Relaxed);
RESPONSE_VTABLE_88.store(response_vtable, Ordering::Relaxed);
OWNER_SLOT_BEFORE_88.store(owner_slot_before, Ordering::Relaxed);
OWNER_INNER_88.store(owner_inner, Ordering::Relaxed);
OWNER_STATE_BEFORE_88.store(owner_state_before, Ordering::Relaxed);
OWNER_FLAGS_88.store(owner_flags, Ordering::Relaxed);
OWNER_MANAGER_88.store(owner_manager, Ordering::Relaxed);
OWNER_MANAGER_STATE_88.store(owner_manager_state, Ordering::Relaxed);
let original: Callback88 = core::mem::transmute(ORIGINAL_88.load(Ordering::Acquire));
original(request, argument);
OWNER_SLOT_AFTER_88.store(guarded_ptr(argument_address), Ordering::Relaxed);
OWNER_STATE_AFTER_88.store(
owner_inner
.checked_add(8)
.and_then(|p| guarded_u32(p))
.map(|v| v as usize)
.unwrap_or(usize::MAX),
Ordering::Relaxed,
);
EXIT_88.fetch_add(1, Ordering::Release);
}
unsafe extern "system" fn wrapper_90(request: *mut c_void, argument: *mut c_void) {
ENTER_90.fetch_add(1, Ordering::Relaxed);
LAST_REQUEST_90.store(request as usize, Ordering::Relaxed);
LAST_ARGUMENT_90.store(argument as usize, Ordering::Relaxed);
LAST_THREAD_90.store(GetCurrentThreadId() as usize, Ordering::Relaxed);
let request_address = request as usize;
let callback_90 = field_ptr(request_address, 0x90);
let callback_98 = field_ptr(request_address, 0x98);
let callback_a0 = field_ptr(request_address, 0xa0);
let callback_a8 = field_ptr(request_address, 0xa8);
CALLBACK_90.store(callback_90, Ordering::Relaxed);
CALLBACK_98.store(callback_98, Ordering::Relaxed);
CALLBACK_A0.store(callback_a0, Ordering::Relaxed);
CALLBACK_A8.store(callback_a8, Ordering::Relaxed);
SELECTED_90.store(
if callback_90 != 0 {
callback_90
} else {
callback_a0
},
Ordering::Relaxed,
);
let original: Callback90 = core::mem::transmute(ORIGINAL_90.load(Ordering::Acquire));
original(request, argument);
EXIT_90.fetch_add(1, Ordering::Release);
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SwapOutcome {
Installed,
CleanFailure,
DegradedFirstSlotActive,
DegradedProtection,
}
unsafe fn install_slots(base: usize) -> SwapOutcome {
let Some(vtable) = checked_va(base, REQUEST_VTABLE_RVA) else {
return SwapOutcome::CleanFailure;
};
let Some(original_88) = checked_va(base, ORIGINAL_88_RVA) else {
return SwapOutcome::CleanFailure;
};
let Some(original_90) = checked_va(base, ORIGINAL_90_RVA) else {
return SwapOutcome::CleanFailure;
};
let Some(slot_88) = checked_va(vtable, SLOT_88) else {
return SwapOutcome::CleanFailure;
};
let Some(slot_90) = checked_va(vtable, SLOT_90) else {
return SwapOutcome::CleanFailure;
};
let Some(size) = image_size(base) else {
return SwapOutcome::CleanFailure;
};
if !image_range_covered(size, REQUEST_VTABLE_RVA, SLOT_90 + 8)
|| !image_range_covered(size, ORIGINAL_88_RVA, ORIGINAL_88_SIGNATURE.len())
|| !image_range_covered(size, ORIGINAL_90_RVA, ORIGINAL_90_SIGNATURE.len())
|| slot_88 & 7 != 0
|| slot_90 & 7 != 0
|| !crate::sbc_trace::validate_cards_build(base)
|| !range_in_image_allocation(base, vtable, SLOT_90 + 8)
|| !range_in_image_allocation(base, original_88, ORIGINAL_88_SIGNATURE.len())
|| !range_in_image_allocation(base, original_90, ORIGINAL_90_SIGNATURE.len())
|| !signature_matches(original_88, &ORIGINAL_88_SIGNATURE)
|| !signature_matches(original_90, &ORIGINAL_90_SIGNATURE)
|| (slot_88 as *const AtomicUsize)
.as_ref()
.unwrap()
.load(Ordering::Acquire)
!= original_88
|| (slot_90 as *const AtomicUsize)
.as_ref()
.unwrap()
.load(Ordering::Acquire)
!= original_90
{
return SwapOutcome::CleanFailure;
}
let mut pinned = core::ptr::null_mut();
if GetModuleHandleExA(
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN,
vtable as *const u8,
&mut pinned,
) == 0
|| pinned as usize != base
{
return SwapOutcome::CleanFailure;
}
ORIGINAL_88.store(original_88, Ordering::Release);
ORIGINAL_90.store(original_90, Ordering::Release);
// Both slots share the same vtable page. Keep it writable only across the two
// compare/exchanges and possible rollback.
let mut old = 0u32;
if VirtualProtect(slot_88 as _, 16, PAGE_READWRITE, &mut old) == 0 {
return SwapOutcome::CleanFailure;
}
let atom_88 = &*(slot_88 as *const AtomicUsize);
let atom_90 = &*(slot_90 as *const AtomicUsize);
let first = atom_88.compare_exchange(
original_88,
wrapper_88 as *const () as usize,
Ordering::AcqRel,
Ordering::Acquire,
);
let outcome = if first.is_err() {
SwapOutcome::CleanFailure
} else if atom_90
.compare_exchange(
original_90,
wrapper_90 as *const () as usize,
Ordering::AcqRel,
Ordering::Acquire,
)
.is_ok()
{
SwapOutcome::Installed
} else if atom_88
.compare_exchange(
wrapper_88 as *const () as usize,
original_88,
Ordering::AcqRel,
Ordering::Acquire,
)
.is_ok()
{
SwapOutcome::CleanFailure
} else {
SwapOutcome::DegradedFirstSlotActive
};
let mut ignored = 0u32;
if VirtualProtect(slot_88 as _, 16, old, &mut ignored) == 0 {
return if outcome == SwapOutcome::DegradedFirstSlotActive {
outcome
} else {
SwapOutcome::DegradedProtection
};
}
outcome
}
unsafe fn worker() {
for _ in 0..700u32 {
if crate::sbc_trace::code_patch_installers_ready() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
if !crate::sbc_trace::code_patch_installers_ready() {
crate::write_log("SBC_REQUEST_TRACE: code-patch readiness timeout; inactive\n");
return;
}
let mut base = 0usize;
for _ in 0..600u32 {
base = GetModuleHandleA(b"CardsDLL_Win64_retail.dll\0".as_ptr()) as usize;
if base != 0 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
match if base == 0 { SwapOutcome::CleanFailure } else { install_slots(base) } {
SwapOutcome::Installed => {
INSTALLED.store(true, Ordering::Release);
crate::write_log("SBC_REQUEST_TRACE: category request vtable slots +0x88/+0x90 installed\n");
let mut seen_88 = 0u64;
let mut seen_90 = 0u64;
let mut reports = 0u8;
while reports < 32 {
std::thread::sleep(std::time::Duration::from_millis(250));
let count_88 = ENTER_88.load(Ordering::Acquire);
let count_90 = ENTER_90.load(Ordering::Acquire);
if count_88 != seen_88 || count_90 != seen_90 {
crate::write_log(&format!(
"SBC_REQUEST_TRACE: +88 entry={} exit={} req={:#x} arg={:#x} tid={} owner={:#x} ovt={:#x} consumer={:#x} rvt={:#x} slot={:#x}->{:#x} inner={:#x} state={}->{} flags={:#x} manager={:#x} manager_state={}; +90 entry={} exit={} req={:#x} arg={:#x} tid={} cb90={:#x} cb98={:#x} cba0={:#x} cba8={:#x} selected={:#x}\n",
count_88,
EXIT_88.load(Ordering::Acquire),
LAST_REQUEST_88.load(Ordering::Relaxed),
LAST_ARGUMENT_88.load(Ordering::Relaxed),
LAST_THREAD_88.load(Ordering::Relaxed),
OWNER_88.load(Ordering::Relaxed),
OWNER_VTABLE_88.load(Ordering::Relaxed),
CONSUMER_88.load(Ordering::Relaxed),
RESPONSE_VTABLE_88.load(Ordering::Relaxed),
OWNER_SLOT_BEFORE_88.load(Ordering::Relaxed),
OWNER_SLOT_AFTER_88.load(Ordering::Relaxed),
OWNER_INNER_88.load(Ordering::Relaxed),
OWNER_STATE_BEFORE_88.load(Ordering::Relaxed),
OWNER_STATE_AFTER_88.load(Ordering::Relaxed),
OWNER_FLAGS_88.load(Ordering::Relaxed),
OWNER_MANAGER_88.load(Ordering::Relaxed),
OWNER_MANAGER_STATE_88.load(Ordering::Relaxed),
count_90,
EXIT_90.load(Ordering::Acquire),
LAST_REQUEST_90.load(Ordering::Relaxed),
LAST_ARGUMENT_90.load(Ordering::Relaxed),
LAST_THREAD_90.load(Ordering::Relaxed),
CALLBACK_90.load(Ordering::Relaxed),
CALLBACK_98.load(Ordering::Relaxed),
CALLBACK_A0.load(Ordering::Relaxed),
CALLBACK_A8.load(Ordering::Relaxed),
SELECTED_90.load(Ordering::Relaxed),
));
seen_88 = count_88;
seen_90 = count_90;
reports += 1;
}
}
crate::write_log("SBC_REQUEST_TRACE: report cap reached; vtable probes remain passive\n");
}
SwapOutcome::CleanFailure => crate::write_log("SBC_REQUEST_TRACE: clean install failure; inactive\n"),
SwapOutcome::DegradedFirstSlotActive => crate::write_log(
"SBC_REQUEST_TRACE: DEGRADED slot +0x88 may remain active; terminate game now\n",
),
SwapOutcome::DegradedProtection => crate::write_log(
"SBC_REQUEST_TRACE: DEGRADED vtable page protection restore failed; terminate game now\n",
),
}
}
pub(crate) fn install() {
let armed = enabled(std::env::var("OPENFUT_SBC_REQUEST_TRACE").ok().as_deref());
ENABLED.store(armed, Ordering::Release);
if !armed {
crate::write_log("SBC_REQUEST_TRACE: disabled\n");
return;
}
crate::write_log("SBC_REQUEST_TRACE: requested; deferred install starting\n");
std::thread::spawn(|| unsafe { worker() });
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gate_is_exact() {
assert!(!enabled(None));
assert!(!enabled(Some("true")));
assert!(enabled(Some("1")));
}
#[test]
fn slots_are_aligned_and_class_local() {
assert_eq!((REQUEST_VTABLE_RVA + SLOT_88) & 7, 0);
assert_eq!((REQUEST_VTABLE_RVA + SLOT_90) & 7, 0);
assert_eq!(SLOT_90 - SLOT_88, 8);
}
#[test]
fn image_coverage_is_checked_and_overflow_safe() {
assert!(image_range_covered(
0x230000,
REQUEST_VTABLE_RVA,
SLOT_90 + 8
));
assert!(!image_range_covered(
REQUEST_VTABLE_RVA + SLOT_90,
REQUEST_VTABLE_RVA,
SLOT_90 + 8
));
assert!(!image_range_covered(usize::MAX, usize::MAX, 8));
}
}