VanHooks is a C++23 library for hooking functions on Windows, Linux and macOS. It has 17 kinds of hook, from the ordinary inline trampoline through import-table, vtable and mid-function hooks to hardware breakpoints, page-guard traps, syscall-gate patches and a kernel instrumentation callback. Every call that can fail returns std::expected, and every hook is an RAII object that restores the original bytes when it goes out of scope.
We wrote it because we were using MinHook on Windows and something else everywhere else, with different error handling in each. MinHook, EasyHook, SafetyHook and PolyHook2 each cover part of the ground. VanHooks covers all of it, so every TeamVanilla project hooks the same way.
Three API levels
The API is layered so simple tasks stay simple and advanced ones don't have to reinvent anything.
- Level 1 —
vh::hook(target, detour). One call, picks the right hook kind, returns avh::Hook. - Level 2 — Explicit kind:
vh::inline_hook,vh::api_hook,vh::iat_hook,vh::vtable_hook,vh::plt_hook,vh::mid_hook. Each takes typed options through avh::config::struct. - Level 3 —
vh::group("Name")for batch lifecycle, andvh::HookRegistry::global()for process-wide management across DLLs.
A first hook
The shortest form takes a target and a detour. Pass a third argument to get a pointer to the original function so the detour can call through to it.
#include <vh/vh.hpp>
static int (*orig_add)(int, int) = nullptr;
int my_add(int a, int b) {
printf("add(%d, %d)\n", a, b);
return orig_add(a, b);
}
auto r = vh::hook(&add, &my_add, &orig_add);
if (!r) {
printf("failed: %s\n", vh::error_to_string(r.error()).data());
}
System functions can be hooked by module and name, without looking up the address yourself:
static decltype(&MessageBoxW) orig_mbw = nullptr;
int WINAPI hk_mbw(HWND h, LPCWSTR text, LPCWSTR cap, UINT type) {
return orig_mbw(h, L"[Intercepted]", cap, type);
}
auto r = vh::api_hook("user32", "MessageBoxW", &hk_mbw, &orig_mbw,
{ .tag = "User32.MessageBoxW" });
Hook kinds
Every kind returns the same vh::Hook object, with the same enable / disable / remove / chain interface.
Core hooks (all platforms)
| Kind | Call | What it does |
|---|---|---|
| Trampoline | vh::inline_hook | Overwrites the start of the function with a jump. 5 bytes on x86/x64, 16 on ARM64. The displaced prologue is copied into a trampoline stub so the original can still be called. |
| Import table | vh::iat_hook / iat_hook_all | Redirects one import in one module, or in every loaded module. Windows. |
| PLT | vh::plt_hook | Patches PLT entries on Linux and lazy pointers on macOS — the POSIX equivalent of an IAT hook. |
| GOT | hook_got | Patches already-resolved GOT slots directly, covering symbols that PLT hooks miss. Linux and macOS. |
| VTable | vh::vtable_hook | Swaps one slot in a C++ virtual function table. The way to reach COM objects such as a Direct3D device. |
| Mid-function | vh::mid_hook | Stops at a byte offset inside a function, hands your callback a MidContext* with all GPRs and flags, and continues. Control flow isn't redirected. |
| Call-site | hook_callsite | Patches a single CALL or JMP instruction's rel32 displacement. Only that one call site is redirected; all other callers of the function are unaffected. |
| Return | hook_return | Intercepts function return. An optional entry callback fires first; the return callback gets a ReturnContext* with the return-value registers and the real return address. x64 only. |
| Thread-local | hook_thread_local | Installs a trampoline gated by a per-thread TLS flag. Only threads that call thread_local_enable(handle) see the detour; everyone else falls through to the original. |
No-write hooks (no .text modification)
| Kind | Call | What it does |
|---|---|---|
| Hardware breakpoint | hook_hwbp | Uses DR0–DR3 and a VEH. No bytes are written to the target. Max 4 active hooks. Windows x64. |
| PAGE_GUARD trap | hook_page_guard | Sets PAGE_GUARD on the target's page and intercepts the exception in a VEH. Windows only. |
| Instrumentation callback | hook_instrumentation_callback | Sets KPROCESS.InstrumentationCallback via NtSetInformationProcess. Fires on every kernel-to-user transition. Process-wide singleton. Windows x64. |
| Software breakpoint | hook_soft_bp | Writes INT 3 (x86/x64) or BRK (ARM64) and catches the exception in a VEH. |
| Exception | hook_exception | A VEH that fires for an arbitrary exception code within an address range. |
Table and gate hooks (Windows)
| Kind | Call | What it does |
|---|---|---|
| Delay-load IAT | hook_delay_iat | Walks IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT and hooks delay-loaded DLLs that hook_iat can't reach, forcing resolution if the import hasn't been called yet. |
| EAT | hook_eat | Patches an Export Address Table slot. Affects callers that resolve the symbol via GetProcAddress after the hook is installed. |
| Syscall gate | hook_syscall | Locates the syscall instruction inside an Nt* stub in ntdll and patches it with a call-site hook. Windows x64. |
| TLS callback | hook_tls_callback | Patches a slot in IMAGE_TLS_DIRECTORY. Fires before DllMain on thread attach and detach. |
Mid-function hooks
A mid-function hook gets the CPU state at a chosen instruction boundary, and can change it before the function continues. On x64 the MidContext holds RAX through R15 and RFLAGS; on ARM64 it holds x0–x28, FP, LR, SP, NZCV, FPCR, FPSR and all 32 NEON/SVE registers.
auto r = vh::mid_hook(game_update_fn,
[](vh::MidContext* ctx) noexcept {
player_health = static_cast<int>(ctx->rax);
},
{ .offset = 0x1C, .tag = "Game.HealthReadback" });
The offset is rounded up to the nearest instruction boundary — VanHooks never splits an instruction. The Zydis disassembler is compiled into the library for this.
Lifetime, groups and chains
A vh::Hook is move-only. It can be disabled and re-enabled, removed early, and asked whether it's installed and active. When it's destroyed, the original bytes go back. The object also exposes its kind, target, detour, trampoline address and tag for introspection.
A group owns several hooks and switches them in one thread-suspension window, which costs far less than switching each one separately. Hooks in a group can be found again by tag.
auto grp = vh::group("UI");
grp.add(vh::api_hook("user32", "MessageBoxW", &hk_mbw, &orig_mbw,
{ .tag = "User32.MessageBoxW" }))
.add(vh::api_hook("user32", "SetWindowTextW", &hk_swt, &orig_swt,
{ .tag = "User32.SetWindowTextW" }));
grp.disable(); // both at once
grp.at("User32.MessageBoxW").enable(); // just one
A second detour can be put in front of an existing hook. Calls then run through the new detour, the old one, and finally the real function. Links come off in the reverse order they went on.
auto base = vh::inline_hook(&fn, &detour1, &orig1).value();
auto link = base.chain(&detour2, &chain_orig).value();
link.remove();
base.remove();
When several DLLs in one process each install their own hooks, vh::HookRegistry::global() keeps their groups by name, and one remove_all() at shutdown clears every one of them.
Batch queue
Installing or removing hooks one at a time means one thread-suspension per hook. The batch queue amortises the cost: queue up any mix of enables, disables and removes, then flush them all in a single Toolhelp32 / ptrace / task_threads window.
auto& eng = vh::advanced::engine();
eng.queue_enable(h1);
eng.queue_enable(h2);
eng.queue_disable(h3);
eng.apply_queued(); // one suspension, all patches applied
Groups use this internally — calling grp.enable() queues every hook in the group and flushes once.
Introspection
Every hook can report its kind, target address, detour address, trampoline address, enabled state and tag. The engine itself can list every installed hook, describe one by handle, find a hook by its target address, and return a total count.
for (auto& desc : eng.list_hooks()) {
printf("[%s] %s → %p (enabled: %d)\n",
desc.tag.c_str(),
to_string(desc.kind),
desc.target,
desc.enabled);
}
Errors
Every fallible call returns vh::Result<T>, which is std::expected<T, vh::Error>. The error enum has named values grouped by category — general, memory, hook lifecycle, disassembly, module/symbol lookup, thread management, batch/chain, PE introspection, breakpoints and call-stack capture. vh::error_to_string turns any of them into a compile-time string view.
Compared with MinHook
| VanHooks | MinHook | |
|---|---|---|
| Platforms | Windows, Linux, macOS | Windows |
| Architectures | x86, x64, ARM64 | x86, x64 |
| Hook kinds | 17 (trampoline, call-site, IAT, delay-IAT, EAT, PLT, GOT, vtable, mid-function, return, thread-local, hardware breakpoint, page-guard, software breakpoint, exception, syscall gate, instrumentation callback, TLS callback) | 1 (inline) |
| Errors | std::expected | C status codes |
| RAII | Yes — hook removes itself on destruction | No — you call MH_RemoveHook |
| Batching | Groups and a queue, one thread-suspension for all | A queue |
| Chaining | Yes — linked detour list | No |
| Introspection | List, describe, find-by-target, count | No |
| Thread-local hooks | Yes — TLS-gated detour | No |
Shutdown and DLL usage
If VanHooks is loaded inside a DLL, call vh::shutdown() before returning from DllMain(DLL_PROCESS_DETACH). This removes all hooks and tears down the engine before static destructors run. Failure to do so is safe in simple cases but can cause use-after-free if a detour references a module-level object that is destroyed first. vh::shutdown() is idempotent.
BOOL WINAPI DllMain(HINSTANCE, DWORD reason, LPVOID) {
if (reason == DLL_PROCESS_DETACH)
vh::shutdown();
return TRUE;
}
Adding it to a project
Copy include/ into your project and link the prebuilt library for your target. With CMake:
if(CMAKE_SIZEOF_VOID_P EQUAL 8)
set(VH_LIB_DIR "${CMAKE_CURRENT_SOURCE_DIR}/lib/win-x64")
else()
set(VH_LIB_DIR "${CMAKE_CURRENT_SOURCE_DIR}/lib/win-x86")
endif()
add_library(VanHooks::vanhooks STATIC IMPORTED)
set_target_properties(VanHooks::vanhooks PROPERTIES
IMPORTED_LOCATION_RELEASE "${VH_LIB_DIR}/Release/vanhooks.lib"
IMPORTED_LOCATION_DEBUG "${VH_LIB_DIR}/Debug/vanhooks.lib"
INTERFACE_INCLUDE_DIRECTORIES "${CMAKE_CURRENT_SOURCE_DIR}/include")
target_link_libraries(my_target PRIVATE VanHooks::vanhooks)
Or add the full source tree with CMake's add_subdirectory:
set(VH_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(VH_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
add_subdirectory(../VanHooks ${CMAKE_BINARY_DIR}/vanhooks_build)
target_link_libraries(my_target PRIVATE vanhooks)
- A C++23 compiler: MSVC 19.38 or later (Visual Studio 2022 17.8), GCC 13, or Clang 17.
- Windows 10 1903 or Windows Server 2019 for Windows targets.
- CMake 3.25 if you use CMake. Dropping the headers and library in by hand works too.
- Link with
/MTfor Release and/MTdfor Debug to match the prebuilt libraries. No Visual C++ redistributable is needed. - Zydis v4.1.0 (fetched automatically by CMake via FetchContent).
