Hooking Every Kernel Export to Analyze a Driver's Logic
About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.
This research is published for educational and defensive purposes. The target sample is anonymized throughout.
TL;DR
- Kernel hook framework: https://github.com/smallzhong/kernelhook
- Using that framework to hook all of a driver’s exported functions, making driver analysis much easier: https://github.com/smallzhong/kernel_monitor
- Stars are welcome — your little star is a big encouragement!
Introduction
Three years ago I wrote a project for doing inline hooks in the kernel. My motivation was that there wasn’t a good framework for inline hooking in the Windows kernel. MiroKaku ported Detours to the kernel — https://github.com/MiroKaku/DetoursX — and that framework is quite nice, but Detours was originally designed for ring-3 hooking and apparently never accounted for the special cases of kernel hooking. As a result, when a 4-byte relative address shows up, it still repairs that 4-byte address using ring-3 logic — which no longer works, because the allocated memory is more than 2 GB away. You can see a failure hooking MmIsAddressValid in that project’s issue tracker — https://github.com/MiroKaku/DetoursX/issues/2 — caused by an E8 call whose relative address DetoursX failed to repair. Here’s the situation before hooking:

After hooking, that E8 call was not correctly repaired, so it jumped to a nonexistent address.
In fact, when relative addressing is involved, you can’t simply repair the 4-byte relative address. Because the kernel is a vast address space, 4 bytes can only address 4 GB of memory, and the kernel can’t allocate a trampoline that close. To solve this, I wrote a hook framework that special-cases relative addressing so every relative address correctly finds its target.
Update 2025-06-03: this issue has since been fixed by MiroKaku in https://github.com/MiroKaku/DetoursX/commit/b2466f0ee27fd6c1ec8b94ba6c90b9d27ba4b17b , using the
48 b8 IMM64 (MOV RAX, IMM64)+FFD0 (CALL RAX)approach. Feel free to use DetoursX directly for hooking now.
The three-year-old version was just a simple framework; over time I adapted it to many special cases, fixed existing bugs, and gradually improved its robustness.
KernelHook
The hook flow when a function has no relative-addressing instructions

As shown, suppose the unhooked code is the yellow blocks. The code order is A B C D E, and suppose A B C together are longer than 14 bytes, enough to fit the jump ff 25 00 00 00 00 00 00 00 00 00 00 00 00. The framework automatically computes the length of these three instructions and replaces them with an ff25 jmp to a block of memory it allocated. After the jump, it first saves the environment, pushing all registers onto the stack. Then it calls a C callback function, where you can perform your operations. If the callback returns FALSE, it jumps back to the original function and continues executing; if it returns TRUE, it returns directly without running the original function. If the original function needs to run, it pops all the previously saved registers, executes the A B C instructions, and finally uses an ff25 jmp to jump to the next line of the original function (D in this example).
When the code contains relative-addressing jumps
The framework adapts many relative-jump patterns: 7X XX, E1 xx, E2 xx, E3 xx, EB xx (one-byte short jumps), 0F 8X XX XX XX XX (4-byte relative jumps), E8 E9 (4-byte short jumps and calls), etc. These cases are all handled as shown below.
Suppose that among the three instructions A B C, only B uses a relative jump. Take the 74 XX JE jump as an example.

As shown, the framework automatically computes the absolute destination of the jump and generates an FF25 unconditional jump at the end of the shellcode. It then rewrites the 74 jump’s target to the relative address of this FF25 instruction. That way, if the JE condition holds, it jumps to the FF25, which then jumps to the original absolute address — preserving correct logic. The other 1-byte and 4-byte relative-addressing jumps are corrected with the same idea.
When the code contains test/lea/mov that use 4-byte relative addressing
This case is more complex, because these instructions can’t simply use FF25 as a trampoline back to the true absolute address like jump instructions can. And these instructions have no absolute-addressing variant, so they can’t be specially rewritten into one either. The framework handles it like this: it 16-byte-align searches for an all-zero region within the hooked module, then copies the relative-addressing code there. Because that address is inside the module, the relative addressing still works. The logic:

Special handling for 48 8d 05 LEA

The kernel is full of 48 8D 05 relative-addressing code, mainly in the Zw* functions. If every function used the “find a blank region inside the module” approach, the module’s blank regions would be exhausted and hooking would become impossible. So I special-case this: rewrite the LEA into a 48 B8 MOV RAX, IMM64.
As shown below:

Hooking most exported functions for detailed kernel monitoring
Special-case handling
With the hook framework above, you can consider hooking some kernel functions to analyze a specific driver’s system-call flow. First you need to handle one special case where a function can’t be directly hooked: when other code jumps into the middle of the instructions you’d overwrite at the function’s start. That’s a bit of a mouthful, but an example makes it clear.
Take RtlUnalignedStringCchLengthW:

At the 12th byte from the start there’s a basic block, and other code jumps to this block. In this case, if you directly patch in ff25 (14 bytes), you’d overwrite that location — and a later jnz could still jump to that address, landing in corrupted memory with unpredictable consequences.
The solution is to write an IDA script that special-cases this: if the first 14 bytes of a function contain a basic block that other blocks jump into, mark it False and skip the function. The check:
def has_xrefs_to_middle(start_ea, end_ea):
instr_size = idc.get_item_size(start_ea)
start_ea += instr_size
while start_ea < end_ea:
t = idautils.CodeRefsTo(start_ea, False)
for i in t:
return True
instr_size = idc.get_item_size(start_ea)
start_ea += instr_size
return False
Monitoring call addresses
Kernel exported functions are called constantly — drivers call them all the time. Recording everything is unrealistic and would freeze the system. So the framework filters call sources by return address, only printing calls that come from specific sources. The monitoring framework wraps a singleton that maintains the set of monitored addresses. These three macros:
#define ADD_MONITOR_RANGE(start, end) smallzhong::MonitorAddressManager::GetInstance().AddMonitorRange((start), (end))
#define DEL_FROM_MONITOR_LIST(addr) smallzhong::MonitorAddressManager::GetInstance().DelFromMonitorList((addr))
#define FILTER_RET_ADDR(ret_addr) smallzhong::MonitorAddressManager::GetInstance().FilterRetAddr((ret_addr))
add and delete monitored ranges, and check whether an address is currently monitored.
Here’s a handler auto-generated by the IDA script. It uses FILTER_RET_ADDR to check the call source — only calls whose return address is from a specific source get their four arguments and return address logged:
BOOLEAN handler_c4a77d9f(PGuestContext context)
{
ULONG64 origin_ret_addr = *(PULONG64)(context->mRsp);
if (FILTER_RET_ADDR(origin_ret_addr))
{
LOG_INFO("Function: ExAllocatePoolWithTag\nRCX: %llx, RDX: %llx, R8: %llx, R9: %llx\nReturn Address: %llx\n\n",
context->mRcx, context->mRdx, context->mR8, context->mR9, origin_ret_addr);
}
return FALSE;
}
Usage flow
- Import MiroKaku’s packaged
Musa.Runtimevia NuGet and happily write kernel C++ code. - Open the driver to monitor in IDA, open
scripts\AutoGen.py, globally (Ctrl+H) change the hardcoded save path, then runAutoGen.pyto get three auto-generated files —available_funcs.inc,handlers.h,handlers.c— and import them into the VS project. - In
DriverMain, hook the functions you’re interested in. - Load the driver and read the logs.
Analyzing a driver with this framework, using the 2025 Tencent Game Security final challenge as an example
The final challenge download: https://gss.tencent.com/competition/2025/race-final.htm
Setting up the analysis environment
Set an ImageCallback in DriverMain:
EXTERN_C NTSTATUS DriverMain(const PDRIVER_OBJECT DriverObject, const PUNICODE_STRING Registry)
{
LOG_INFO("entry\r\n");
NTSTATUS status = STATUS_SUCCESS;
status = PsSetLoadImageNotifyRoutine(ImageLoadCallback);
...
}
In the callback, check whether ACEDriver.sys was loaded, and if so, add the corresponding monitor range:
VOID ImageLoadCallback(
PUNICODE_STRING FullImageName,
HANDLE ProcessId,
PIMAGE_INFO ImageInfo)
{
if (ProcessId == 0 && FullImageName != NULL)
{
// check whether ACEDriver.sys is loaded
if (wcsstr(FullImageName->Buffer, L"\\ACEDriver.sys"))
{
LOG_INFO("ACEDriver.sys" " has been loaded!\n");
LOG_INFO("Image Base: %p\n", ImageInfo->ImageBase);
LOG_INFO("Image Size: %llx\n", ImageInfo->ImageSize);
ADD_MONITOR_RANGE((ULONG64)ImageInfo->ImageBase, (ULONG64)ImageInfo->ImageBase + ImageInfo->ImageSize);
}
}
}
Boot the VM and load this analysis framework.

It successfully auto-hooks all hookable exported functions.
Loading ACEDriver.sys
After loading, the full log looks like this (excerpt — the full log is in the repo):
[smallzhong][ImageLoadCallback():22] ACEDriver.sys has been loaded!
[smallzhong][ImageLoadCallback():23] Image Base: FFFFF8044C9C0000
[smallzhong][ImageLoadCallback():24] Image Size: 12ce000
Function: ExAllocatePool
RCX: 200, RDX: 1a0, R8: fffff80445f331f0, R9: ffffbc84b35e7768
Return Address: fffff8044d9eac91
Function: NtQuerySystemInformation
RCX: b, RDX: ffffbc84b35e7650, R8: 0, R9: ffffbc84b35e7648
Return Address: fffff8044d7b062a
...
Function: KeDelayExecutionThread
RCX: 0, RDX: 0, R8: ffffbc84b35e7808, R9: 2f
Return Address: fffff8044d1209b1
Function: KdRefreshDebuggerNotPresent
RCX: fffff80446796028, RDX: 3bd, R8: fffff80446796000, R9: 188b5e66ecc8b28
Return Address: fffff8044d121634
KDTARGET: Refreshing KD connection
Function: ZwQuerySystemInformation
...
*** Fatal System Error: 0x00414345
(0x0000000000000000,0x0000000000000000,0x0000000000000000,0x0000000000000000)
Break instruction exception - code 80000003 (first chance)
You can see a KDTARGET: Refreshing KD connection message partway through. Looking up, KdRefreshDebuggerNotPresent was called — the driver uses this function for anti-debugging. Its return address is fffff8044d121634, and the image base is FFFFF8044C9C0000, so the call site is ACEDriver.sys + 0x761634. Jumping into that location in the dump shows it’s a thread started by ExQueueWorkItem, which you can also see from the ExQueueWorkItem call in the log. The final conclusion: you need to hook KdDisableDebugger and KdRefreshDebuggerNotPresent, and set their return values manually. Implementation:
if (func.function_name == "KdDisableDebugger")
{
auto lambda = [](GuestContext* context) -> BOOLEAN {
ULONG64 origin_ret_addr = *(PULONG64)(context->mRsp);
if (FILTER_RET_ADDR(origin_ret_addr))
{
LOG_INFO("Function: KdDisableDebugger\nRCX: %llx, RDX: %llx, R8: %llx, R9: %llx\nReturn Address: %llx\n\n",
context->mRcx, context->mRdx, context->mR8, context->mR9, origin_ret_addr);
}
context->mRax = STATUS_SUCCESS;
return TRUE;
};
try {
GLOBAL_HOOK_MANAGER.add_hook(func.address, reinterpret_cast<ULONG64>(+lambda));
LOG_INFO("Successfully hooked %s at %llx\r\n", func.function_name.c_str(), func.address);
}
catch (const std::exception& e) {
LOG_INFO("Failed to hook %s: %s\r\n", func.function_name.c_str(), e.what());
}
}
else if (func.function_name == "KdRefreshDebuggerNotPresent")
{
//DbgBreakPoint();
auto lambda = [](GuestContext* context) -> BOOLEAN {
ULONG64 origin_ret_addr = *(PULONG64)(context->mRsp);
if (FILTER_RET_ADDR(origin_ret_addr))
{
LOG_INFO("Function: KdRefreshDebuggerNotPresent\nRCX: %llx, RDX: %llx, R8: %llx, R9: %llx\nReturn Address: %llx\n\n",
context->mRcx, context->mRdx, context->mR8, context->mR9, origin_ret_addr);
}
context->mRax = 1;
return TRUE;
};
try {
GLOBAL_HOOK_MANAGER.add_hook(func.address, reinterpret_cast<ULONG64>(+lambda));
LOG_INFO("Successfully hooked %s at %llx\r\n", func.function_name.c_str(), func.address);
}
catch (const std::exception& e) {
LOG_INFO("Failed to hook %s: %s\r\n", func.function_name.c_str(), e.what());
}
}
Inside the handler we directly modify the mRax return value and return TRUE. In this framework, return TRUE means “don’t call the original function — set the registers and return directly”. So calling KdDisableDebugger directly returns STATUS_SUCCESS without calling the original, and calling KdRefreshDebuggerNotPresent returns 1 — the value that says “no debugger attached”. After adding hooks for these two functions, reload the driver.
Loading ACEDriver.sys again after hooking
After adding the hooks above and reloading, it no longer blue-screens. The full log (excerpt — the full log is in the repo):
[smallzhong][ImageLoadCallback():22] ACEDriver.sys has been loaded!
[smallzhong][ImageLoadCallback():23] Image Base: FFFFF8044CFA0000
[smallzhong][ImageLoadCallback():24] Image Size: 12ce000
Function: ExAllocatePool
RCX: 200, RDX: 1a0, R8: fffff80445f331f0, R9: ffffbc84b35d2768
Return Address: fffff8044dfcac91
...
[smallzhong][DriverMain::<lambda_2>::operator ()():111] Function: KdRefreshDebuggerNotPresent
RCX: fffff80446796028, RDX: 3bd, R8: fffff80446796000, R9: 188b5e66ecc8b28
Return Address: fffff8044d701634
...
[smallzhong][DriverMain::<lambda_1>::operator ()():87] Function: KdDisableDebugger
RCX: fffff80446796028, RDX: 3b4, R8: fffff80446796000, R9: abb86a985761b61
Return Address: fffff8044d7018fd
...
Function: ExQueueWorkItem
RCX: ffffd38420034940, RDX: 1, R8: ffffd38420034940, R9: 0
Return Address: fffff8044d701c02
Function: KeDelayExecutionThread
...
You can see that two work items call each other back and forth. The later part is probably the hypervisor’s EPT-related information being requested and stored — and you can also work backward from these functions’ call sites to the key functions. I’ll stop the analysis here, because once you find the key-algorithm location, you’re into the dizzying part of reverse engineering. This framework only helps you rapidly locate the key logic points of a whole driver; the actual reversing still has to be done by hand, tracing back through the printed return addresses.
Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).
Get in touch