{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreibqvze7l6g6kobcl6lrz5o7rvbxxiu3j722skxuwtuc5mglb444ke",
"uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3mphorn6ymkn2"
},
"coverImage": {
"$type": "blob",
"ref": {
"$link": "bafkreihny5rfz3imm42hqavktwo3dozehsyerf53luosmzzaaukwzms7hi"
},
"mimeType": "image/webp",
"size": 57948
},
"path": "/ambiguitydev/i-replaced-hooking-libraries-with-one-rust-crate-j4k",
"publishedAt": "2026-06-29T22:48:29.000Z",
"site": "https://dev.to",
"tags": [
"rust",
"reverseengineering",
"assembly",
"neohook",
"\"Hooking by Example\" by Kyle Halladay",
"@HANDLER"
],
"textContent": "> TL;DR: I've shipped hooks with different libs before. I even\n> wrote own hooks in assembly, expected the usual C++ headache, and instead found neohook (v0.11.0) a Rust crate that does inline hooks, trampolines, mid-function hooks and more,\n> I disassembled what it produces to make sure it wasn't cheating. It wasn't. It's now the only hooking library I reach for and because it ships a C ABI, I use it from C, C++ and Python too.\n\n## How I got here\n\nI wanted to intercept a function call inside a running Windows process, run my own code, and still let the original do its thing. The textbook answer is **function hooking** , and the textbook tools are C++. I've shipped all common libs. They work, but the \"just overwrite the first bytes with a jump\" idea hides a surprising amount of sharp edges, and each one solves a different subset of them.\n\nSince the rest of my project is in Rust, I went looking on crates.io first and found `neohook`. After a weekend of poking at it I quietly retired the other libraries from my toolbox, because it's the first time hooking felt _boring_ in a good way. This post is my walkthrough: a real hook, a look at the assembly underneath, the footguns I expected to hit but didn't and the part that surprised me most, that I can call the exact same engine from C, C++ and Python.\n\nIf you know the classic \"Hooking by Example\" by Kyle Halladay: this is what that article looks like when you _don't_ have to hand-write the whole unsafe engine room.\n\n## First contact: hooking in one line\n\nThe first thing I tried was the smallest possible thing redirect a plain Rust\nfunction at runtime:\n\n\n\n use neohook::detour_inline;\n\n #[inline(never)]\n fn target(x: i32) -> i32 { x * 2 }\n fn detour(x: i32) -> i32 { x + 100 }\n\n fn main() {\n let _hook = detour_inline!(target, detour).expect(\"hook failed\");\n assert_eq!(target(5), 105); // intercepted!\n }\n\n\n`target(5)` stopped returning `10` and started returning `105`. I didn't touch,\nrecompile, or relink anything the function was replaced **in the running\nprocess**. Okay, neat. But a real hook usually needs to _wrap_ the original, not\nthrow it away: log it, check the arguments, tweak the result. For that you need a\n**trampoline** a way to still reach the real original after you've patched over\nit:\n\n\n\n use std::sync::OnceLock;\n use neohook::detour_helper;\n\n type AddFn = fn(i32, i32) -> i32;\n static ORG_ADD: OnceLock<AddFn> = OnceLock::new();\n\n #[inline(never)]\n fn add(a: i32, b: i32) -> i32 { a + b }\n\n fn detour_add(a: i32, b: i32) -> i32 {\n let original = ORG_ADD.get().unwrap(); // the real add, via the trampoline\n original(a, b) * 10\n }\n\n fn main() {\n let _hook = detour_helper!(ORG_ADD, add, detour_add, AddFn)\n .expect(\"hook failed\");\n assert_eq!(add(2, 3), 50); // (2+3) * 10\n }\n\n\n`detour_helper!` installs the hook **and** stashes a typed pointer to the\ntrampoline in `ORG_ADD`. Keep the word _trampoline_ in mind it shows up\nliterally once we look at the disassembly.\n\n## The real test: hooking a Win32 API\n\nToy functions are easy. I wanted to see it work on something real, so I went for\n`MessageBoxW` from `user32.dll`. The goal: make **every** dialog in the process\nrun through my code first log the title and text, and rewrite the title.\n\n\n\n use std::ffi::c_void;\n use std::sync::OnceLock;\n use neohook::{detour_helper, find_function};\n\n // int MessageBoxW(HWND, LPCWSTR text, LPCWSTR caption, UINT type);\n type MessageBoxWFn =\n unsafe extern \"system\" fn(*mut c_void, *const u16, *const u16, u32) -> i32;\n\n static ORIGINAL_MBW: OnceLock<MessageBoxWFn> = OnceLock::new();\n\n unsafe extern \"system\" fn detour_mbw(\n hwnd: *mut c_void,\n text: *const u16,\n caption: *const u16,\n utype: u32,\n ) -> i32 {\n println!(\"[hook] MessageBoxW intercepted\");\n // Rewrite the caption, forward the text unchanged ...\n let new_caption: Vec<u16> = \"[hooked]\".encode_utf16().chain([0]).collect();\n let original = ORIGINAL_MBW.get().unwrap();\n original(hwnd, text, new_caption.as_ptr(), utype)\n }\n\n fn main() {\n let target = find_function(\"user32.dll\", \"MessageBoxW\").unwrap();\n let _hook = detour_helper!(ORIGINAL_MBW, target, detour_mbw, MessageBoxWFn)\n .expect(\"hook failed\");\n // every MessageBoxW call in the process now lands at me first\n }\n\n\nWhat I liked: `find_function` resolves the address from the loaded DLL at runtime\nno hardcoded offset, no `GetProcAddress` dance. It just worked. The full,\nrunnable version is in the repo at\n`examples/win32_messagebox.rs`.\n\nAt this point I got suspicious. \"_Hook any function in one line_ \" is the kind of\nclaim that's usually hiding something. So I opened a disassembler.\n\n## What it actually does, in assembly\n\nSay `MessageBoxW` starts like this (x64, representative the exact bytes depend\non the Windows version):\n\n\n\n ; --- BEFORE: original prologue at address 0x7FFA1234ABC0 ---\n MessageBoxW:\n 48 89 5C 24 08 mov [rsp+8], rbx ; 5 bytes\n 57 push rdi ; 1 byte\n 48 83 EC 20 sub rsp, 20h ; 4 bytes\n ...\n\n\nA relative jump `E9 rel32` needs **5 bytes** , so neohook overwrites the start\nwith a jump to my detour:\n\n\n\n ; --- AFTER: patched ---\n MessageBoxW:\n E9 ?? ?? ?? ?? jmp detour_mbw ; 5-byte rel32 jump\n 90 (remainder of the displaced instruction, now dead)\n 48 83 EC 20 sub rsp, 20h ; never runs directly anymore\n ...\n\n\nSo `MessageBoxW` is redirected. But this is exactly the part that worried me: the\nfirst instructions are now **destroyed**. How does my detour still call the\noriginal?\n\n### The trampoline\n\nIt turns out neohook copies the displaced instructions (\"stolen bytes\") into a\nfreshly allocated, executable block and appends a jump _back_ to just past the\npatch. That block is the **trampoline** :\n\n\n\n ; --- TRAMPOLINE (allocated memory) ---\n trampoline:\n 48 89 5C 24 08 mov [rsp+8], rbx ; relocated stolen bytes\n 57 push rdi\n 48 83 EC 20 sub rsp, 20h\n E9 ?? ?? ?? ?? jmp MessageBoxW+10 ; back, right past the patch\n\n\n`ORIGINAL_MBW` points at `trampoline`. When my detour calls `original(...)`, the\nCPU runs the real first instructions and then jumps into the rest of the\nuntouched function. The original never knows it was hooked.\n\n\n\n caller ──> MessageBoxW ──jmp──> detour_mbw ──> (my code)\n │\n └─ original() ──> trampoline ──jmp──> MessageBoxW+10 ──> rest\n\n\nThat's the clean version of the story. The reason I'd been burned by this before\nis that the clean version is a lie there are at least four ways it goes wrong.\nSo I went looking for whether neohook actually handles them.\n\n## The footguns I expected and what neohook does about them\n\n### 1. Instruction relocation (the one that gets everybody)\n\nYou can't just `memcpy` machine code to a new address. Lots of x64 instructions\nare **RIP-relative** they address relative to the instruction's own location:\n\n\n\n 48 8B 0D 39 2C 04 00 mov rcx, [rip + 0x42C39] ; loads from \"here + 0x42C39\"\n\n\nMove that into the trampoline and `rip` is different now, so `0x42C39` points at\ngarbage. Same story for relative `call`/`jmp`. This is the classic homemade-hook\ncrash, and it's miserable to debug.\n\nneohook disassembles the stolen bytes and _re-encodes_ them for\ntheir new address displacements and relative targets get recomputed. I checked\nthe trampoline's bytes against the original in a disassembler and the relative\noperands had indeed been fixed up. This was the moment I started trusting it.\n\n### 2. A thread running into the patch mid-write\n\nWhile you're overwriting bytes, another thread could be executing _inside_ those\nvery bytes, or have a return address pointing into them. You end up with half-old,\nhalf-new instructions and a random, unreproducible crash.\n\nneohook **suspends the other threads** during the patch, checks each one's\ninstruction pointer, nudges it somewhere safe if needed, and even rewrites return\naddresses on the stack before resuming them. This is the kind of thing you almost\nnever get right by hand.\n\n### 3. Atomicity all or nothing\n\nA hook is several steps: flip memory protection, back up bytes, build the\ntrampoline, write the patch, restore protection. If step 4 fails, you're left\nwith a half-patched, corrupt function.\n\nneohook is **transactional** : multiple hooks commit together, and if anything\nfails it does an **atomic rollback** to the original state. No half-applied mess. One can see it was influenced by other great libraries like Microsoft Detours which also rely on a Transaction API.\n\n### 4. Cleanup RAII instead of a leak\n\nForget to remove a hook and the patch (plus its trampoline) lingers after your\ntool is done. In neohook the hook is an **RAII value** : drop it and the original\nbytes are restored, the stubs freed. That's why every example binds it to\n`let _hook = ...` the hook lives exactly as long as that value does.\n\n> The pattern I noticed: every place I'd previously written pages of `unsafe` and\n> edge-case handling in C++, neohook had already absorbed into the library.\n\n## The feature that sold me: mid-function hooks\n\nAn inline hook swaps out a _whole function_. But sometimes you want to land in\nthe **middle** of one at the exact spot where a value is computed but not yet\nused and just nudge a register. neohook captures the **full register state** at the\ntarget into a `HookContext` you can read and write:\n\n\n\n use neohook::{HookContext, MidHook};\n\n // rcx = 1st argument (Win64 ABI) = damage value\n extern \"system\" fn apply_damage(amount: u64) { /* ... HP -= amount ... */ }\n\n unsafe extern \"system\" fn gmode(ctx: *mut HookContext) {\n let ctx = &mut *ctx;\n ctx.rcx = 0; // zero the damage before the math runs\n // ctx.redirect_rip stays 0 -> the function continues with the modified rcx\n }\n\n fn main() {\n let target = apply_damage as *const u8;\n let _hook = unsafe { MidHook::install(target, gmode) }.unwrap();\n apply_damage(9999); // the damage arrives as 0\n }\n\n\n`HookContext` exposes `rax`, `rcx`, `rdx`, `r8`–`r15`, `rflags`, `mxcsr`, and even\n`xmm[0..16]`. Set `redirect_rip` and you can divert control flow and skip whole\nregions of code.\n\nUnder the hood it builds a **context-bridge stub** that saves the registers,\ncalls your handler, and restores them 1:1 afterward so the function only sees\nthe values you deliberately changed. I'd written something like this by hand once\nand it took an afternoon and three crashes. Here it was a single `install` call.\n\n## The part that surprised me: it isn't Rust-only\n\nHere's the thing that actually made me retire my old tools. My world isn't pure\nRust. I wasn't even a fan of Rust since I always did it with C++, and a bunch of tooling glued together in Python. With\nMinHook I was already in C/C++; switching to a Rust crate sounded like it would\n_lose_ me those languages. It does the opposite.\n\nneohook ships a **C ABI** generated with cbindgen. You build the header once:\n\n\n\n cargo build --features generate-headers # writes the header into include/\n\n\n…and now the whole engine inline hooks, mid-hooks, VEH/INT3 hooks, pattern\nscanning is callable from C, C++, or anything that speaks the C\nABI.\n\n### From C / C++\n\nThe same mid-hook from earlier, but in C:\n\n\n\n #include \"neohook.h\" // generated by `cargo build --features generate-headers`\n\n // Handler receives a HookContext* same layout as the Rust struct.\n void gmode(HookContext* ctx) {\n ctx->rcx = 0; // zero the damage argument\n }\n\n int main(void) {\n void* target = /* address of the function to hook */;\n MidHook* h = detours_midhook_install(target, gmode);\n // ... run the program ...\n detours_midhook_unhook(h);\n return 0;\n }\n\n\nNo Rust in sight at the call site. You link against the built library, include\nthe generated header, and you get the exact same transactional, thread-safe\nengine I disassembled above.\n\n### From Python (ctypes)\n\nThis is the one that sold me. My instrumentation harness is Python, and I could\ndrive the hooks straight from it no extension module, no `maturin`, just\n`ctypes` against the built DLL:\n\n\n\n import ctypes\n\n neo = ctypes.CDLL(\"./neohook.dll\")\n\n # Mirror the leading fields of HookContext (full layout is in the generated\n # header); the first three u64s are rflags, rax, rcx enough to reach rcx.\n class HookContext(ctypes.Structure):\n _fields_ = [\n (\"rflags\", ctypes.c_uint64),\n (\"rax\", ctypes.c_uint64),\n (\"rcx\", ctypes.c_uint64),\n # ... rax/rdx/rbx/.../r15, mxcsr, xmm[16], redirect_rip ...\n ]\n\n HANDLER = ctypes.CFUNCTYPE(None, ctypes.POINTER(HookContext))\n\n @HANDLER\n def gmode(ctx):\n ctx.contents.rcx = 0 # same trick, in Python\n\n neo.detours_midhook_install.restype = ctypes.c_void_p\n hook = neo.detours_midhook_install(ctypes.c_void_p(target), gmode)\n # ... run ...\n neo.detours_midhook_unhook(ctypes.c_void_p(hook))\n\n\nOne hooking engine, three languages, identical guarantees. That's the moment I\nstopped maintaining a separate C++ hooking layer and a separate \"something for\nPython\" and just standardized on this. (Keep the `HANDLER` object alive for as\nlong as the hook is installed `ctypes` will otherwise garbage-collect your\ncallback out from under the CPU.)\n\n## How it stacks up against the C++ classics\n\nTo be fair, Detours, MinHook and PolyHook2 are battle-tested and great. The\ndifference is that they're C/C++ with manual memory management, and the feature\ncoverage varies by which one you pick. What made `neohook` stick for me is that\nit folds all of this into **one** Rust crate\n\nTwo more things I bumped into while exploring and thought were genuinely nice a\n**capturing closure** as a detour …\n\n\n\n let calls = AtomicU32::new(0);\n let _h = detour_closure!(add, \"system\" fn(a: i32, b: i32) -> i32,\n move |orig, a, b| { calls.fetch_add(1, Ordering::Relaxed); orig(a, b) * 10 })?;\n\n\n… and an **automatic tracing hook** that logs the arguments and return value with\nno detour body at all:\n\n\n\n let _h = detour_trace!(add, \"system\" fn(a: i32, b: i32) -> i32)?;\n // add(2, 3) -> 5 logged automatically, the real result stays 5\n\n\n## Am I going back to the old tools? No.\n\nI came in skeptical of the \"one line\" marketing and left having verified, in a\ndisassembler, that the hard parts relocation, thread safety, atomic patching,\ncleanup are actually handled. Then I found I could call the same engine from\nC, C++ and Python, which collapsed three separate hooking setups in my stack\ninto one. neohook is where I've standardized.",
"title": "I replaced hooking libraries with one rust crate"
}