"""Win32 debug agent for HAVOC_NOCD.EXE. Extends the ctypes debug-loop approach proven in tools/dbg_path.py into a reusable, addressable class. Windows-only -- requires ctypes.windll, must run on Caspian (or, later, under Wine) against the real process.""" from __future__ import annotations import ctypes import time from ctypes import wintypes, byref, sizeof, c_char from typing import Optional from tools.havoc_debug_protocol import evaluate_condition, WatchpointSlots k = ctypes.windll.kernel32 DEBUG_ONLY_THIS_PROCESS = 0x00000002 DBG_CONTINUE = 0x00010002 DBG_EXCEPTION_NOT_HANDLED = 0x80010001 EXCEPTION_BREAKPOINT = 0x80000003 EXCEPTION_SINGLE_STEP = 0x80000004 # In a WOW64 (32-bit) process debugged by a 64-bit debugger, INT3 breakpoints and trap-flag # single-steps in the 32-bit code arrive as these WX86 status codes, NOT the native ones # above. This only shows up against the real process (confirmed on HAVOC_NOCD.EXE: a 0xCC at # 0x40C8E0 raised 0x4000001F, not 0x80000003). Match both everywhere a trap is handled. STATUS_WX86_SINGLE_STEP = 0x4000001E STATUS_WX86_BREAKPOINT = 0x4000001F _BREAKPOINT_CODES = (EXCEPTION_BREAKPOINT, STATUS_WX86_BREAKPOINT) _SINGLE_STEP_CODES = (EXCEPTION_SINGLE_STEP, STATUS_WX86_SINGLE_STEP) _DEBUG_TRAP_CODES = _BREAKPOINT_CODES + _SINGLE_STEP_CODES EXCEPTION_ACCESS_VIOLATION = 0xC0000005 CREATE_PROCESS_DEBUG_EVENT = 3 CREATE_THREAD_DEBUG_EVENT = 2 EXIT_PROCESS_DEBUG_EVENT = 5 LOAD_DLL_DEBUG_EVENT = 6 EXCEPTION_DEBUG_EVENT = 1 CONTEXT_FULL = 0x00010007 CONTEXT_DEBUG_REGISTERS = 0x00010010 CONTEXT_ALL = CONTEXT_FULL | CONTEXT_DEBUG_REGISTERS # includes DR0-DR3, DR6, DR7 TRAP_FLAG = 0x100 # EFlags bit 8: single-step after the next instruction # Native x64 context flag for debug registers (different base from WOW64 flags) CONTEXT_AMD64_DEBUG = 0x00100010 # x64 CONTEXT size and field offsets (winnt.h layout, must be 16-byte aligned) _CONTEXT64_SIZE = 0x4D0 _CTX64_FLAGS_OFF = 0x030 # DWORD ContextFlags _CTX64_DR0_OFF = 0x048 # DWORD64 Dr0 _CTX64_DR6_OFF = 0x068 # DWORD64 Dr6 _CTX64_DR7_OFF = 0x070 # DWORD64 Dr7 class STARTUPINFO(ctypes.Structure): _fields_ = [("cb", wintypes.DWORD), ("lpReserved", wintypes.LPWSTR), ("lpDesktop", wintypes.LPWSTR), ("lpTitle", wintypes.LPWSTR), ("dwX", wintypes.DWORD), ("dwY", wintypes.DWORD), ("dwXSize", wintypes.DWORD), ("dwYSize", wintypes.DWORD), ("dwXCountChars", wintypes.DWORD), ("dwYCountChars", wintypes.DWORD), ("dwFillAttribute", wintypes.DWORD), ("dwFlags", wintypes.DWORD), ("wShowWindow", wintypes.WORD), ("cbReserved2", wintypes.WORD), ("lpReserved2", ctypes.c_void_p), ("hStdInput", wintypes.HANDLE), ("hStdOutput", wintypes.HANDLE), ("hStdError", wintypes.HANDLE)] class PROCESS_INFORMATION(ctypes.Structure): _fields_ = [("hProcess", wintypes.HANDLE), ("hThread", wintypes.HANDLE), ("dwProcessId", wintypes.DWORD), ("dwThreadId", wintypes.DWORD)] class EXCEPTION_RECORD(ctypes.Structure): _fields_ = [("ExceptionCode", wintypes.DWORD), ("ExceptionFlags", wintypes.DWORD), ("ExceptionRecord", ctypes.c_void_p), ("ExceptionAddress", ctypes.c_void_p), ("NumberParameters", wintypes.DWORD), ("ExceptionInformation", ctypes.c_void_p * 15)] class EXCEPTION_DEBUG_INFO(ctypes.Structure): _fields_ = [("ExceptionRecord", EXCEPTION_RECORD), ("dwFirstChance", wintypes.DWORD)] class DEBUG_EVENT(ctypes.Structure): class _U(ctypes.Union): _fields_ = [("Exception", EXCEPTION_DEBUG_INFO), ("raw", c_char * 160)] _fields_ = [("dwDebugEventCode", wintypes.DWORD), ("dwProcessId", wintypes.DWORD), ("dwThreadId", wintypes.DWORD), ("u", _U)] class WOW64_FLOATING_SAVE_AREA(ctypes.Structure): _fields_ = [("ControlWord", wintypes.DWORD), ("StatusWord", wintypes.DWORD), ("TagWord", wintypes.DWORD), ("ErrorOffset", wintypes.DWORD), ("ErrorSelector", wintypes.DWORD), ("DataOffset", wintypes.DWORD), ("DataSelector", wintypes.DWORD), ("RegisterArea", c_char * 80), ("Cr0NpxState", wintypes.DWORD)] class WOW64_CONTEXT(ctypes.Structure): _fields_ = [("ContextFlags", wintypes.DWORD), ("Dr0", wintypes.DWORD), ("Dr1", wintypes.DWORD), ("Dr2", wintypes.DWORD), ("Dr3", wintypes.DWORD), ("Dr6", wintypes.DWORD), ("Dr7", wintypes.DWORD), ("FloatSave", WOW64_FLOATING_SAVE_AREA), ("SegGs", wintypes.DWORD), ("SegFs", wintypes.DWORD), ("SegEs", wintypes.DWORD), ("SegDs", wintypes.DWORD), ("Edi", wintypes.DWORD), ("Esi", wintypes.DWORD), ("Ebx", wintypes.DWORD), ("Edx", wintypes.DWORD), ("Ecx", wintypes.DWORD), ("Eax", wintypes.DWORD), ("Ebp", wintypes.DWORD), ("Eip", wintypes.DWORD), ("SegCs", wintypes.DWORD), ("EFlags", wintypes.DWORD), ("Esp", wintypes.DWORD), ("SegSs", wintypes.DWORD), ("ExtendedRegisters", c_char * 512)] _REGISTER_NAMES = ["Eax", "Ebx", "Ecx", "Edx", "Esi", "Edi", "Ebp", "Esp", "Eip", "EFlags", "Dr0", "Dr1", "Dr2", "Dr3", "Dr6", "Dr7"] class DebugAgent: """Owns one debugged process. One DebugAgent instance per running game session.""" def __init__(self) -> None: self._process_info: Optional[PROCESS_INFORMATION] = None self._threads: dict[int, wintypes.HANDLE] = {} self._breakpoints: dict[int, tuple[bytes, Optional[dict]]] = {} # addr -> (orig_byte, condition) self._watchpoints = WatchpointSlots() self._last_event: Optional[DEBUG_EVENT] = None self._exited: Optional[int] = None # exit code, if the process has exited self._stopped_thread_id: Optional[int] = None # thread that hit the last reported stop # A debug event we received and reported as a stop but have NOT continued yet, so the # debuggee stays suspended for inspection. (pid, tid); resumed on next continue_execution. self._pending_event: Optional[tuple[int, int]] = None # For single-stepping over a restored breakpoint so it can be re-armed (Task 3): self._step_over: Optional[tuple[int, int]] = None # (addr_to_rearm, thread_id) def _read_process_memory(self, addr: int, length: int) -> bytes: buf = (c_char * length)() read = ctypes.c_size_t(0) ok = k.ReadProcessMemory(self._process_info.hProcess, ctypes.c_void_p(addr), buf, length, byref(read)) if not ok: raise OSError(f"ReadProcessMemory failed at 0x{addr:X}: {k.GetLastError()}") return bytes(buf[: read.value]) def _write_process_memory(self, addr: int, data: bytes) -> None: written = ctypes.c_size_t(0) ok = k.WriteProcessMemory(self._process_info.hProcess, ctypes.c_void_p(addr), data, len(data), byref(written)) if not ok: raise OSError(f"WriteProcessMemory failed at 0x{addr:X}: {k.GetLastError()}") def launch(self, path: str, cwd: str) -> None: # Terminate any previously-attached process. Without this, a game whose debug event # loop was abandoned (e.g. after a timeout) stays hung with all threads suspended, # and the new launch fails because the stale instance holds file/mutex locks. if self._process_info is not None: k.TerminateProcess(self._process_info.hProcess, 0) # Drain the exit event so the debug subsystem releases the process cleanly. evt = DEBUG_EVENT() for _ in range(32): if k.WaitForDebugEvent(byref(evt), 500): k.ContinueDebugEvent(evt.dwProcessId, evt.dwThreadId, DBG_CONTINUE) if evt.dwDebugEventCode == EXIT_PROCESS_DEBUG_EVENT: break k.CloseHandle(self._process_info.hProcess) k.CloseHandle(self._process_info.hThread) self._process_info = None # Reset all process-local state so re-launching doesn't inherit stale breakpoints, # thread handles, or watchpoints from a previous session on the same server instance. self._threads.clear() self._breakpoints.clear() self._watchpoints = WatchpointSlots() self._last_event = None self._exited = None self._stopped_thread_id = None self._pending_event = None self._step_over = None si = STARTUPINFO() si.cb = sizeof(si) pi = PROCESS_INFORMATION() ok = k.CreateProcessA(path.encode(), None, None, None, False, DEBUG_ONLY_THIS_PROCESS, None, cwd.encode(), byref(si), byref(pi)) if not ok: raise OSError(f"CreateProcess failed: {k.GetLastError()}") self._process_info = pi self._threads[pi.dwThreadId] = pi.hThread def set_breakpoint(self, addr: int, condition: Optional[dict] = None) -> None: if addr in self._breakpoints: orig_byte, _old = self._breakpoints[addr] self._breakpoints[addr] = (orig_byte, condition) # update condition only return orig_byte = self._read_process_memory(addr, 1) self._write_process_memory(addr, b"\xCC") self._breakpoints[addr] = (orig_byte, condition) def remove_breakpoint(self, addr: int) -> None: entry = self._breakpoints.pop(addr, None) if entry is not None: orig_byte, _condition = entry self._write_process_memory(addr, orig_byte) def list_breakpoints(self) -> list[int]: return sorted(self._breakpoints.keys()) def _set_native_dr_registers(self, thread_handle: int) -> None: """Set hardware DR registers via the native x64 SetThreadContext. Wow64SetThreadContext only updates the WOW64 context save area; the actual CPU hardware DR registers that trigger debug exceptions need the native path.""" # Allocate 16-byte aligned buffer for x64 CONTEXT (size 0x4D0) raw = (ctypes.c_byte * (_CONTEXT64_SIZE + 15))() base = ctypes.addressof(raw) aligned = ctypes.c_void_p((base + 15) & ~15) ctypes.memset(aligned, 0, _CONTEXT64_SIZE) ctypes.c_uint32.from_address(aligned.value + _CTX64_FLAGS_OFF).value = CONTEXT_AMD64_DEBUG # Read current native DR state (ignore failure on non-suspendable threads) k.GetThreadContext(thread_handle, aligned) # Write DR0-DR3 for each watchpoint slot for slot, wp_addr in self._watchpoints.addresses().items(): ctypes.c_uint64.from_address(aligned.value + _CTX64_DR0_OFF + slot * 8).value = wp_addr # Clear remaining DR slots active_slots = set(self._watchpoints.addresses().keys()) for slot in range(4): if slot not in active_slots: ctypes.c_uint64.from_address(aligned.value + _CTX64_DR0_OFF + slot * 8).value = 0 ctypes.c_uint64.from_address(aligned.value + _CTX64_DR6_OFF).value = 0 ctypes.c_uint64.from_address(aligned.value + _CTX64_DR7_OFF).value = self._watchpoints.compute_dr7() k.SetThreadContext(thread_handle, aligned) def _apply_watchpoints_to_thread(self, thread_handle: int) -> None: """Set DR0-DR3 and DR7 on one thread. Uses both the WOW64 context (for Wow64GetThreadContext readback) and the native x64 context (for actual hardware DR register effect on WOW64 threads). Silently skips 64-bit WOW64 helper threads where Wow64GetThreadContext returns access denied.""" # Native x64 path: sets actual hardware DR registers (Wow64SetThreadContext alone is not enough) self._set_native_dr_registers(thread_handle) # WOW64 path: keeps WOW64_CONTEXT DR fields in sync for get_registers readback try: ctx = self._get_context(thread_handle) except OSError: return for slot, wp_addr in self._watchpoints.addresses().items(): setattr(ctx, f"Dr{slot}", wp_addr) ctx.Dr7 = self._watchpoints.compute_dr7() self._set_context(thread_handle, ctx) def set_watchpoint(self, addr: int, size: int, mode: str) -> int: slot = self._watchpoints.allocate(addr, size, mode) for thread_handle in self._threads.values(): self._apply_watchpoints_to_thread(thread_handle) return slot def remove_watchpoint(self, slot: int) -> None: self._watchpoints.release(slot) for thread_handle in self._threads.values(): self._apply_watchpoints_to_thread(thread_handle) def list_watchpoints(self) -> dict: return self._watchpoints.addresses() def _get_context(self, thread_handle: wintypes.HANDLE) -> WOW64_CONTEXT: ctx = WOW64_CONTEXT() ctx.ContextFlags = CONTEXT_ALL if not k.Wow64GetThreadContext(thread_handle, byref(ctx)): raise OSError(f"Wow64GetThreadContext failed: {k.GetLastError()}") return ctx def _set_context(self, thread_handle: wintypes.HANDLE, ctx: WOW64_CONTEXT) -> None: ctx.ContextFlags = CONTEXT_ALL if not k.Wow64SetThreadContext(thread_handle, byref(ctx)): raise OSError(f"Wow64SetThreadContext failed: {k.GetLastError()}") def get_registers(self) -> dict: if self._stopped_thread_id is not None and self._stopped_thread_id in self._threads: thread_handle = self._threads[self._stopped_thread_id] else: thread_handle = next(iter(self._threads.values())) ctx = self._get_context(thread_handle) return {name.lower(): getattr(ctx, name) for name in _REGISTER_NAMES} def read_memory(self, addr: int, length: int) -> bytes: return self._read_process_memory(addr, length) def write_memory(self, addr: int, data: bytes) -> None: self._write_process_memory(addr, data) def continue_execution(self, timeout: float) -> dict: if self._exited is not None: return {"status": "exited", "reason": None, "exit_code": self._exited} # If we reported a stop last time, the debuggee is still suspended on that event. # Resume it now before waiting for the next one. if self._pending_event is not None: pid, tid = self._pending_event self._pending_event = None k.ContinueDebugEvent(pid, tid, DBG_CONTINUE) evt = DEBUG_EVENT() deadline = time.time() + timeout while time.time() < deadline: remaining_ms = max(1, int((deadline - time.time()) * 1000)) if not k.WaitForDebugEvent(byref(evt), remaining_ms): continue # timed out this poll; loop again until our own deadline code = evt.dwDebugEventCode # Stale events from a previously-terminated process (drained but not fully gone) # can arrive after a new launch. Ignore them to avoid misidentifying the new game. if evt.dwProcessId != self._process_info.dwProcessId: k.ContinueDebugEvent(evt.dwProcessId, evt.dwThreadId, DBG_CONTINUE) continue if code == EXIT_PROCESS_DEBUG_EVENT: # For an exit event the union holds EXIT_PROCESS_DEBUG_INFO; its first dword # (dwExitCode) overlaps ExceptionRecord.ExceptionCode, so this reads the code. self._exited = evt.u.Exception.ExceptionRecord.ExceptionCode & 0xFFFFFFFF k.ContinueDebugEvent(evt.dwProcessId, evt.dwThreadId, DBG_CONTINUE) return {"status": "exited", "reason": None, "exit_code": self._exited} if code == CREATE_THREAD_DEBUG_EVENT: handle = k.OpenThread(0x1FFFFF, False, evt.dwThreadId) if handle: self._threads[evt.dwThreadId] = handle if self._watchpoints.addresses(): self._apply_watchpoints_to_thread(handle) elif code == EXCEPTION_DEBUG_EVENT: result = self._handle_exception(evt) if result is not None: # Stop: leave the debuggee suspended on this event so registers/memory can # be inspected. The next continue_execution() resumes it via _pending_event. self._pending_event = (evt.dwProcessId, evt.dwThreadId) return result k.ContinueDebugEvent(evt.dwProcessId, evt.dwThreadId, DBG_CONTINUE) return {"status": "running", "reason": None, "exit_code": None} def _handle_exception(self, evt: DEBUG_EVENT) -> Optional[dict]: """Returns a stop-reason dict if execution should actually stop and be reported, or None if it should transparently resume (e.g. a conditional breakpoint whose condition was false).""" er = evt.u.Exception.ExceptionRecord exc_code = er.ExceptionCode & 0xFFFFFFFF addr = er.ExceptionAddress or 0 thread_handle = self._threads.get(evt.dwThreadId) if exc_code in _BREAKPOINT_CODES and addr in self._breakpoints: orig_byte, condition = self._breakpoints[addr] ctx = self._get_context(thread_handle) registers = {name.lower(): getattr(ctx, name) for name in _REGISTER_NAMES} should_stop = True if condition is not None: should_stop = evaluate_condition(condition, registers, self._read_process_memory) # Restore original byte, rewind EIP, and set trap flag to single-step past # the restored instruction so we can re-arm the 0xCC afterwards. self._write_process_memory(addr, orig_byte) ctx.Eip = addr ctx.EFlags |= TRAP_FLAG self._set_context(thread_handle, ctx) self._step_over = (addr, evt.dwThreadId) if should_stop: self._stopped_thread_id = evt.dwThreadId return {"status": "stopped", "reason": f"breakpoint@0x{addr:X}", "exit_code": None} return None # condition false: caller's loop will ContinueDebugEvent and keep going if exc_code in _SINGLE_STEP_CODES: ctx = self._get_context(thread_handle) # Dr6 bits 0-3: which of DR0-DR3 fired. If any are set this is a watchpoint hit; # if Dr6 is clear it is a trap-flag single-step from breakpoint re-arm. triggered_slots = [slot for slot in range(4) if ctx.Dr6 & (1 << slot)] if triggered_slots: ctx.Dr6 = 0 # clear condition bits so the next single-step starts clean self._set_context(thread_handle, ctx) self._stopped_thread_id = evt.dwThreadId return { "status": "stopped", "reason": f"watchpoint@slot{triggered_slots[0]}", "exit_code": None, } # Trap-flag single-step: re-arm the breakpoint we just stepped past. if self._step_over is not None: rearm_addr, _tid = self._step_over self._step_over = None if rearm_addr in self._breakpoints: self._write_process_memory(rearm_addr, b"\xCC") return None # transparent; do not stop # AV: stop and report. The game loops on DBG_CONTINUE for its boot-time AVs, so the # caller must explicitly continue past each one. Callers that don't care about AVs # can loop: while r['reason'] and 'access_violation' in r['reason']: r = continue() if exc_code == EXCEPTION_ACCESS_VIOLATION: self._stopped_thread_id = evt.dwThreadId return {"status": "stopped", "reason": f"access_violation@0x{addr:X}", "exit_code": None} return None def screenshot(self) -> bytes: """Capture the game window as PNG bytes. Requires Pillow (pip install pillow).""" from PIL import ImageGrab # local import: only needed on Caspian, not for pure-logic tests import io hwnd = ctypes.windll.user32.FindWindowA(None, b"HAVOC(tm) by Reality Bytes") if not hwnd: raise OSError("game window not found -- is HAVOC_NOCD.EXE running and visible?") rect = wintypes.RECT() ctypes.windll.user32.GetWindowRect(hwnd, byref(rect)) img = ImageGrab.grab(bbox=(rect.left, rect.top, rect.right, rect.bottom)) buf = io.BytesIO() img.save(buf, format="PNG") return buf.getvalue() def get_status(self) -> dict: if self._exited is not None: return {"status": "exited", "exit_code": self._exited} if self._process_info is None: return {"status": "not_launched"} return {"status": "running"}