Instrumentation Callback 是 Windows 内核提供的一个未文档化功能,允许用户态程序拦截进程中所有 syscall 的返回。
它类似于函数出口 hook,但无需修改任何代码,内核会在 syscall 返回用户态时,自动跳转到预先注册的回调函数。
完整实现可参考:https://github.com/Deputation/instrumentation_callbacks
通过NtSetInformationProcess设置回调。每个进程只能注册一个回调,新注册会覆盖旧的:
struct PROCESS_INSTRUMENTATION_CALLBACK_INFORMATION {
#ifdef _WIN64
ULONG Version; // 0
ULONG Reserved;
#endif
PVOID Callback; // 回调函数地址(x86 只有这一个字段)
};
PROCESS_INSTRUMENTATION_CALLBACK_INFORMATION info = {0};
info.Callback = my_callback;
NtSetInformationProcess(
GetCurrentProcess(),
(PROCESS_INFORMATION_CLASS)0x28, // ProcessInstrumentationCallback
&info, sizeof(info)
);
注册后,内核会将回调地址保存到_KPROCESS.InstrumentationCallback:
0: kd> dt _KPROCESS
ntdll!_KPROCESS
+0x000 Header : _DISPATCHER_HEADER
...
+0x2c8 InstrumentationCallback : Ptr64 Void
当 syscall 返回时,内核不再返回到原地址,而是跳转到InstrumentationCallback,同时将原返回地址保存到R10(x64)或ECX(x86)。
回调结束后要还原线程上下文并跳回原返回地址,所以我们需要在回调入口处保存完整现场:
原返回地址(R10/ECX)
栈指针(RSP/ESP)
线程上下文(通过RtlCaptureContext)
这部分必须用汇编实现。可以将其封装为bridge函数,作为实际注册的回调入口:
; x64 实现
bridge PROC
; 保存原始信息到 TEB
mov gs:[2e0h], rsp ; TEB.InstrumentationCallbackPreviousSp
mov gs:[2d8h], r10 ; TEB.InstrumentationCallbackPreviousPc
sub rsp, 4d0h ; 分配 CONTEXT 结构空间
and rsp, -10h ; 16 字节对齐
mov rcx, rsp
call __imp_RtlCaptureContext ; 捕获上下文
sub rsp, 20h ; shadow space
call callback ; 调用 c++ 回调函数
int 3 ; 不应执行到此
bridge ENDP
// x86 实现
extern "C" __declspec(naked) static void bridge() {
__asm {
; 保存原始信息到 TEB
mov fs:[1b0h], ecx ; TEB.InstrumentationCallbackPreviousPc
mov fs:[1b4h], esp ; TEB.InstrumentationCallbackPreviousSp
sub esp, 2cch ; 分配 CONTEXT 结构空间
and esp, 0FFFFFFF0h ; 16 字节对齐
push esp
call [g_pfnRtlCaptureContext] ; 捕获上下文
mov [esp+0b4h], ebp ; 要修正捕获的 ebp !!!
push esp
call callback ; 调用 c++ 回调函数
add esp, 4 ; __cdecl 栈平衡,但 callback 内部跳走了,执行不到这里
int 3
}
}
TEB 中预留了两个相关字段,可用于临时存储原返回地址和栈指针:
// x64 (win10.0.26100.3476)
0:000> dt ntdll!_TEB
+0x000 NtTib : _NT_TIB
...
+0x2d8 InstrumentationCallbackPreviousPc : Uint8B
+0x2e0 InstrumentationCallbackPreviousSp : Uint8B
// x86 (win10.0.26100.3476)
0:000> dt ntdll!_TEB
+0x000 NtTib : _NT_TIB
...
+0x1b0 InstrumentationCallbackPreviousPc : Uint4B
+0x1b4 InstrumentationCallbackPreviousSp : Uint4B
注意:内核不会自动填充这些字段,需要我们在 bridge 中手动保存。
在 C++ 回调中,可以执行监控逻辑,最后通过RtlRestoreContext恢复上下文并返回原地址:
void callback(CONTEXT* ctx) {
auto teb = reinterpret_cast<uintptr_t>(NtCurrentTeb());
#ifdef _WIN64
// 从 TEB 恢复原始返回信息
ctx->Rip = *reinterpret_cast<uint64_t*>(teb + 0x2d8);
ctx->Rsp = *reinterpret_cast<uint64_t*>(teb + 0x2e0);
// 清理痕迹,恢复 TEB, Rcx 和 R10
*reinterpret_cast<uint64_t*>(teb + 0x02d8) = 0;
*reinterpret_cast<uint64_t*>(teb + 0x02e0) = 0;
ctx->Rcx = ctx->R10;
ctx->R10 = 0;
#else
// 从 TEB 恢复原始返回信息
ctx->Eip = *reinterpret_cast<uint32_t*>(teb + 0x1b0);
ctx->Esp = *reinterpret_cast<uint32_t*>(teb + 0x1b4);
// 清理痕迹,恢复 TEB, Rcx 和 R10
*reinterpret_cast<uint32_t*>(teb + 0x1b0) = 0;
*reinterpret_cast<uint32_t*>(teb + 0x1b4) = 0;
ctx->Ecx = 0;
#endif
// 防止递归:回调内部的 syscall 也会触发回调
if (instrumentation::is_thread_handling_syscall()) {
RtlRestoreContext(ctx, nullptr);
}
instrumentation::set_thread_handling_syscall(true);
// === 监控逻辑 ===
#ifdef _WIN64
auto return_address = reinterpret_cast<void*>(ctx->Rip);
auto return_value = ctx->Rax;
#else
auto return_address = reinterpret_cast<void*>(ctx->Eip);
auto return_value = ctx->Eax;
#endif
// 根据返回地址查找函数名
uint64_t offset;
auto func_name = syms::get_function_by_address(return_address, &offset);
printf("[rax=0x%llx] syscall returning to %s+0x%llx\n", return_value, func_name, offset);
// 重置标志,处理下一次 syscall
instrumentation::set_thread_handling_syscall(false);
// 恢复上下文,回到 syscall 真正的返回地址
RtlRestoreContext(ctx, nullptr);
}
注意:回调内部调用 API 也可能触发 syscall,导致无限递归。可以通过 TLS 标志位来防止重入。
判断 syscall 来源:通过原返回地址定位是哪个 Nt 函数
修改返回值:RAX/EAX中保存着 syscall 返回值(通常是NTSTATUS),可直接修改
读取/修改参数:
x86:所有参数在栈上,可完整获取
x64:前 4 个参数通过寄存器传递(RCX/RDX/R8/R9),可能已被覆盖;第 5 个参数起在栈上,可正常获取
对于输出参数(指针类型),可通过指针修改其指向的内容,控制 syscall 输出内容
监控所有 syscall :自动覆盖,无需维护 syscall 列表
获取/修改返回值:修改ctx->Rax
获取调用来源:ctx->Rip指向 Nt 函数内部
获取部分参数:x86 全部可获取;x64 第 5 个起可获取
修改输出参数:x86 全部可修改;x64 第 5 个起可修改
不能阻止调用:回调触发时 syscall 已经执行完毕
不能修改输入参数:同上
如上所述,回调中可能会将原返回地址和栈指针保存到 TEB 指定位置,可触发一次 syscall 后检查该位置是否为0。
也可以对比特定寄存器,比如R10/ECX保存了回调函数地址,可以判断其指向的代码位置。
不过这些痕迹都可以在回调中被抹去,不可靠。
利用"只能有一个回调"的特性,启动时注册自己的回调占位,并定期检查是否被覆盖。
Instrumentation Callback 的核心价值:零代码修改,全局 syscall 监控,控制 (x64下为有限控制) syscall 输出。
适用场景:
行为分析、沙箱监控
修改 syscall 结果,对抗安全检测
不适用场景:
需要阻止或修改调用参数的主动防护
需要监控非 syscall 函数(如普通 Win32 API)
性能敏感场景(每次 syscall 都有额外开销)
这是安全攻防中一个值得了解的技术点,既可用于防御监控,也可能被恶意利用。理解其原理,才能更好地运用或防范。