阅读时间: 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 是我们的核心检测函数,它负责协调整个检测流程。这里我们先展示函数框架,具体的两个辅助函数 GetModulesByEnumeration 和 GetModulesByMemoryMapping 的实现会在后面详细讲解。
/**
* @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";
}函数设计思路:
- 这里调用的
GetModulesByEnumeration和GetModulesByMemoryMapping都是我们自己封装的辅助函数 - 这种封装设计让主逻辑更清晰,每个辅助函数专注完成一个具体任务
- 后面我们会详细讲解这两个辅助函数的具体实现
验证逻辑:
- 分别获取模块枚举列表和内存映射列表
- 对比两份数据源,识别在内存映射中存在但模块枚举中缺失的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检测,也可以扩展到其他安全检测场景。通过对比不同数据源的信息,我们能够发现单一检测手段无法识别的隐藏威胁。
推荐阅读:
