Windows Syscall 监控:Instrumentation Callback 技术详解
Windows内核提供Instrumentation Callback功能,允许用户态程序拦截进程中所有syscall的返回。该功能通过注册回调函数实现,在 syscall 返回时自动跳转到预先注册的回调函数。回调函数可监控 syscall 的返回地址、返回值及部分参数,并可修改输出结果。该技术适用于行为分析和沙箱监控等场景,但无法阻止 syscall 执行或修改输入参数。 2025-12-31 02:22:42 Author: www.freebuf.com(查看原文) 阅读量:1 收藏

一、什么是 Instrumentation Callback

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)。

1. 保存现场(bridge)

回调结束后要还原线程上下文并跳回原返回地址,所以我们需要在回调入口处保存完整现场:

  • 原返回地址(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 中手动保存。

2. 回调逻辑(callback)

在 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 标志位来防止重入。

三、回调中能做什么

  1. 判断 syscall 来源:通过原返回地址定位是哪个 Nt 函数

  2. 修改返回值RAX/EAX中保存着 syscall 返回值(通常是NTSTATUS),可直接修改

  3. 读取/修改参数

    • x86:所有参数在栈上,可完整获取

    • x64:前 4 个参数通过寄存器传递(RCX/RDX/R8/R9),可能已被覆盖;第 5 个参数起在栈上,可正常获取

    • 对于输出参数(指针类型),可通过指针修改其指向的内容,控制 syscall 输出内容

四、能力与限制

能做什么

  • 监控所有 syscall :自动覆盖,无需维护 syscall 列表

  • 获取/修改返回值:修改ctx->Rax

  • 获取调用来源:ctx->Rip指向 Nt 函数内部

  • 获取部分参数:x86 全部可获取;x64 第 5 个起可获取

  • 修改输出参数:x86 全部可修改;x64 第 5 个起可修改

不能做什么

  • 不能阻止调用:回调触发时 syscall 已经执行完毕

  • 不能修改输入参数:同上

五、检测方法

1. 检查 TEB,寄存器等痕迹

如上所述,回调中可能会将原返回地址和栈指针保存到 TEB 指定位置,可触发一次 syscall 后检查该位置是否为0。

也可以对比特定寄存器,比如R10/ECX保存了回调函数地址,可以判断其指向的代码位置。

不过这些痕迹都可以在回调中被抹去,不可靠。

2. 提前占位

利用"只能有一个回调"的特性,启动时注册自己的回调占位,并定期检查是否被覆盖。

六、总结

Instrumentation Callback 的核心价值:零代码修改,全局 syscall 监控,控制 (x64下为有限控制) syscall 输出

适用场景

  • 行为分析、沙箱监控

  • 修改 syscall 结果,对抗安全检测

不适用场景

  • 需要阻止或修改调用参数的主动防护

  • 需要监控非 syscall 函数(如普通 Win32 API)

  • 性能敏感场景(每次 syscall 都有额外开销)

这是安全攻防中一个值得了解的技术点,既可用于防御监控,也可能被恶意利用。理解其原理,才能更好地运用或防范。


文章来源: https://www.freebuf.com/articles/game/464503.html
如有侵权请联系:admin#unsafe.sh