wormz-a / crates /openwa-dll /src /debug_watchpoint.rs
paavohtl's picture
Claude Opus 4.6 (1M context)
refactor: rename openwa-wormkit crate to openwa-dll, output openwa.dll
e5f8547
Raw History Blame Contribute Delete
12.3 kB
//! Hardware watchpoint debugger for DDGame constructor field investigation.
//!
//! Uses x86 debug registers (DR0–DR3) to watch DDGame offsets for writes.
//! A Vectored Exception Handler logs the exact instruction address (as Ghidra VA)
//! of every write to the watched offsets.
//!
//! Currently dormant β€” activate by wiring calls in `game_session.rs`. No external debugger needed β€” the DLL
//! instruments itself by triggering INT3 exceptions whose VEH handler sets
//! the debug registers directly in the thread context.
//!
//! ## How it works
//!
//! 1. `prepare()` registers a first-chance VEH.
//! 2. `on_ddgame_alloc()` is called right after DDGame's `wa_malloc`. It stores
//! the base address, sets state to `Arming`, and executes `int3`.
//! 3. The VEH catches `EXCEPTION_BREAKPOINT`, writes the target addresses into
//! DR0–DR3 and the enable/condition bits into DR7 (write-only, 4-byte),
//! advances EIP past the `int3`, and returns `EXCEPTION_CONTINUE_EXECUTION`.
//! The CPU loads the modified context β€” watchpoints are now live.
//! 4. When a watched address is written, the CPU raises `STATUS_SINGLE_STEP`.
//! The VEH logs the writer's instruction address as a Ghidra VA.
//! 5. `teardown()` fires another `int3` whose handler clears all DR registers,
//! then removes the VEH.
use crate::log_line;
use openwa_core::address::va;
use openwa_core::rebase::rb;
use std::sync::atomic::{AtomicU32, Ordering};
use windows_sys::Win32::System::Diagnostics::Debug::{
AddVectoredExceptionHandler, RemoveVectoredExceptionHandler, EXCEPTION_POINTERS,
};
/// Offsets to watch. Hardware limit: 4 watchpoints (DR0–DR3).
/// Change these to investigate different fields.
/// NOTE: The base pointer is set by `on_ddgame_alloc()` β€” can be DDGame or DDGameWrapper.
const WATCH_OFFSETS: [(u32, &str); 1] = [(0x3A40, "display+0x3A40")];
/// DDGame base address (set when allocation is reported).
static DDGAME_BASE: AtomicU32 = AtomicU32::new(0);
/// VEH handle for cleanup.
static mut VEH_HANDLE: *mut core::ffi::c_void = core::ptr::null_mut();
/// State machine driven by intentional INT3 exceptions.
#[derive(PartialEq)]
enum Phase {
Idle,
Arming,
Active,
Disarming,
}
static mut PHASE: Phase = Phase::Idle;
/// Saved original DR values for restoration.
static mut SAVED_DR: [u32; 6] = [0; 6]; // DR0, DR1, DR2, DR3, DR6, DR7
// Exception codes (NTSTATUS = i32)
const STATUS_BREAKPOINT: i32 = 0x80000003u32 as i32;
const STATUS_SINGLE_STEP: i32 = 0x80000004u32 as i32;
// ─── VEH Handler ────────────────────────────────────────────────────────────
/// Vectored Exception Handler.
///
/// Handles two exception types:
/// - `EXCEPTION_BREAKPOINT` (INT3): arm or disarm debug registers via context
/// - `STATUS_SINGLE_STEP` (DR hit): log the instruction that wrote to a watched offset
///
/// Data breakpoints are *traps* β€” EIP points to the instruction **after** the
/// write. The logged Ghidra VA is therefore one instruction past the actual
/// writer. Look at the preceding instruction in Ghidra.
unsafe extern "system" fn veh_handler(info: *mut EXCEPTION_POINTERS) -> i32 {
let ei = &*info;
let rec = &*ei.ExceptionRecord;
let ctx = &mut *ei.ContextRecord;
let phase = core::ptr::read_volatile(&raw const PHASE);
match (rec.ExceptionCode, phase) {
// ── INT3: arm watchpoints ──
(STATUS_BREAKPOINT, Phase::Arming) => {
// Save original debug registers so we can restore them later
SAVED_DR = [ctx.Dr0, ctx.Dr1, ctx.Dr2, ctx.Dr3, ctx.Dr6, ctx.Dr7];
let base = DDGAME_BASE.load(Ordering::Relaxed);
let dr_regs = [&mut ctx.Dr0, &mut ctx.Dr1, &mut ctx.Dr2, &mut ctx.Dr3];
for (i, dr) in dr_regs.into_iter().enumerate() {
if i < WATCH_OFFSETS.len() {
*dr = base + WATCH_OFFSETS[i].0;
}
}
ctx.Dr6 = 0;
ctx.Dr7 = dr7_for_count(WATCH_OFFSETS.len());
PHASE = Phase::Active;
ctx.Eip = ctx.Eip.wrapping_add(1); // skip INT3 (1 byte)
-1 // EXCEPTION_CONTINUE_EXECUTION
}
// ── INT3: disarm watchpoints ──
(STATUS_BREAKPOINT, Phase::Disarming) => {
ctx.Dr0 = SAVED_DR[0];
ctx.Dr1 = SAVED_DR[1];
ctx.Dr2 = SAVED_DR[2];
ctx.Dr3 = SAVED_DR[3];
ctx.Dr6 = SAVED_DR[4];
ctx.Dr7 = SAVED_DR[5];
PHASE = Phase::Idle;
ctx.Eip = ctx.Eip.wrapping_add(1);
-1
}
// ── Hardware watchpoint hit ──
(STATUS_SINGLE_STEP, Phase::Active) => {
let dr6 = ctx.Dr6;
let eip = ctx.Eip;
let delta = rb(va::IMAGE_BASE).wrapping_sub(va::IMAGE_BASE);
let wa_base = rb(va::IMAGE_BASE);
let base = DDGAME_BASE.load(Ordering::Relaxed);
for (i, &(offset, name)) in WATCH_OFFSETS.iter().enumerate() {
if dr6 & (1 << i) != 0 {
let val = *((base + offset) as *const u32);
let ghidra_eip = eip.wrapping_sub(delta);
// Walk EBP chain for stack trace
let mut trace = String::new();
let mut ebp = ctx.Ebp;
for depth in 0..12 {
// Validate EBP: must be in plausible stack range, aligned,
// and above ESP (stack grows down)
if !(0x10000..=0x7FFE0000).contains(&ebp) || (ebp & 3) != 0 {
break;
}
// Safety: check both [ebp] and [ebp+4] are readable
if !openwa_core::mem::can_read(ebp, 8) {
break;
}
let ret_addr = *((ebp + 4) as *const u32);
let next_ebp = *(ebp as *const u32);
let ghidra_ret = ret_addr.wrapping_sub(delta);
let in_wa = ret_addr >= wa_base && ret_addr < wa_base + 0x300000;
if depth > 0 {
trace.push_str("<-");
}
if in_wa {
trace.push_str(&openwa_core::registry::format_va(ghidra_ret));
} else {
use core::fmt::Write;
let _ = write!(trace, "r:{:08X}", ret_addr);
}
// EBP must increase (frames go up the stack)
if next_ebp <= ebp {
break;
}
ebp = next_ebp;
}
let _ = log_line(&format!(
"[Watchpoint] {} = 0x{:08X} eip={} stack=[{}]",
name,
val,
openwa_core::registry::format_va(ghidra_eip),
trace,
));
}
}
ctx.Dr6 = 0;
-1
}
// Not ours β€” pass to next handler
_ => 0, // EXCEPTION_CONTINUE_SEARCH
}
}
/// Compute DR7 value for `n` write-only, 4-byte, locally-enabled watchpoints.
///
/// Per breakpoint `i` (0–3):
/// - Bit `2*i`: local enable (L0–L3)
/// - Bits `16 + 4*i`: condition = 01 (write only)
/// - Bits `18 + 4*i`: length = 11 (4 bytes)
fn dr7_for_count(n: usize) -> u32 {
let mut dr7 = 0u32;
for i in 0..n.min(4) {
dr7 |= 1 << (i * 2); // Local enable
dr7 |= 0b01 << (16 + i * 4); // Condition: write-only
dr7 |= 0b11 << (18 + i * 4); // Length: 4 bytes
}
dr7
}
// ─── wa_malloc hook (for original constructor instrumentation) ───────────────
/// Size of DDGame allocation β€” the original allocates 0x98D8 (0x98B8 + 0x20
/// overhead from alloca_probe/SEH), then memsets 0x98B8 of it.
const DDGAME_ALLOC_SIZE: u32 = 0x98D8;
/// Original wa_malloc trampoline (set by minhook).
unsafe extern "cdecl" fn malloc_trampoline_stub(_: u32) -> *mut u8 {
core::ptr::null_mut()
}
static mut MALLOC_TRAMPOLINE: unsafe extern "cdecl" fn(u32) -> *mut u8 = malloc_trampoline_stub;
/// Hooked wa_malloc β€” intercepts the DDGame allocation to arm watchpoints.
unsafe extern "cdecl" fn malloc_hook(size: u32) -> *mut u8 {
let result = MALLOC_TRAMPOLINE(size);
if size == DDGAME_ALLOC_SIZE && !result.is_null() {
let _ = log_line(&format!(
"[Watchpoint] Intercepted wa_malloc(0x{:X}) = 0x{:08X}",
size, result as u32,
));
on_ddgame_alloc(result);
}
result
}
/// Whether the wa_malloc hook is currently installed.
static mut MALLOC_HOOKED: bool = false;
/// Install a minhook on wa_malloc (0x5C0AE3) to intercept the DDGame allocation.
unsafe fn hook_wa_malloc() {
use minhook::MinHook;
let target = rb(va::WA_MALLOC) as *mut core::ffi::c_void;
let detour = malloc_hook as *mut core::ffi::c_void;
match MinHook::create_hook(target, detour) {
Ok(trampoline) => {
MALLOC_TRAMPOLINE = core::mem::transmute(trampoline);
if let Err(e) = MinHook::enable_hook(target) {
let _ = log_line(&format!("[Watchpoint] MH_EnableHook failed: {:?}", e));
} else {
MALLOC_HOOKED = true;
let _ = log_line("[Watchpoint] wa_malloc hooked for DDGame interception");
}
}
Err(e) => {
let _ = log_line(&format!("[Watchpoint] MH_CreateHook failed: {:?}", e));
}
}
}
/// Remove the wa_malloc hook.
unsafe fn unhook_wa_malloc() {
if !MALLOC_HOOKED {
return;
}
use minhook::MinHook;
let target = rb(va::WA_MALLOC) as *mut core::ffi::c_void;
let _ = MinHook::disable_hook(target);
let _ = MinHook::remove_hook(target);
MALLOC_HOOKED = false;
let _ = log_line("[Watchpoint] wa_malloc hook removed");
}
// ─── Public API ─────────────────────────────────────────────────────────────
/// Register the VEH. Call before `create_ddgame`.
pub unsafe fn prepare() {
VEH_HANDLE = AddVectoredExceptionHandler(1, Some(veh_handler));
let _ = log_line("[Watchpoint] VEH installed, awaiting DDGame allocation");
}
/// Register the VEH and hook wa_malloc to intercept the DDGame allocation.
/// Use this when instrumenting the **original** WA constructor.
pub unsafe fn prepare_with_malloc_hook() {
VEH_HANDLE = AddVectoredExceptionHandler(1, Some(veh_handler));
let _ = log_line("[Watchpoint] VEH installed");
hook_wa_malloc();
}
/// Called right after DDGame `wa_malloc`. Arms the watchpoints via INT3 β†’ VEH.
///
/// When this function returns, all 4 hardware watchpoints are live on the
/// current thread.
pub unsafe fn on_ddgame_alloc(ddgame: *mut u8) {
let base = ddgame as u32;
DDGAME_BASE.store(base, Ordering::Relaxed);
let _ = log_line(&format!(
"[Watchpoint] DDGame at 0x{:08X}, arming watchpoints on: {}",
base,
WATCH_OFFSETS
.iter()
.map(|(off, name)| format!("+0x{:04X}({})", off, name))
.collect::<Vec<_>>()
.join(", "),
));
PHASE = Phase::Arming;
// INT3 triggers our VEH which sets DR0–DR3 and DR7 in the thread context.
// When execution resumes here, the watchpoints are active.
core::arch::asm!("int3");
let _ = log_line("[Watchpoint] Armed!");
}
/// Disarm watchpoints, remove VEH, and unhook wa_malloc if needed.
pub unsafe fn teardown() {
unhook_wa_malloc();
if PHASE == Phase::Active {
PHASE = Phase::Disarming;
core::arch::asm!("int3");
}
if !VEH_HANDLE.is_null() {
RemoveVectoredExceptionHandler(VEH_HANDLE);
VEH_HANDLE = core::ptr::null_mut();
}
let _ = log_line("[Watchpoint] Disarmed and VEH removed");
}