阅读时间: 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。
动手实现
主函数设计
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| int main() { DWORD currentPid = GetCurrentProcessId(); HANDLE hProcess = GetCurrentProcess();
std::cout << "08-防御篇-内存映射文件检测\n"; std::cout << "检测原理:交叉验证 EnumProcessModules 与内存映射信息\n"; std::cout << "检测目标:发现被LDR断链隐藏的DLL模块\n\n";
while (true) { DetectUnlinkedModules(hProcess, currentPid); Sleep(1000); system("cls"); }
return 0; }
|
实现说明:
- 使用
GetCurrentProcess() 检测当前进程(便于演示)
- 实际应用中可改为
OpenProcess() 检测其他进程
- 循环检测机制可实时监控异常注入行为
接下来就实现核心的 DetectUnlinkedModules 函数,它会调用两个关键的辅助函数来完成检测任务。
核心检测函数 DetectUnlinkedModules
DetectUnlinkedModules 是我们的核心检测函数,它负责协调整个检测流程。这里我们先展示函数框架,具体的两个辅助函数 GetModulesByEnumeration 和 GetModulesByMemoryMapping 的实现会在后面详细讲解。
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
|
void DetectUnlinkedModules(HANDLE hProcess, DWORD processId) { std::cout << "=== LDR断链检测器(交叉验证版)===\n"; std::cout << "目标进程ID:" << processId << "\n"; std::cout << "正在执行双重检测...\n\n";
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() << " 个映射文件\n\n";
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() << " 个断链模块!\n\n"; for (const auto& module : hiddenModules) { std::cout << " [隐藏模块] " << module << "\n"; std::cout << " - 内存中存在映射文件\n"; std::cout << " - EnumProcessModules中未找到\n"; std::cout << " 结论:可能遭受LDR断链攻击!\n\n"; } }
std::cout << "1秒后自动重新检测,Ctrl+C退出程序...\n"; }
|
函数设计思路:
- 这里调用的
GetModulesByEnumeration 和 GetModulesByMemoryMapping 都是我们自己封装的辅助函数
- 这种封装设计让主逻辑更清晰,每个辅助函数专注完成一个具体任务
- 后面我们会详细讲解这两个辅助函数的具体实现
验证逻辑:
- 分别获取模块枚举列表和内存映射列表
- 对比两份数据源,识别在内存映射中存在但模块枚举中缺失的DLL
- 生成详细的检测报告,标明异常模块及相关证据
- 支持循环检测模式,实时监控注入行为
获取模块枚举列表(GetModulesByEnumeration)
使用 EnumProcessModules() 获取进程模块列表,此列表基于LDR链表,可能被断链攻击篡改。
原理解析:EnumProcessModules 遍历的是LDR链表,攻击者可以通过断链操作从这个列表中”抹除”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
|
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。
类型定义准备
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
|
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 );
|
技术说明:
MemoryMappedFilenameInformation(值为2)是我们需要的信息类型,用于获取映射文件名
UNICODE_STRING_EX是Windows内核常用的字符串结构,包含长度和缓冲区指针
pNtQueryVirtualMemory是函数指针类型,用于动态获取未文档化的NTAPI函数
完整函数代码
将以上步骤组合起来,就是完整的GetModulesByMemoryMapping函数:
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
|
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; }
|
实现要点总结:
- 分步骤设计:函数分为4个清晰的步骤,逻辑明确
- 错误处理:每步都有错误检查,确保程序稳定性
- 性能优化:使用静态变量缓存函数地址,避免重复获取
- 数据过滤:只关注DLL/EXE文件,减少后续处理负担
辅助函数:路径提取和字符串转换
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
|
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; }
|
完整代码实现
以下是完整的实现代码,包含了详细的分步注释:
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 209 210 211 212 213
| #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")
#if !defined(_WIN64) #error "此程序必须以 x64 模式编译" #endif
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断链检测器(交叉验证版)===\r\n"; std::cout << "目标进程ID: " << processId << "\r\n"; std::cout << "正在执行双重检测...\r\n\r\n";
std::set<std::string> enumeratedModules; if (!GetModulesByEnumeration(hProcess, enumeratedModules)) { std::cout << "[错误] EnumProcessModules 失败\r\n"; return; } std::cout << "[检测1] EnumProcessModules 枚举到 " << enumeratedModules.size() << " 个模块\r\n";
std::set<std::string> mappedModules; if (!GetModulesByMemoryMapping(hProcess, mappedModules)) { std::cout << "[错误] MemoryMappedFilename 扫描失败\r\n"; return; } std::cout << "[检测2] MemoryMappedFilename 发现 " << mappedModules.size() << " 个映射文件\r\n\r\n";
std::vector<std::string> hiddenModules; for (const auto& mappedModule : mappedModules) { if (enumeratedModules.find(mappedModule) == enumeratedModules.end()) { hiddenModules.push_back(mappedModule); } }
std::cout << "=== 交叉验证结果 ===\r\n"; if (hiddenModules.empty()) { std::cout << "[安全] 未检测到断链模块,进程安全。\r\n"; } else { std::cout << "[警告] 检测到 " << hiddenModules.size() << " 个断链模块!\r\n\r\n"; for (const auto& module : hiddenModules) { std::cout << " [隐藏模块] " << module << "\r\n"; std::cout << " - 在内存中有映射文件\r\n"; std::cout << " - 不在EnumProcessModules列表中\r\n"; std::cout << " 结论: 可能遭受LDR断链攻击!\r\n\r\n"; } } std::cout << "1秒后自动重新检测,Ctrl+C 退出程序...\r\n"; }
int main() { DWORD currentPid = GetCurrentProcessId(); HANDLE hProcess = GetCurrentProcess();
std::cout << "08-防御篇-内存映射文件检测\r\n"; std::cout << "检测原理: 交叉验证EnumProcessModules与MemoryMappedFilename\r\n"; std::cout << "目标: 发现被LDR断链隐藏的恶意DLL\r\n\r\n";
while (true) { DetectUnlinkedModules(hProcess, currentPid); Sleep(1000); system("cls"); } return 0; }
|
代码特点:
- ✅ 分步骤实现:核心函数
GetModulesByMemoryMapping按5个步骤详细实现
- ✅ 完整注释:每个函数都有详细的Doxygen注释和实现原理说明
- ✅ 错误处理:每步都有完善的错误检查和边界条件处理
- ✅ 性能优化:使用静态变量缓存函数地址,避免重复获取
- ✅ 教学友好:注释清晰,逻辑明确,便于学习和理解
运行效果:
程序会每秒检测一次,实时显示检测结果。当发现隐藏的DLL时,会明确标注出来并给出警告信息。
检测效果演示

上图展示了检测工具的实际运行效果。当发现被LDR断链隐藏的DLL时,工具会明确显示警告信息,指出具体的隐藏模块名称。
总结
内存映射文件检测技术的核心优势:
✅ 难以绕过:依赖内核维护的映射信息,用户态代码无法篡改
✅ 直接有效:专门针对LDR断链攻击的检测方法
✅ 实时检测:支持循环检测,及时发现注入行为
✅ 跨版本兼容:Windows 7到11都支持这些API
这种交叉验证的思路不仅适用于DLL检测,也可以扩展到其他安全检测场景。通过对比不同数据源的信息,我们能够发现单一检测手段无法识别的隐藏威胁。
推荐阅读: