Files
WeirdUtils/libs/hook/src/hook.zig
T
MarcelineVQ b7f293c8c5 Initial commit: WeirdUtils DLL for WoW 1.12.1
Lua protection bypass, embedded addon loading, async PNG screenshots
with self-contained deflate compressor (store + fixed Huffman).
15KB ReleaseSmall.
2026-02-23 16:02:55 -08:00

471 lines
16 KiB
Zig

//! x86 inline hooking library for Windows DLL injection.
//!
//! Provides a `Hook` struct for patching function prologues with JMP detours,
//! building trampolines to call the original, and chaining with other hooks.
//! Also includes memory read/write helpers, rel32 arithmetic, a generic
//! `fastcall` caller, and a fastcall-to-cdecl thunk builder.
//!
//! ## Quick start
//!
//! ```zig
//! const hook = @import("hook.zig");
//!
//! var my_hook = hook.Hook{};
//!
//! // One-shot: prepare trampoline + patch in one call.
//! // The last arg is a slice of opcode offsets within the prologue that
//! // contain E8/E9 (CALL/JMP rel32) instructions needing fixup.
//! _ = my_hook.install(target_addr, prologue_size, @intFromPtr(&detour), &.{1});
//!
//! // Two-phase (when you need the alloc block before patching, e.g. for a thunk):
//! _ = my_hook.prepare(target_addr, prologue_size, &.{});
//! const thunk_buf = my_hook.mem.? + 32;
//! _ = hook.buildFastcallToCdeclThunk(thunk_buf, @intFromPtr(&hookImpl), 1);
//! my_hook.activate(@intFromPtr(thunk_buf));
//!
//! // Call the original from inside the detour:
//! const orig = my_hook.getTrampoline(*const fn () callconv(.{ .x86_stdcall = .{} }) void);
//! orig();
//!
//! // Unhook (restore original bytes, free memory):
//! my_hook.remove();
//! ```
const std = @import("std");
const use_hwbp = @import("config").use_hwbp;
// =============================================================================
// Windows API
// =============================================================================
const WINAPI = std.builtin.CallingConvention.winapi;
pub const PAGE_EXECUTE_READWRITE: u32 = 0x40;
pub const MEM_COMMIT: u32 = 0x1000;
pub const MEM_RELEASE: u32 = 0x8000;
extern "kernel32" fn VirtualProtect(
lpAddress: *anyopaque,
dwSize: usize,
flNewProtect: u32,
lpflOldProtect: *u32,
) callconv(WINAPI) i32;
extern "kernel32" fn VirtualAlloc(
lpAddress: ?*anyopaque,
dwSize: usize,
flAllocationType: u32,
flProtect: u32,
) callconv(WINAPI) ?[*]u8;
extern "kernel32" fn VirtualFree(
lpAddress: *anyopaque,
dwSize: usize,
dwFreeType: u32,
) callconv(WINAPI) i32;
// =============================================================================
// Hardware breakpoint support (DR0-DR3 + Vectored Exception Handler)
// =============================================================================
const CONTEXT_DEBUG_REGISTERS: u32 = 0x00010010;
const EXCEPTION_SINGLE_STEP: u32 = 0x80000004;
const EXCEPTION_CONTINUE_EXECUTION: i32 = -1;
const EXCEPTION_CONTINUE_SEARCH: i32 = 0;
extern "kernel32" fn GetCurrentThread() callconv(WINAPI) *anyopaque;
extern "kernel32" fn GetThreadContext(
hThread: *anyopaque,
lpContext: *CONTEXT,
) callconv(WINAPI) i32;
extern "kernel32" fn SetThreadContext(
hThread: *anyopaque,
lpContext: *const CONTEXT,
) callconv(WINAPI) i32;
extern "kernel32" fn AddVectoredExceptionHandler(
First: u32,
Handler: *const fn (*EXCEPTION_POINTERS) callconv(WINAPI) i32,
) callconv(WINAPI) ?*anyopaque;
extern "kernel32" fn RemoveVectoredExceptionHandler(
Handle: *anyopaque,
) callconv(WINAPI) u32;
const CONTEXT = extern struct {
ContextFlags: u32,
Dr0: u32,
Dr1: u32,
Dr2: u32,
Dr3: u32,
Dr6: u32,
Dr7: u32,
FloatSave: [112]u8,
SegGs: u32,
SegFs: u32,
SegEs: u32,
SegDs: u32,
Edi: u32,
Esi: u32,
Ebx: u32,
Edx: u32,
Ecx: u32,
Eax: u32,
Ebp: u32,
Eip: u32,
SegCs: u32,
EFlags: u32,
Esp: u32,
SegSs: u32,
ExtendedRegisters: [512]u8,
};
const EXCEPTION_RECORD = extern struct {
ExceptionCode: u32,
ExceptionFlags: u32,
ExceptionRecord: ?*EXCEPTION_RECORD,
ExceptionAddress: ?*anyopaque,
NumberParameters: u32,
ExceptionInformation: [15]usize,
};
const EXCEPTION_POINTERS = extern struct {
ExceptionRecord: *EXCEPTION_RECORD,
ContextRecord: *CONTEXT,
};
var hwbp_slots: [4]?*Hook = .{ null, null, null, null };
var hwbp_detours: [4]usize = .{ 0, 0, 0, 0 };
var veh_handle: ?*anyopaque = null;
fn vehHandler(info: *EXCEPTION_POINTERS) callconv(WINAPI) i32 {
if (info.ExceptionRecord.ExceptionCode != EXCEPTION_SINGLE_STEP)
return EXCEPTION_CONTINUE_SEARCH;
const eip = info.ContextRecord.Eip;
for (0..4) |i| {
if (hwbp_slots[i]) |h| {
if (h.target == eip) {
info.ContextRecord.Eip = @intCast(hwbp_detours[i]);
return EXCEPTION_CONTINUE_EXECUTION;
}
}
}
return EXCEPTION_CONTINUE_SEARCH;
}
// =============================================================================
// Memory helpers
// =============================================================================
/// Read a value of type `T` from an arbitrary memory address (unaligned).
pub fn readMem(comptime T: type, addr: usize) T {
return @as(*align(1) const T, @ptrFromInt(addr)).*;
}
/// Write raw bytes to an arbitrary memory address. No protection change —
/// caller must ensure the page is writable (or use `writeProtected`).
pub fn writeMem(addr: usize, bytes: []const u8) void {
const dest: [*]u8 = @ptrFromInt(addr);
for (bytes, 0..) |b, i| {
dest[i] = b;
}
}
/// Write bytes to a potentially read-only/executable page. Temporarily sets
/// PAGE_EXECUTE_READWRITE, writes, then restores the original protection.
pub fn writeProtected(addr: usize, bytes: []const u8) void {
var old: u32 = 0;
_ = VirtualProtect(@ptrFromInt(addr), bytes.len, PAGE_EXECUTE_READWRITE, &old);
writeMem(addr, bytes);
_ = VirtualProtect(@ptrFromInt(addr), bytes.len, old, &old);
}
// =============================================================================
// Rel32 helpers
// =============================================================================
/// Resolve the absolute target of an E8 (CALL) or E9 (JMP) at `addr`.
/// Reads the signed rel32 operand at addr+1 and computes addr+5+offset.
pub fn rel32Target(addr: usize) usize {
const offset: u32 = @bitCast(@as(*align(1) const i32, @ptrFromInt(addr + 1)).*);
return (addr + 5) +% offset;
}
/// Write a rel32 displacement into dest[0..4] such that a JMP/CALL
/// from address `from` reaches `to`. Displacement = to - (from + 4).
pub fn writeRel32(dest: [*]u8, from: usize, to: usize) void {
std.mem.writeInt(u32, dest[0..4], to -% (from + 4), .little);
}
// =============================================================================
// Calling convention helper
// =============================================================================
/// Call a __fastcall function at `addr` with ECX and EDX arguments.
/// Dispatches on return type: void, f64 (x87 ST0), or integer/pointer (EAX).
pub fn fastcall(comptime R: type, addr: usize, ecx: anytype, edx: anytype) R {
if (R == f64) {
return asm volatile ("call *%[func]"
: [ret] "={st}" (-> f64),
: [_] "{ecx}" (ecx), [_] "{edx}" (edx), [func] "r" (addr),
: .{ .eax = true, .memory = true, .cc = true }
);
} else if (R == void) {
asm volatile ("call *%[func]"
:
: [_] "{ecx}" (ecx), [_] "{edx}" (edx), [func] "r" (addr),
: .{ .eax = true, .memory = true, .cc = true }
);
} else {
return asm volatile ("call *%[func]"
: [ret] "={eax}" (-> R),
: [_] "{ecx}" (ecx), [_] "{edx}" (edx), [func] "r" (addr),
: .{ .memory = true, .cc = true }
);
}
}
// =============================================================================
// Hook struct
// =============================================================================
const ALLOC_SIZE: usize = 64;
const TRAMPOLINE_RESERVE: usize = 32;
const MAX_PROLOGUE: usize = 16;
pub const Hook = struct {
mem: ?[*]u8 = null,
trampoline: usize = 0,
target: usize = 0,
prologue_size: usize = 0,
saved_bytes: [MAX_PROLOGUE]u8 = undefined,
/// Allocate executable memory, save current bytes at target, and build the
/// trampoline. Does NOT patch the target yet — call activate() after.
/// `rel32_fixups` is a slice of opcode offsets within the prologue that
/// contain E8/E9 instructions needing rel32 adjustment.
pub fn prepare(
self: *Hook,
target: usize,
prologue_size: usize,
rel32_fixups: []const usize,
) bool {
if (self.mem != null) return true;
const mem = VirtualAlloc(null, ALLOC_SIZE, MEM_COMMIT, PAGE_EXECUTE_READWRITE) orelse return false;
self.mem = mem;
self.target = target;
self.prologue_size = prologue_size;
// Save current bytes for remove()
const src: [*]const u8 = @ptrFromInt(target);
@memcpy(self.saved_bytes[0..prologue_size], src[0..prologue_size]);
// Build trampoline
self.trampoline = @intFromPtr(mem);
if (src[0] == 0xE9) {
// Another DLL already hooked — resolve their JMP and chain through it
const other_detour = rel32Target(target);
mem[0] = 0xE9;
writeRel32(mem + 1, self.trampoline + 1, other_detour);
} else {
// Original prologue — copy bytes, fix up any relative instructions, JMP back
@memcpy(mem[0..prologue_size], src[0..prologue_size]);
for (rel32_fixups) |opcode_offset| {
const abs_target = rel32Target(target + opcode_offset);
const tramp_operand = self.trampoline + opcode_offset + 1;
writeRel32(mem + opcode_offset + 1, tramp_operand, abs_target);
}
mem[prologue_size] = 0xE9;
writeRel32(
mem + prologue_size + 1,
self.trampoline + prologue_size + 1,
target + prologue_size,
);
}
return true;
}
/// Write the E9 JMP patch (inline mode) or set a hardware breakpoint
/// (HWBP mode) to redirect target → detour_addr.
pub fn activate(self: *Hook, detour_addr: usize) void {
if (use_hwbp) {
// Register VEH on first use
if (veh_handle == null) {
veh_handle = AddVectoredExceptionHandler(1, &vehHandler);
}
// Find free DR slot
const slot: u2 = for (0..4) |i| {
if (hwbp_slots[i] == null) break @as(u2, @intCast(i));
} else return; // all 4 slots occupied
hwbp_slots[slot] = self;
hwbp_detours[slot] = detour_addr;
const thread = GetCurrentThread();
var ctx: CONTEXT = std.mem.zeroes(CONTEXT);
ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS;
_ = GetThreadContext(thread, &ctx);
// Set DRn to target address
switch (slot) {
0 => { ctx.Dr0 = @intCast(self.target); },
1 => { ctx.Dr1 = @intCast(self.target); },
2 => { ctx.Dr2 = @intCast(self.target); },
3 => { ctx.Dr3 = @intCast(self.target); },
}
// Enable local execute breakpoint in DR7
const s: u5 = slot;
ctx.Dr7 |= @as(u32, 1) << (s * 2);
ctx.Dr7 &= ~(@as(u32, 0xF) << (16 + s * 4));
_ = SetThreadContext(thread, &ctx);
} else {
var patch: [MAX_PROLOGUE]u8 = .{0x90} ** MAX_PROLOGUE;
patch[0] = 0xE9;
writeRel32(patch[1..5], self.target + 1, detour_addr);
writeProtected(self.target, patch[0..self.prologue_size]);
}
}
/// Convenience: prepare + activate in one call.
pub fn install(
self: *Hook,
target: usize,
prologue_size: usize,
detour_addr: usize,
rel32_fixups: []const usize,
) bool {
if (!self.prepare(target, prologue_size, rel32_fixups)) return false;
self.activate(detour_addr);
return true;
}
/// Restore original bytes (inline) or clear the DR slot (HWBP), then free
/// the trampoline memory.
pub fn remove(self: *Hook) void {
if (self.mem == null) return;
if (use_hwbp) {
for (0..4) |i| {
if (hwbp_slots[i]) |h| {
if (h == self) {
const thread = GetCurrentThread();
var ctx: CONTEXT = std.mem.zeroes(CONTEXT);
ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS;
_ = GetThreadContext(thread, &ctx);
switch (@as(u2, @intCast(i))) {
0 => { ctx.Dr0 = 0; },
1 => { ctx.Dr1 = 0; },
2 => { ctx.Dr2 = 0; },
3 => { ctx.Dr3 = 0; },
}
const s: u5 = @intCast(i);
ctx.Dr7 &= ~(@as(u32, 1) << (s * 2));
ctx.Dr7 &= ~(@as(u32, 0xF) << (16 + s * 4));
_ = SetThreadContext(thread, &ctx);
hwbp_slots[i] = null;
hwbp_detours[i] = 0;
break;
}
}
}
} else {
writeProtected(self.target, self.saved_bytes[0..self.prologue_size]);
}
_ = VirtualFree(@ptrFromInt(@intFromPtr(self.mem.?)), 0, MEM_RELEASE);
self.mem = null;
}
/// Cast trampoline address to a typed function pointer for calling the original.
pub fn getTrampoline(self: *const Hook, comptime T: type) T {
return @ptrFromInt(self.trampoline);
}
};
// =============================================================================
// Thunk builder: fastcall(ECX, EDX, stack...) → cdecl(stack, stack, stack...)
// =============================================================================
/// Build a fastcall-to-cdecl bridge thunk in `buf`.
/// `cdecl_fn` is the address of the cdecl target function.
/// `stack_arg_count` is the number of extra stack arguments beyond ECX/EDX.
/// Returns the total thunk size in bytes.
///
/// Generated code:
/// push dword [esp + 4*N] ; for each stack arg, right-to-left
/// ...
/// push edx ; arg 2
/// push ecx ; arg 1
/// mov eax, <cdecl_fn>
/// call eax
/// add esp, (2 + stack_arg_count) * 4
/// ret (stack_arg_count * 4)
pub fn buildFastcallToCdeclThunk(buf: [*]u8, cdecl_fn: usize, stack_arg_count: u8) usize {
var pos: usize = 0;
// Push stack args right-to-left. At entry, [esp] = return addr,
// [esp+4] = first stack arg, [esp+8] = second, etc.
// But each push shifts esp, so we always read from [esp + 4 * stack_arg_count]
// (the offset stays constant because we push the same number of times as the depth grows).
var i: u8 = stack_arg_count;
while (i > 0) : (i -= 1) {
// push dword ptr [esp + 4 * stack_arg_count]
buf[pos] = 0xFF;
buf[pos + 1] = 0x74;
buf[pos + 2] = 0x24;
buf[pos + 3] = stack_arg_count * 4;
pos += 4;
}
// push edx (arg 2)
buf[pos] = 0x52;
pos += 1;
// push ecx (arg 1)
buf[pos] = 0x51;
pos += 1;
// mov eax, <cdecl_fn>
buf[pos] = 0xB8;
std.mem.writeInt(u32, buf[pos + 1 ..][0..4], @intCast(cdecl_fn), .little);
pos += 5;
// call eax
buf[pos] = 0xFF;
buf[pos + 1] = 0xD0;
pos += 2;
// add esp, (2 + stack_arg_count) * 4 (cdecl caller cleanup)
const cleanup: u8 = (2 + stack_arg_count) * 4;
buf[pos] = 0x83;
buf[pos + 1] = 0xC4;
buf[pos + 2] = cleanup;
pos += 3;
// ret (stack_arg_count * 4) (fastcall callee cleans stack args)
if (stack_arg_count == 0) {
buf[pos] = 0xC3; // ret
pos += 1;
} else {
buf[pos] = 0xC2; // ret imm16
std.mem.writeInt(u16, buf[pos + 1 ..][0..2], @as(u16, stack_arg_count) * 4, .little);
pos += 3;
}
return pos;
}