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
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
//! with this module compiled in changes nothing unless a var is set:
|
||||
//! OPENFUT_SBC_HOOK=1 -> arm the deferred worker (resolve + log; READ-ONLY)
|
||||
//! OPENFUT_SBC_ARM_ONLY=1 -> Tier-0 negative control: write BYTE[B+0x28]=1 (renders EMPTY)
|
||||
//! OPENFUT_SBC_COMMIT=1 -> after proven native parse success, arm populated M
|
||||
//! OPENFUT_SBC_POPULATE=1 -> legacy Tier-1 gate: BLOCKED (logs corrected trace gap, returns)
|
||||
//!
|
||||
//! CardsDLL_Win64_retail.dll is loaded lazily (only on entering Ultimate Team), so we
|
||||
@@ -19,11 +20,14 @@
|
||||
//! See the spec for the verified disassembly behind each one.
|
||||
|
||||
use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use windows_sys::Win32::System::Diagnostics::Debug::FlushInstructionCache;
|
||||
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleA;
|
||||
use windows_sys::Win32::System::Memory::{
|
||||
VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READWRITE,
|
||||
PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOACCESS, PAGE_READWRITE, PAGE_WRITECOPY,
|
||||
VirtualProtect, VirtualQuery, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READ,
|
||||
PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOACCESS, PAGE_READWRITE,
|
||||
PAGE_WRITECOPY,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentThreadId};
|
||||
|
||||
// ── RVAs (verified byte-exact against /tmp/fut/cardsdll.dll this pass) ────────────
|
||||
const IMAGE_BASE: usize = 0x180000000;
|
||||
@@ -42,6 +46,13 @@ const B_READY_OFF: usize = 0x28; // B+0x28 ready byte (the isValid gate)
|
||||
const B_COLL_OFF: usize = 0x08; // B+0x08 collection ptr (MUST stay 0 — see spec §4/C5)
|
||||
const M_CACHE_OFF: usize = 0x20a68; // M = *(A + 0x20a68) (render source; per-session heap)
|
||||
const M_COUNT_OFF: usize = 0x50; // WORD[M+0x50] category count
|
||||
const SBC_CONTROLLER_VTABLE_RVA: usize = 0x20a820;
|
||||
const SBC_CONTROLLER_EVENT_VTABLE_RVA: usize = 0x20a888;
|
||||
const SBC_CONTROLLER_EVENT_SUBOBJECT_OFF: usize = 0x138;
|
||||
const SBC_CONTROLLER_MODEL_OFF: usize = 0x140;
|
||||
const SBC_COMPLETION_STATUS_JNE_RVA: usize = 0x0b8962;
|
||||
const SBC_COMPLETION_STATUS_JNE: [u8; 2] = [0x75, 0x48];
|
||||
const SBC_COMPLETION_STATUS_FALLTHROUGH: [u8; 2] = [0x90, 0x90];
|
||||
const B_DTOR_RVA: usize = 0x63040;
|
||||
const B_ISVALID_RVA: usize = 0x65d40;
|
||||
const B_CLEAR_RVA: usize = 0x65d20;
|
||||
@@ -76,9 +87,11 @@ mod rva {
|
||||
|
||||
static ARMED: AtomicBool = AtomicBool::new(false);
|
||||
static ARM_ONLY: AtomicBool = AtomicBool::new(false);
|
||||
static COMMIT: AtomicBool = AtomicBool::new(false);
|
||||
static POPULATE: AtomicBool = AtomicBool::new(false);
|
||||
static DONE: AtomicBool = AtomicBool::new(false);
|
||||
static CARDS_BASE: AtomicUsize = AtomicUsize::new(0);
|
||||
static SBC_CONTROLLER: AtomicUsize = AtomicUsize::new(0);
|
||||
static STATE: AtomicUsize = AtomicUsize::new(RuntimeState::Disabled as usize);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -134,6 +147,13 @@ enum ValidationError {
|
||||
CollectionUnreadable,
|
||||
CollectionNotNull,
|
||||
ReadyByteNotWritable,
|
||||
ModelEmpty,
|
||||
ControllerMissing,
|
||||
ControllerVtableMismatch,
|
||||
ControllerModelMismatch,
|
||||
CompletionBranchMismatch,
|
||||
CompletionBranchProtectFailed,
|
||||
CompletionBranchFlushFailed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -263,6 +283,26 @@ unsafe fn writable_u8(ptr: usize) -> bool {
|
||||
.is_some_and(|end| end <= (mbi.BaseAddress as usize).saturating_add(mbi.RegionSize))
|
||||
}
|
||||
|
||||
unsafe fn executable_range(ptr: usize, len: usize) -> bool {
|
||||
let Some(end) = ptr.checked_add(len) else {
|
||||
return false;
|
||||
};
|
||||
let mut mbi: MEMORY_BASIC_INFORMATION = core::mem::zeroed();
|
||||
let n = VirtualQuery(
|
||||
ptr as _,
|
||||
&mut mbi,
|
||||
core::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
|
||||
);
|
||||
if n == 0 || mbi.State != MEM_COMMIT || mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD) != 0 {
|
||||
return false;
|
||||
}
|
||||
let protection = mbi.Protect & 0xff;
|
||||
matches!(
|
||||
protection,
|
||||
PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY
|
||||
) && end <= (mbi.BaseAddress as usize).saturating_add(mbi.RegionSize)
|
||||
}
|
||||
|
||||
/// Guarded 16-bit read (M category count is a WORD).
|
||||
unsafe fn read_u16(ptr: usize) -> Option<u16> {
|
||||
let lo = read_u8(ptr)? as u16;
|
||||
@@ -388,6 +428,12 @@ pub fn install() {
|
||||
.unwrap_or(false),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
COMMIT.store(
|
||||
std::env::var("OPENFUT_SBC_COMMIT")
|
||||
.map(|v| v == "1")
|
||||
.unwrap_or(false),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
POPULATE.store(
|
||||
std::env::var("OPENFUT_SBC_POPULATE")
|
||||
.map(|v| v == "1")
|
||||
@@ -398,6 +444,192 @@ pub fn install() {
|
||||
std::thread::spawn(|| unsafe { worker() });
|
||||
}
|
||||
|
||||
/// Records the concrete SBC controller observed registering FUT_SBS_CATEGORIES.
|
||||
/// The registration hook is observational; all structural checks happen again on
|
||||
/// the notifier thread before this address is trusted.
|
||||
pub(crate) unsafe fn note_sbc_controller(controller: usize) {
|
||||
let base = CARDS_BASE.load(Ordering::Acquire);
|
||||
let valid = base != 0
|
||||
&& read_ptr(controller) == base.checked_add(SBC_CONTROLLER_VTABLE_RVA)
|
||||
&& controller
|
||||
.checked_add(SBC_CONTROLLER_EVENT_SUBOBJECT_OFF)
|
||||
.and_then(|p| read_ptr(p))
|
||||
== base.checked_add(SBC_CONTROLLER_EVENT_VTABLE_RVA);
|
||||
if valid {
|
||||
SBC_CONTROLLER.store(controller, Ordering::Release);
|
||||
crate::write_log(&format!(
|
||||
"SBC_CONTROLLER_TRACE: captured controller={controller:#x}\n"
|
||||
));
|
||||
} else {
|
||||
crate::write_log(&format!(
|
||||
"SBC_CONTROLLER_TRACE: rejected controller={controller:#x} (vtable mismatch)\n"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn log_controller_model(native_model: usize) {
|
||||
let controller = SBC_CONTROLLER.load(Ordering::Acquire);
|
||||
let controller_model = controller
|
||||
.checked_add(SBC_CONTROLLER_MODEL_OFF)
|
||||
.and_then(|p| read_ptr(p))
|
||||
.unwrap_or(0);
|
||||
let main_vtable = read_ptr(controller).unwrap_or(0);
|
||||
let event_vtable = controller
|
||||
.checked_add(SBC_CONTROLLER_EVENT_SUBOBJECT_OFF)
|
||||
.and_then(|p| read_ptr(p))
|
||||
.unwrap_or(0);
|
||||
crate::write_log(&format!(
|
||||
"SBC_CONTROLLER_TRACE: notifier controller={controller:#x} main_vt={main_vtable:#x} event_vt={event_vtable:#x} controller_M={controller_model:#x} parsed_M={native_model:#x} match={}\n",
|
||||
controller != 0 && controller_model == native_model,
|
||||
));
|
||||
}
|
||||
|
||||
unsafe fn validated_sbc_controller(
|
||||
base: usize,
|
||||
native_model: usize,
|
||||
) -> Result<usize, ValidationError> {
|
||||
let controller = SBC_CONTROLLER.load(Ordering::Acquire);
|
||||
if controller == 0 {
|
||||
return Err(ValidationError::ControllerMissing);
|
||||
}
|
||||
if read_ptr(controller) != base.checked_add(SBC_CONTROLLER_VTABLE_RVA)
|
||||
|| controller
|
||||
.checked_add(SBC_CONTROLLER_EVENT_SUBOBJECT_OFF)
|
||||
.and_then(|p| read_ptr(p))
|
||||
!= base.checked_add(SBC_CONTROLLER_EVENT_VTABLE_RVA)
|
||||
{
|
||||
return Err(ValidationError::ControllerVtableMismatch);
|
||||
}
|
||||
if controller
|
||||
.checked_add(SBC_CONTROLLER_MODEL_OFF)
|
||||
.and_then(|p| read_ptr(p))
|
||||
!= Some(native_model)
|
||||
{
|
||||
return Err(ValidationError::ControllerModelMismatch);
|
||||
}
|
||||
Ok(controller)
|
||||
}
|
||||
|
||||
/// Route the already-scheduled category completion through CardsDLL's own success
|
||||
/// branch. The original function first rejects a non-zero status with a two-byte
|
||||
/// `jne ServerErrSets`; after a separately proven native parse, that status belongs
|
||||
/// to the stale scheduler completion rather than the category HTTP transaction.
|
||||
unsafe fn arm_native_completion_success(base: usize) -> Result<(), ValidationError> {
|
||||
let target = base
|
||||
.checked_add(SBC_COMPLETION_STATUS_JNE_RVA)
|
||||
.ok_or(ValidationError::AddressOverflow)?;
|
||||
if !executable_range(target, SBC_COMPLETION_STATUS_JNE.len())
|
||||
|| core::slice::from_raw_parts(target as *const u8, SBC_COMPLETION_STATUS_JNE.len())
|
||||
!= SBC_COMPLETION_STATUS_JNE
|
||||
{
|
||||
return Err(ValidationError::CompletionBranchMismatch);
|
||||
}
|
||||
let mut old = 0u32;
|
||||
if VirtualProtect(
|
||||
target as _,
|
||||
SBC_COMPLETION_STATUS_FALLTHROUGH.len(),
|
||||
PAGE_EXECUTE_READWRITE,
|
||||
&mut old,
|
||||
) == 0
|
||||
{
|
||||
return Err(ValidationError::CompletionBranchProtectFailed);
|
||||
}
|
||||
core::ptr::copy_nonoverlapping(
|
||||
SBC_COMPLETION_STATUS_FALLTHROUGH.as_ptr(),
|
||||
target as *mut u8,
|
||||
SBC_COMPLETION_STATUS_FALLTHROUGH.len(),
|
||||
);
|
||||
let flushed = FlushInstructionCache(
|
||||
GetCurrentProcess(),
|
||||
target as _,
|
||||
SBC_COMPLETION_STATUS_FALLTHROUGH.len(),
|
||||
) != 0;
|
||||
let mut ignored = 0u32;
|
||||
let protected = VirtualProtect(
|
||||
target as _,
|
||||
SBC_COMPLETION_STATUS_FALLTHROUGH.len(),
|
||||
old,
|
||||
&mut ignored,
|
||||
) != 0;
|
||||
if !flushed || !protected {
|
||||
return Err(ValidationError::CompletionBranchFlushFailed);
|
||||
}
|
||||
crate::write_log(&format!(
|
||||
"SBC_HOOK: armed native completion success branch at {target:#x} tid={}\n",
|
||||
GetCurrentThreadId(),
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Commit the already-populated native SBC model after the category success notifier.
|
||||
///
|
||||
/// This is called synchronously by the passive notifier wrapper *after* the original
|
||||
/// notifier returns. It never invokes a parser or constructs game objects. The only
|
||||
/// mutation is the established cache-ready byte, and only when the normal parser has
|
||||
/// produced at least one category and every pointer/vtable invariant still matches.
|
||||
pub(crate) unsafe fn commit_after_native_parse() {
|
||||
if !COMMIT.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
let base = CARDS_BASE.load(Ordering::Acquire);
|
||||
if base == 0 || !control_matches(base) {
|
||||
set_failed(ValidationError::AUnreadable);
|
||||
return;
|
||||
}
|
||||
let snapshot = match runtime_snapshot(base).and_then(|snapshot| {
|
||||
validate_snapshot(base, &snapshot)?;
|
||||
if snapshot.m == 0
|
||||
|| read_u16(snapshot.m + M_COUNT_OFF)
|
||||
.filter(|&count| count > 0)
|
||||
.is_none()
|
||||
{
|
||||
return Err(ValidationError::ModelEmpty);
|
||||
}
|
||||
if !writable_u8(snapshot.b + B_READY_OFF) {
|
||||
return Err(ValidationError::ReadyByteNotWritable);
|
||||
}
|
||||
Ok(snapshot)
|
||||
}) {
|
||||
Ok(snapshot) => snapshot,
|
||||
Err(error) => {
|
||||
set_failed(error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let count = read_u16(snapshot.m + M_COUNT_OFF).unwrap_or(0);
|
||||
log_controller_model(snapshot.m);
|
||||
if DONE.swap(true, Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
crate::write_log(&format!(
|
||||
"SBC_HOOK: post-parse commit -> M={:#x} categories={} BYTE[{:#x}]=1\n",
|
||||
snapshot.m,
|
||||
count,
|
||||
snapshot.b + B_READY_OFF,
|
||||
));
|
||||
core::ptr::write_volatile((snapshot.b + B_READY_OFF) as *mut u8, 1);
|
||||
if read_u8(snapshot.b + B_READY_OFF) != Some(1)
|
||||
|| !transition(RuntimeState::Validated, RuntimeState::Committed)
|
||||
{
|
||||
set_failed(ValidationError::ReadyByteUnexpected);
|
||||
return;
|
||||
}
|
||||
let _controller = match validated_sbc_controller(base, snapshot.m) {
|
||||
Ok(controller) => controller,
|
||||
Err(error) => {
|
||||
set_failed(error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(error) = arm_native_completion_success(base) {
|
||||
set_failed(error);
|
||||
return;
|
||||
}
|
||||
crate::write_log(
|
||||
"SBC_HOOK: post-parse commit DONE; awaiting CardsDLL native completion events\n",
|
||||
);
|
||||
}
|
||||
|
||||
/// Deferred worker: waits (up to ~5 min) for CardsDLL to load — it only appears when
|
||||
/// the user enters Ultimate Team — then runs the resolve/log (+ optional Tier-0 arm)
|
||||
/// exactly once.
|
||||
|
||||
Reference in New Issue
Block a user