阅读时间: 5-6 分钟
前置知识: 理解LDR模块断链原理、Windows内存管理基础
学习目标: 掌握内存映射文件检测技术,实现交叉验证防御系统


📺 配套视频教程

本文配套视频教程已发布在 B 站,建议结合视频学习效果更佳:

💡 提示: 点击视频右下角可全屏观看,建议配合文章食用!

视频链接: https://www.bilibili.com/video/BV1J1CQBdEZR/


承接上节:攻击者的精心布局有个漏洞

上节课,攻击者通过 LDR 模块断链 成功绕过了模块枚举检测:

  • ✅ 绕过点1:从三个LDR链表中移除了DLL节点
  • ✅ 绕过点2:EnumProcessModules() 无法枚举到这个DLL
  • ✅ 绕过点3:白名单检测和签名验证彻底失效

然而,这种绕过方式有个天然的盲点:

虽然DLL从链表中”消失”了,但它的文件映射仍然存在于进程的虚拟内存中。就像员工从花名册上被除名了,但每天还在办公室上班。

什么是文件映射?

当Windows加载一个DLL时,不是把整个文件复制到内存,而是创建一个”映射关系”:把磁盘上的文件映射到进程的虚拟地址空间。这样多个进程可以共享同一个DLL文件,节省内存。

这意味着什么?

即使DLL从LDR链表断链了,Windows内核仍然维护着这个映射关系,而我们可以通过内存扫描并与LDR链表进行比对,找出被隐藏的DLL。


核心原理:通过API实现交叉验证

具体实现需要通过三个API配合完成”双重检测”:

① 获取”官方名单”(基于LDR链表)
EnumProcessModules() 枚举模块,这份名单基于LDR链表,可能被攻击者篡改。

② 扫描内存映射(基于内核维护信息)
VirtualQueryEx() 扫描所有内存页面,找出类型为 MEM_IMAGE 的映射文件,再用 NtQueryVirtualMemory() 查出具体是哪个文件。

③ 交叉验证(找出差异)
对比两份数据源:在内存映射中存在,但在模块枚举中缺失的——就是被隐藏的DLL。


动手实现

主函数设计

int main()
{
    DWORD currentPid = GetCurrentProcessId();
    HANDLE hProcess = GetCurrentProcess();

    std::cout << "08-防御篇-内存映射文件检测n";
    std::cout << "检测原理:交叉验证 EnumProcessModules 与内存映射信息n";
    std::cout << "检测目标:发现被LDR断链隐藏的DLL模块nn";

    // 循环检测,每秒刷新一次
    while (true)
    {
        DetectUnlinkedModules(hProcess, currentPid);
        Sleep(1000);
        system("cls");
    }

    return 0;
}

实现说明

  • 使用 GetCurrentProcess() 检测当前进程(便于演示)
  • 实际应用中可改为 OpenProcess() 检测其他进程
  • 循环检测机制可实时监控异常注入行为

接下来就实现核心的 DetectUnlinkedModules 函数,它会调用两个关键的辅助函数来完成检测任务。


核心检测函数 DetectUnlinkedModules

DetectUnlinkedModules 是我们的核心检测函数,它负责协调整个检测流程。这里我们先展示函数框架,具体的两个辅助函数 GetModulesByEnumerationGetModulesByMemoryMapping 的实现会在后面详细讲解。

/**
 * @brief 执行交叉验证检测:对比EnumProcessModules与内存映射结果
 * @param hProcess 目标进程句柄
 * @param processId 进程ID(用于显示)
 */
void DetectUnlinkedModules(HANDLE hProcess, DWORD processId)
{
    std::cout << "=== LDR断链检测器(交叉验证版)===n";
    std::cout << "目标进程ID:" << processId << "n";
    std::cout << "正在执行双重检测...nn";

    // 第一步:获取枚举模块列表(自定义封装函数,后面详讲)
    std::set<std::string> enumeratedModules;
    if (!GetModulesByEnumeration(hProcess, enumeratedModules))
    {
        std::cout << "[错误] EnumProcessModules失败n";
        return;
    }
    std::cout << "[检测1] EnumProcessModules枚举到 " << enumeratedModules.size() << " 个模块n";

    // 第二步:获取内存映射列表(自定义封装函数,后面详讲)
    std::set<std::string> mappedModules;
    if (!GetModulesByMemoryMapping(hProcess, mappedModules))
    {
        std::cout << "[错误] 内存映射扫描失败n";
        return;
    }
    std::cout << "[检测2] 内存映射发现 " << mappedModules.size() << " 个映射文件nn";

    // 第三步:找出差异(在内存映射中但不在枚举列表中)
    std::vector<std::string> hiddenModules;
    for (const auto& mappedModule : mappedModules)
    {
        if (enumeratedModules.find(mappedModule) == enumeratedModules.end())
        {
            hiddenModules.push_back(mappedModule);
        }
    }

    // 第四步:输出检测结果
    std::cout << "=== 交叉验证结果 ===n";
    if (hiddenModules.empty())
    {
        std::cout << "[安全] 未检测到断链模块,进程安全。n";
    }
    else
    {
        std::cout << "[警告] 检测到 " << hiddenModules.size() << " 个断链模块!nn";
        for (const auto& module : hiddenModules)
        {
            std::cout << "  [隐藏模块] " << module << "n";
            std::cout << "    - 内存中存在映射文件n";
            std::cout << "    - EnumProcessModules中未找到n";
            std::cout << "    结论:可能遭受LDR断链攻击!nn";
        }
    }

    std::cout << "1秒后自动重新检测,Ctrl+C退出程序...n";
}

函数设计思路

  • 这里调用的 GetModulesByEnumerationGetModulesByMemoryMapping 都是我们自己封装的辅助函数
  • 这种封装设计让主逻辑更清晰,每个辅助函数专注完成一个具体任务
  • 后面我们会详细讲解这两个辅助函数的具体实现

验证逻辑

  • 分别获取模块枚举列表和内存映射列表
  • 对比两份数据源,识别在内存映射中存在但模块枚举中缺失的DLL
  • 生成详细的检测报告,标明异常模块及相关证据
  • 支持循环检测模式,实时监控注入行为

获取模块枚举列表(GetModulesByEnumeration)

使用 EnumProcessModules() 获取进程模块列表,此列表基于LDR链表,可能被断链攻击篡改。

原理解析EnumProcessModules 遍历的是LDR链表,攻击者可以通过断链操作从这个列表中”抹除”DLL的存在。

/**
 * @brief 通过EnumProcessModules获取模块列表(可能被篡改)
 * @param hProcess 目标进程句柄
 * @param moduleSet 输出:模块文件名集合(小写格式)
 * @return 成功返回TRUE
 */
BOOL GetModulesByEnumeration(HANDLE hProcess, std::set<std::string>& moduleSet)
{
    HMODULE hModules[1024];
    DWORD cbNeeded = 0;

    // 枚举所有模块句柄
    if (!EnumProcessModules(hProcess, hModules, sizeof(hModules), &cbNeeded))
        return FALSE;

    DWORD moduleCount = cbNeeded / sizeof(HMODULE);

    // 提取每个模块的文件名
    for (DWORD i = 0; i < moduleCount; i++)
    {
        char modulePath[MAX_PATH];
        if (GetModuleFileNameExA(hProcess, hModules[i], modulePath, MAX_PATH))
        {
            std::string fileName = ExtractFileName(modulePath);  // 提取文件名
            std::transform(fileName.begin(), fileName.end(), fileName.begin(), ::tolower);
            moduleSet.insert(fileName);
        }
    }

    return TRUE;
}

技术要点

  • 数据来源:LDR链表(可能被断链攻击篡改)
  • 统一转换为小写格式,便于后续比较
  • 仅提取文件名,忽略完整路径以减少干扰项

扫描内存映射文件(GetModulesByMemoryMapping)- 分步骤实现

在开始实现核心函数之前,我们需要先定义必要的类型和函数指针,因为NtQueryVirtualMemory是未文档化的NTAPI。

类型定义准备

/**
 * @brief MEMORY_INFORMATION_CLASS枚举(扩展)
 */
typedef enum _MEMORY_INFORMATION_CLASS_EX
{
    MemoryBasicInformation = 0,
    MemoryWorkingSetInformation = 1,
    MemoryMappedFilenameInformation = 2,  // 我们需要的信息类型
    MemoryRegionInformation = 3,
    MemoryWorkingSetExInformation = 4
} MEMORY_INFORMATION_CLASS_EX;

/**
 * @brief UNICODE_STRING结构体(如果未定义)
 */
typedef struct _UNICODE_STRING_EX
{
    USHORT Length;
    USHORT MaximumLength;
    PWSTR Buffer;
} UNICODE_STRING_EX, *PUNICODE_STRING_EX;

/**
 * @brief NtQueryVirtualMemory函数原型
 */
typedef NTSTATUS(NTAPI *pNtQueryVirtualMemory)(
    HANDLE ProcessHandle,
    PVOID BaseAddress,
    MEMORY_INFORMATION_CLASS_EX MemoryInformationClass,
    PVOID MemoryInformation,
    SIZE_T MemoryInformationLength,
    PSIZE_T ReturnLength
);

技术说明

  • MemoryMappedFilenameInformation(值为2)是我们需要的信息类型,用于获取映射文件名
  • UNICODE_STRING_EX是Windows内核常用的字符串结构,包含长度和缓冲区指针
  • pNtQueryVirtualMemory是函数指针类型,用于动态获取未文档化的NTAPI函数

完整函数代码

将以上步骤组合起来,就是完整的GetModulesByMemoryMapping函数:

/**
 * @brief 通过VirtualQueryEx和NtQueryVirtualMemory扫描内存映射文件
 * @param hProcess 目标进程句柄
 * @param mappedModuleSet 输出:内存映射的模块文件名集合
 * @return 成功返回TRUE
 */
BOOL GetModulesByMemoryMapping(HANDLE hProcess, std::set<std::string>& mappedModuleSet)
{
    // 第一步:动态获取NtQueryVirtualMemory函数地址
    static pNtQueryVirtualMemory NtQueryVirtMem = nullptr;
    if (!NtQueryVirtMem)
    {
        HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
        NtQueryVirtMem = (pNtQueryVirtualMemory)GetProcAddress(hNtdll, "NtQueryVirtualMemory");
        if (!NtQueryVirtMem) return FALSE;
    }

    // 第二步:确定扫描范围
    SYSTEM_INFO sysInfo;
    GetSystemInfo(&sysInfo);
    PVOID currentAddress = sysInfo.lpMinimumApplicationAddress;
    PVOID maxAddress = sysInfo.lpMaximumApplicationAddress;

    // 第三步:遍历内存区域
    while (currentAddress < maxAddress)
    {
        MEMORY_BASIC_INFORMATION mbi;

        // 查询当前内存区域信息
        if (VirtualQueryEx(hProcess, currentAddress, &mbi, sizeof(mbi)) == 0)
            break;

        // 第四步:查询映射文件名
        if (mbi.Type == MEM_IMAGE && mbi.State == MEM_COMMIT)
        {
            BYTE buffer[1024];
            SIZE_T returnLength = 0;

            // 查询映射文件名
            NTSTATUS status = NtQueryVirtMem(
                hProcess,
                currentAddress,
                MemoryMappedFilenameInformation,
                buffer,
                sizeof(buffer),
                &returnLength
            );

            if (status == 0 && returnLength > 0)
            {
                PUNICODE_STRING_EX unicodeString = (PUNICODE_STRING_EX)buffer;

                if (unicodeString->Length > 0 && unicodeString->Buffer)
                {
                    // 转换宽字符路径为窄字符
                    std::wstring widePath(unicodeString->Buffer, unicodeString->Length / sizeof(WCHAR));
                    std::string fullPath = WideStringToString(widePath);
                    std::string fileName = ExtractFileName(fullPath);

                    // 转换为小写格式
                    std::transform(fileName.begin(), fileName.end(), fileName.begin(), ::tolower);

                    // 仅保留DLL和EXE文件
                    if (fileName.find(".dll") != std::string::npos || fileName.find(".exe") != std::string::npos)
                    {
                        mappedModuleSet.insert(fileName);
                    }
                }
            }
        }

        // 移动到下一个内存区域
        currentAddress = (PVOID)((ULONG_PTR)currentAddress + mbi.RegionSize);
    }

    return TRUE;
}

实现要点总结

  • 分步骤设计:函数分为4个清晰的步骤,逻辑明确
  • 错误处理:每步都有错误检查,确保程序稳定性
  • 性能优化:使用静态变量缓存函数地址,避免重复获取
  • 数据过滤:只关注DLL/EXE文件,减少后续处理负担

辅助函数:路径提取和字符串转换

/**
 * @brief 从完整路径中提取文件名
 * @param fullPath 完整路径
 * @return 文件名
 */
std::string ExtractFileName(const std::string& fullPath)
{
    size_t lastSlash = fullPath.find_last_of("\");
    if (lastSlash != std::string::npos)
    {
        return fullPath.substr(lastSlash + 1);
    }
    return fullPath;
}

/**
 * @brief 将宽字符字符串转换为多字节字符串
 * @param wstr 宽字符字符串
 * @return 多字节字符串
 */
std::string WideStringToString(const std::wstring& wstr)
{
    if (wstr.empty())
    {
        return std::string();
    }
    int size_needed = WideCharToMultiByte(CP_ACP, 0, wstr.c_str(), (int)wstr.length(), NULL, 0, NULL, NULL);
    std::string result(size_needed, 0);
    WideCharToMultiByte(CP_ACP, 0, wstr.c_str(), (int)wstr.length(), &result[0], size_needed, NULL, NULL);
    return result;
}

完整代码实现

以下是完整的实现代码,包含了详细的分步注释:

#include <windows.h>
#include <winternl.h>
#include <psapi.h>
#include <iostream>
#include <vector>
#include <string>
#include <set>
#include <algorithm>

#pragma comment(lib, "ntdll.lib")
#pragma comment(lib, "psapi.lib")

/**
 * @file 08-防御篇-内存映射文件检测.cpp
 * @brief 通过交叉验证EnumProcessModules和MemoryMappedFilenameInformation检测LDR断链
 * @details 检测原理:断链后的DLL在内存中仍有映射,但不出现在模块枚举列表中
 */

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

// 类型定义与API声明
typedef enum _MEMORY_INFORMATION_CLASS_EX
{
    MemoryBasicInformation = 0,
    MemoryWorkingSetInformation = 1,
    MemoryMappedFilenameInformation = 2,
    MemoryRegionInformation = 3,
    MemoryWorkingSetExInformation = 4
} MEMORY_INFORMATION_CLASS_EX;

typedef struct _UNICODE_STRING_EX
{
    USHORT Length;
    USHORT MaximumLength;
    PWSTR Buffer;
} UNICODE_STRING_EX, *PUNICODE_STRING_EX;

typedef NTSTATUS(NTAPI *pNtQueryVirtualMemory)(
    HANDLE ProcessHandle,
    PVOID BaseAddress,
    MEMORY_INFORMATION_CLASS_EX MemoryInformationClass,
    PVOID MemoryInformation,
    SIZE_T MemoryInformationLength,
    PSIZE_T ReturnLength
);

// 辅助函数:路径提取
std::string ExtractFileName(const std::string& fullPath)
{
    size_t lastSlash = fullPath.find_last_of("\");
    if (lastSlash != std::string::npos)
    {
        return fullPath.substr(lastSlash + 1);
    }
    return fullPath;
}

std::string WideStringToString(const std::wstring& wstr)
{
    if (wstr.empty()) return std::string();
    int size_needed = WideCharToMultiByte(CP_ACP, 0, wstr.c_str(), (int)wstr.length(), NULL, 0, NULL, NULL);
    std::string result(size_needed, 0);
    WideCharToMultiByte(CP_ACP, 0, wstr.c_str(), (int)wstr.length(), &result[0], size_needed, NULL, NULL);
    return result;
}

// 核心检测函数
BOOL GetModulesByEnumeration(HANDLE hProcess, std::set<std::string>& moduleSet)
{
    HMODULE hModules[1024];
    DWORD cbNeeded = 0;
    if (!EnumProcessModules(hProcess, hModules, sizeof(hModules), &cbNeeded))
        return FALSE;

    DWORD moduleCount = cbNeeded / sizeof(HMODULE);
    for (DWORD i = 0; i < moduleCount; i++)
    {
        char modulePath[MAX_PATH];
        if (GetModuleFileNameExA(hProcess, hModules[i], modulePath, MAX_PATH))
        {
            std::string fileName = ExtractFileName(modulePath);
            std::transform(fileName.begin(), fileName.end(), fileName.begin(), ::tolower);
            moduleSet.insert(fileName);
        }
    }
    return TRUE;
}

BOOL GetModulesByMemoryMapping(HANDLE hProcess, std::set<std::string>& mappedModuleSet)
{
    static pNtQueryVirtualMemory NtQueryVirtMem = nullptr;
    if (!NtQueryVirtMem)
    {
        HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
        NtQueryVirtMem = (pNtQueryVirtualMemory)GetProcAddress(hNtdll, "NtQueryVirtualMemory");
        if (!NtQueryVirtMem) return FALSE;
    }

    SYSTEM_INFO sysInfo;
    GetSystemInfo(&sysInfo);
    PVOID currentAddress = sysInfo.lpMinimumApplicationAddress;
    PVOID maxAddress = sysInfo.lpMaximumApplicationAddress;

    while (currentAddress < maxAddress)
    {
        MEMORY_BASIC_INFORMATION mbi;
        if (VirtualQueryEx(hProcess, currentAddress, &mbi, sizeof(mbi)) == 0)
            break;

        if (mbi.Type == MEM_IMAGE && mbi.State == MEM_COMMIT)
        {
            BYTE buffer[1024];
            SIZE_T returnLength = 0;
            NTSTATUS status = NtQueryVirtMem(
                hProcess,
                currentAddress,
                MemoryMappedFilenameInformation,
                buffer,
                sizeof(buffer),
                &returnLength
            );

            if (status == 0 && returnLength > 0)
            {
                PUNICODE_STRING_EX unicodeString = (PUNICODE_STRING_EX)buffer;
                if (unicodeString->Length > 0 && unicodeString->Buffer)
                {
                    std::wstring widePath(unicodeString->Buffer, unicodeString->Length / sizeof(WCHAR));
                    std::string fullPath = WideStringToString(widePath);
                    std::string fileName = ExtractFileName(fullPath);
                    std::transform(fileName.begin(), fileName.end(), fileName.begin(), ::tolower);

                    if (fileName.find(".dll") != std::string::npos || fileName.find(".exe") != std::string::npos)
                    {
                        mappedModuleSet.insert(fileName);
                    }
                }
            }
        }
        currentAddress = (PVOID)((ULONG_PTR)currentAddress + mbi.RegionSize);
    }
    return TRUE;
}

void DetectUnlinkedModules(HANDLE hProcess, DWORD processId)
{
    std::cout << "=== LDR断链检测器(交叉验证版)===rn";
    std::cout << "目标进程ID: " << processId << "rn";
    std::cout << "正在执行双重检测...rnrn";

    std::set<std::string> enumeratedModules;
    if (!GetModulesByEnumeration(hProcess, enumeratedModules))
    {
        std::cout << "[错误] EnumProcessModules 失败rn";
        return;
    }
    std::cout << "[检测1] EnumProcessModules 枚举到 " << enumeratedModules.size() << " 个模块rn";

    std::set<std::string> mappedModules;
    if (!GetModulesByMemoryMapping(hProcess, mappedModules))
    {
        std::cout << "[错误] MemoryMappedFilename 扫描失败rn";
        return;
    }
    std::cout << "[检测2] MemoryMappedFilename 发现 " << mappedModules.size() << " 个映射文件rnrn";

    std::vector<std::string> hiddenModules;
    for (const auto& mappedModule : mappedModules)
    {
        if (enumeratedModules.find(mappedModule) == enumeratedModules.end())
        {
            hiddenModules.push_back(mappedModule);
        }
    }

    std::cout << "=== 交叉验证结果 ===rn";
    if (hiddenModules.empty())
    {
        std::cout << "[安全] 未检测到断链模块,进程安全。rn";
    }
    else
    {
        std::cout << "[警告] 检测到 " << hiddenModules.size() << " 个断链模块!rnrn";
        for (const auto& module : hiddenModules)
        {
            std::cout << "  [隐藏模块] " << module << "rn";
            std::cout << "    - 在内存中有映射文件rn";
            std::cout << "    - 不在EnumProcessModules列表中rn";
            std::cout << "    结论: 可能遭受LDR断链攻击!rnrn";
        }
    }
    std::cout << "1秒后自动重新检测,Ctrl+C 退出程序...rn";
}

int main()
{
    DWORD currentPid = GetCurrentProcessId();
    HANDLE hProcess = GetCurrentProcess();

    std::cout << "08-防御篇-内存映射文件检测rn";
    std::cout << "检测原理: 交叉验证EnumProcessModules与MemoryMappedFilenamern";
    std::cout << "目标: 发现被LDR断链隐藏的恶意DLLrnrn";

    while (true)
    {
        DetectUnlinkedModules(hProcess, currentPid);
        Sleep(1000);
        system("cls");
    }
    return 0;
}

代码特点

  • 分步骤实现:核心函数GetModulesByMemoryMapping按5个步骤详细实现
  • 完整注释:每个函数都有详细的Doxygen注释和实现原理说明
  • 错误处理:每步都有完善的错误检查和边界条件处理
  • 性能优化:使用静态变量缓存函数地址,避免重复获取
  • 教学友好:注释清晰,逻辑明确,便于学习和理解

运行效果
程序会每秒检测一次,实时显示检测结果。当发现隐藏的DLL时,会明确标注出来并给出警告信息。


检测效果演示

上图展示了检测工具的实际运行效果。当发现被LDR断链隐藏的DLL时,工具会明确显示警告信息,指出具体的隐藏模块名称。


总结

内存映射文件检测技术的核心优势:

✅ 难以绕过:依赖内核维护的映射信息,用户态代码无法篡改
✅ 直接有效:专门针对LDR断链攻击的检测方法
✅ 实时检测:支持循环检测,及时发现注入行为
✅ 跨版本兼容:Windows 7到11都支持这些API

这种交叉验证的思路不仅适用于DLL检测,也可以扩展到其他安全检测场景。通过对比不同数据源的信息,我们能够发现单一检测手段无法识别的隐藏威胁。


推荐阅读:

By UD2

发表回复

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