阅读时间: 8-10 分钟
前置知识: 理解进程注入技术、Windows API 基础、数字签名概念
学习目标: 掌握模块归属检测技术,实现基于数字签名的进程防御系统
📺 配套视频教程
本文配套视频教程已发布在 B 站,建议结合视频学习效果更佳:
💡 提示: 点击视频右下角可全屏观看,建议配合文章食用!
视频链接: https://www.bilibili.com/video/BV1dtyaBbErC/
06-防御篇-检测模块归属(博客版)
承接上节:攻击者的致命疏忽
上节课,攻击者通过 APC 注入成功绕过了我们的 ETW 监控:
- ✅ 绕过 ETW 监控:不创建新线程,复用现有线程的 APC 队列
- ✅ 利用 Alertable 状态:等待线程进入可警告状态后执行 Shellcode
- ✅ 隐蔽执行:ETW 事件中看不到远程线程创建的痕迹
但是,攻击者忽略了一个关键问题:无论注入方式多么隐蔽,恶意代码最终要以某种形式存在于目标进程的内存空间中。
作为防御者,我们的思路是:不监控注入行为,而是检查进程内存中已经存在的模块,通过模块归属来判断是否存在恶意注入。
防御者的思考:为什么需要模块归属检测
传统检测方法的局限性:
- 线程起始地址检测:只能检测直接使用
LoadLibrary 的注入
- ETW 事件监控:只能检测创建新线程的注入行为
- 行为监控:攻击者可以通过各种方式绕过行为记录
模块归属检测的优势:
- 技术无关性:不管注入方式如何(远程线程、APC、线程劫持等),恶意代码模块都会出现在进程空间
- 事后检测能力:即使注入过程被绕过,仍能发现已加载的恶意模块
- 简单可靠:基于文件路径的黑白名单判断,逻辑清晰,误报率低
核心原理讲解
模块归属检测的基本概念
每个 Windows 进程都有一个模块列表,记录了所有加载到该进程地址空间的 DLL 和 EXE 文件。我们可以通过枚举这些模块,检查它们的可信度来判断是否存在恶意注入。
路径检测的局限性:
通常系统模块都在 C:\Windows\System32\ 目录下,但只检测路径是很不可靠的:
- 路径伪装:攻击者可以将恶意DLL放到系统目录下伪装成系统模块
- 无法识别可信第三方软件:其他厂商的合法软件不在系统目录
- 准确性有限:单纯路径检查无法提供可靠的可信度判断
更好的解决方案:数字签名验证
数字签名是现代软件可信度判断的标准:
- Microsoft 官方签名:系统模块的可信度保证
- 第三方厂商签名:合法商业软件的身份认证
- 无签名模块:极有可能是恶意代码或未经验证的软件
基于数字签名的模块分类:
1 2 3 4 5 6 7 8 9
| 进程模块分类: ├── 有数字签名的模块 (Trusted Modules) │ ├── Microsoft 签名的系统模块 │ ├── 其他可信厂商签名的模块 │ └── 有效数字签名的任意模块 ├── 白名单模块 (Whitelisted Modules) │ └── 我们自己的程序文件 └── 可疑模块 (Suspicious Modules) └── 无数字签名或签名无效的模块
|
检测逻辑的核心思想
核心假设:
- 可信模块:具有有效数字签名的模块,包括系统模块和可信第三方模块
- 白名单模块:我们自己的程序,明确可信(即使没有签名)
- 可疑模块:没有数字签名或签名验证失败的模块,需要重点关注
判断流程:
1
| 枚举进程模块 → 验证数字签名 → 模块分类 → 输出可疑模块
|
数字签名验证的实际应用
在实际应用中,数字签名也常常被用于验证软件的可信度:
1. 游戏反外挂系统
外挂程序通常没有有效的数字签名,游戏客户端通过验证所有加载模块的签名来检测和阻止外挂注入。
2. 杀毒软件的自我保护
杀毒软件监控自身进程,阻止未签名的恶意模块注入,保护核心功能不被破坏。
动手实现:构建实时模块监控器
从 main 函数开始
首先创建程序的入口点,建立实时监控的基本框架:
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
| #include <windows.h> #include <psapi.h> #include <iostream> #include <string> #include <algorithm> #include <wintrust.h>
#pragma comment(lib, "psapi.lib") #pragma comment(lib, "wintrust.lib")
void DetectSuspiciousModules(DWORD processId);
int main() { DWORD currentPid = GetCurrentProcessId();
while (true) { DetectSuspiciousModules(currentPid);
Sleep(1000);
system("cls"); }
return 0; }
|
知识点讲解:
GetCurrentProcessId():获取当前进程的唯一标识符
while(true):建立无限循环,实现持续监控
Sleep(1000):暂停1秒,控制检测频率
system("cls"):清空控制台屏幕
#pragma comment(lib, ...):告诉链接器自动链接指定的库
- 函数声明:在实际编程中,当我们需要使用还未实现的函数时,通常先声明函数名,稍后再实现
第一步:获取进程句柄
1 2
| HANDLE hProcess = GetCurrentProcess();
|
为什么使用 GetCurrentProcess()?
- 返回当前进程的伪句柄(特殊值 -1)
- 伪句柄不需要调用
CloseHandle() 关闭
- 如果要检测其他进程,需要使用
OpenProcess() 并处理权限问题
第二步:枚举进程模块
1 2 3 4 5 6 7 8 9 10 11 12
| HMODULE hModules[1024]; DWORD cbNeeded;
if (!EnumProcessModules(hProcess, hModules, sizeof(hModules), &cbNeeded)) { std::cout << "获取模块列表失败\r\n"; return; }
DWORD moduleCount = cbNeeded / sizeof(HMODULE); std::cout << "当前进程共有 " << moduleCount << " 个模块\r\n\r\n";
|
EnumProcessModules API 的使用:
hProcess:进程句柄
hModules:模块句柄数组(输出)
sizeof(hModules):数组大小
&cbNeeded:实际使用的字节数(输出)
- 1024 是经验值,一般进程不会加载超过这个数量的模块
第三步:初始化统计变量
1 2 3 4
| int suspiciousCount = 0; int systemCount = 0; int whitelistCount = 0;
|
为什么设计三类统计变量?
systemCount:有数字签名的可信模块
whitelistCount:我们自己的程序文件
suspiciousCount:无签名且不在白名单中的可疑模块
- “可疑”比”恶意”更准确,体现了安全检测的谨慎态度
第四步:遍历检测每个模块
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
| for (DWORD i = 0; i < moduleCount; i++) { char modulePath[MAX_PATH]; if (GetModuleFileNameExA(hProcess, hModules[i], modulePath, MAX_PATH)) { std::string fullPath = modulePath;
size_t lastSlash = fullPath.find_last_of("\\"); std::string fileName = (lastSlash != std::string::npos) ? fullPath.substr(lastSlash + 1) : fullPath;
if (VerifyDigitalSignature(fullPath)) { systemCount++; } else if (IsWhitelistDLL(fileName)) { whitelistCount++; } else { suspiciousCount++; std::cout << "🚨 [可疑模块] " << fileName << "\r\n"; std::cout << " 路径: " << fullPath << "\r\n"; std::cout << " 基址: 0x" << std::hex << (uintptr_t)hModules[i] << std::dec << "\r\n";
MODULEINFO moduleInfo; if (GetModuleInformation(hProcess, hModules[i], &moduleInfo, sizeof(moduleInfo))) { std::cout << " 大小: " << moduleInfo.SizeOfImage << " 字节\r\n"; } std::cout << "\r\n"; } } }
|
为什么需要两种路径形式?
- 完整路径:数字签名验证需要检查文件的完整位置
- 文件名:白名单检查只需要文件名,更灵活
三层分类逻辑的优先级设计:
VerifyDigitalSignature():第一层,最客观的可信标准
IsWhitelistDLL():第二层,我们的程序,主观可信
else:第三层,其他所有情况,需要人工判断
为什么用 else if 链?
- 提高效率:满足第一个条件后不再检查后续条件
- 逻辑清晰:明确的优先级关系
- 避免重复:一个模块只属于一个类别
第五步:输出统计结果
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| std::cout << "=== 检测结果 ===\r\n"; std::cout << "系统模块: " << systemCount << " 个(已过滤)\r\n"; std::cout << "白名单模块: " << whitelistCount << " 个\r\n"; std::cout << "可疑模块: " << suspiciousCount << " 个\r\n\r\n";
if (suspiciousCount > 0) { std::cout << "⚠️ 检测到可疑模块!可能遭受注入攻击!\r\n"; std::cout << "建议检查这些模块是否为您手动加载的DLL。\r\n"; } else { std::cout << "✓ 未检测到可疑模块,进程安全。\r\n"; }
std::cout << "\r\n1秒后自动重新检测,Ctrl+C 退出程序...\r\n";
|
第六步:实现数字签名验证功能
在主检测函数中,我们需要VerifyDigitalSignature函数来判断模块是否有数字签名。这是模块可信度判断的核心功能:
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
|
BOOL VerifyDigitalSignature(const std::string& filePath) { DWORD fileAttr = GetFileAttributesA(filePath.c_str()); if (fileAttr == INVALID_FILE_ATTRIBUTES) { return FALSE; }
std::wstring widePath(filePath.begin(), filePath.end());
WINTRUST_FILE_INFO fileInfo = {0}; fileInfo.cbStruct = sizeof(WINTRUST_FILE_INFO); fileInfo.pcwszFilePath = widePath.c_str();
WINTRUST_DATA winTrustData = {0}; winTrustData.cbStruct = sizeof(WINTRUST_DATA); winTrustData.pPolicyCallbackData = NULL; winTrustData.pSIPClientData = NULL; winTrustData.dwUIChoice = 2; winTrustData.fdwRevocationChecks = 0; winTrustData.dwUnionChoice = 1; winTrustData.dwStateAction = 1; winTrustData.hWVTStateData = NULL; winTrustData.pwszURLReference = NULL; winTrustData.dwProvFlags = 0x1000; winTrustData.pSignatureSettings = NULL; winTrustData.pFile = &fileInfo;
GUID policyGUID = {0xaac56b, 0xcd44, 0x11d0, {0x8c, 0xc2, 0x00, 0xc0, 0x4f, 0xc2, 0x95, 0xee}};
LONG result = WinVerifyTrust(NULL, &policyGUID, &winTrustData);
return (result == ERROR_SUCCESS); }
|
知识点讲解:
GetFileAttributesA:检查文件是否存在,避免验证不存在的文件
WinVerifyTrust:Windows核心的数字签名验证API
WINTRUST_FILE_INFO:描述要验证的文件信息
WINTRUST_DATA:控制验证行为的详细参数
- 关键配置参数说明:
dwUIChoice = 2:禁用所有用户界面提示
fdwRevocationChecks = 0:不检查证书吊销状态(提高速度)
dwProvFlags = 0x1000:启用更安全的验证模式
第七步:实现白名单检查功能
为了减少误报,我们需要实现IsWhitelistDLL函数来识别我们自己的程序文件:
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
|
BOOL IsWhitelistDLL(const std::string& dllName) { std::string lowerDllName = dllName; std::transform(lowerDllName.begin(), lowerDllName.end(), lowerDllName.begin(), ::tolower);
const std::string whitelistDLLs[] = { "uxtheme.dll", "06-防御篇-检测模块归属.exe" };
for (const auto& whitelistDLL : whitelistDLLs) { if (lowerDllName == whitelistDLL) { return TRUE; } }
return FALSE; }
|
知识点讲解:
std::transform:C++标准库算法,用于字符大小写转换
::tolower:C标准库函数,将字符转换为小写
- 白名单机制:预先定义的可信文件列表,避免误报
- 大小写不敏感比较:确保匹配的准确性
核心步骤的深度解析
获取进程句柄的考虑
在实际编程中,要访问进程信息首先需要进程句柄,这就像要进入房间需要钥匙一样。
GetCurrentProcess() 返回当前进程的伪句柄(特殊值 -1)
- 伪句柄不需要调用
CloseHandle() 关闭
- 如果要检测其他进程,需要使用
OpenProcess() 并处理权限问题
枚举模块的完整流程
“获取模块列表”是一个完整的业务目标,包含:
- 准备存储空间:
HMODULE hModules[1024]
- 调用系统API:
EnumProcessModules
- 处理返回结果:检查返回值和错误处理
- 计算有效数据:
DWORD moduleCount = cbNeeded / sizeof(HMODULE)
关键技术理解:
- 1024 是经验值,一般进程不会加载超过这个数量的模块
- 如果不够大,
cbNeeded 会告诉我们实际需要多少空间
- 错误处理要检查返回值而不是依赖异常
分类统计的设计思路
我们为什么设计三类而不是简单的两类(可信/不可信)?
- 数字签名模块:客观可信,系统标准
- 白名单模块:主观可信,我们自己的程序
- 可疑模块:需要人工判断的其他模块
变量命名的含义:
suspiciousCount:为什么不是 badCount?
- 体现了安全检测的谨慎态度
- 我们的检测方法可能有误报
- “可疑”比”恶意”更准确
遍历检测的核心逻辑
这是最复杂的步骤,包含了完整的检测逻辑:
路径获取与处理:
1 2
| std::string fullPath = modulePath; std::string fileName = ...;
|
- 完整路径:数字签名验证需要检查文件的完整位置
- 文件名:白名单检查只需要文件名,更灵活
三层分类逻辑的优先级设计:
1 2 3 4 5 6 7 8 9
| if (VerifyDigitalSignature(fullPath)) { } else if (IsWhitelistDLL(fileName)) { } else { }
|
为什么用 else if 链而不是独立 if?
- 提高效率:满足第一个条件后不再检查后续条件
- 逻辑清晰:明确的优先级关系
- 避免重复:一个模块只属于一个类别
完整代码
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
| #include <windows.h> #include <psapi.h> #include <iostream> #include <string> #include <algorithm> #include <wintrust.h>
#pragma comment(lib, "psapi.lib") #pragma comment(lib, "wintrust.lib")
BOOL VerifyDigitalSignature(const std::string& filePath) { DWORD fileAttr = GetFileAttributesA(filePath.c_str()); if (fileAttr == INVALID_FILE_ATTRIBUTES) { return FALSE; }
std::wstring widePath(filePath.begin(), filePath.end());
WINTRUST_FILE_INFO fileInfo = {0}; fileInfo.cbStruct = sizeof(WINTRUST_FILE_INFO); fileInfo.pcwszFilePath = widePath.c_str();
WINTRUST_DATA winTrustData = {0}; winTrustData.cbStruct = sizeof(WINTRUST_DATA); winTrustData.pPolicyCallbackData = NULL; winTrustData.pSIPClientData = NULL; winTrustData.dwUIChoice = 2; winTrustData.fdwRevocationChecks = 0; winTrustData.dwUnionChoice = 1; winTrustData.dwStateAction = 1; winTrustData.hWVTStateData = NULL; winTrustData.pwszURLReference = NULL; winTrustData.dwProvFlags = 0x1000; winTrustData.pSignatureSettings = NULL; winTrustData.pFile = &fileInfo;
GUID policyGUID = {0xaac56b, 0xcd44, 0x11d0, {0x8c, 0xc2, 0x00, 0xc0, 0x4f, 0xc2, 0x95, 0xee}};
LONG result = WinVerifyTrust(NULL, &policyGUID, &winTrustData);
return (result == ERROR_SUCCESS); }
BOOL IsWhitelistDLL(const std::string& dllName) { std::string lowerDllName = dllName; std::transform(lowerDllName.begin(), lowerDllName.end(), lowerDllName.begin(), ::tolower);
const std::string whitelistDLLs[] = { "uxtheme.dll", "06-防御篇-检测模块归属.exe" };
for (const auto& whitelistDLL : whitelistDLLs) { if (lowerDllName == whitelistDLL) { return TRUE; } }
return FALSE; }
void DetectSuspiciousModules(DWORD processId) { std::cout << "=== 简单模块检测器(教学版) ===\r\n"; std::cout << "当前进程ID: " << processId << "\r\n"; std::cout << "正在检测进程中的可疑模块...\r\n\r\n";
HANDLE hProcess = GetCurrentProcess();
HMODULE hModules[1024]; DWORD cbNeeded;
if (!EnumProcessModules(hProcess, hModules, sizeof(hModules), &cbNeeded)) { std::cout << "获取模块列表失败\r\n"; return; }
DWORD moduleCount = cbNeeded / sizeof(HMODULE); std::cout << "当前进程共有 " << moduleCount << " 个模块\r\n\r\n";
int suspiciousCount = 0; int systemCount = 0; int whitelistCount = 0;
for (DWORD i = 0; i < moduleCount; i++) { char modulePath[MAX_PATH]; if (GetModuleFileNameExA(hProcess, hModules[i], modulePath, MAX_PATH)) { std::string fullPath = modulePath;
size_t lastSlash = fullPath.find_last_of("\\"); std::string fileName = (lastSlash != std::string::npos) ? fullPath.substr(lastSlash + 1) : fullPath;
if (VerifyDigitalSignature(fullPath)) { systemCount++; } else if (IsWhitelistDLL(fileName)) { whitelistCount++; } else { suspiciousCount++; std::cout << "🚨 [可疑模块] " << fileName << "\r\n"; std::cout << " 路径: " << fullPath << "\r\n"; std::cout << " 基址: 0x" << std::hex << (uintptr_t)hModules[i] << std::dec << "\r\n";
MODULEINFO moduleInfo; if (GetModuleInformation(hProcess, hModules[i], &moduleInfo, sizeof(moduleInfo))) { std::cout << " 大小: " << moduleInfo.SizeOfImage << " 字节\r\n"; } std::cout << "\r\n"; } } }
std::cout << "=== 检测结果 ===\r\n"; std::cout << "系统模块: " << systemCount << " 个(已过滤)\r\n"; std::cout << "白名单模块: " << whitelistCount << " 个\r\n"; std::cout << "可疑模块: " << suspiciousCount << " 个\r\n\r\n";
if (suspiciousCount > 0) { std::cout << "⚠️ 检测到可疑模块!可能遭受注入攻击!\r\n"; std::cout << "建议检查这些模块是否为您手动加载的DLL。\r\n"; } else { std::cout << "✓ 未检测到可疑模块,进程安全。\r\n"; }
std::cout << "\r\n1秒后自动重新检测,Ctrl+C 退出程序...\r\n"; }
int main() { DWORD currentPid = GetCurrentProcessId();
while (true) { DetectSuspiciousModules(currentPid);
Sleep(1000);
system("cls"); }
return 0; }
|
检测效果演示
模块检测器运行效果:

如图所示,程序能够成功检测到:
- ✅ 正常进程显示所有加载的模块分类统计
- ✅ 系统模块(已过滤)和白名单模块数量清晰
- ✅ 无可疑模块时显示安全状态提示
- ✅ 每秒自动刷新,实时监控进程模块变化
优势与局限
优势
- 技术无关性:不依赖特定的注入技术检测,任何形式的模块加载都能发现
- 实时监控能力:每秒自动检测,能快速发现模块变化
- 签名验证可靠:基于数字签名的可信度判断,有效防止路径伪装攻击
- 输出清晰直观:分类明确,重点突出可疑模块
- 教学友好:代码结构清晰,便于理解和扩展
局限
- 签名依赖性:完全依赖数字签名,未签名的合法模块可能被误报
- 静态白名单:白名单需要预先配置,不够灵活
- 无法识别注入时机:只能检测已加载的模块,无法知道何时注入的
- 内存模块检测有限:对于不通过文件加载的内存模块检测能力有限
- 签名吊销检查:为提高验证速度,未进行实时的签名吊销状态检查
教学版简化说明
当前实现为教学简化版,实际生产环境中还应考虑:
- 数字签名验证:验证模块的数字签名和发布者信息
- 动态白名单管理:支持动态添加和修改白名单
- 更复杂的路径分析:识别伪装的系统路径
- 模块完整性检查:检查模块是否被修改
- 网络行为监控:结合网络行为进行综合判断
防御建议
基础防御措施
- 定期模块检查:在关键业务流程中定期检查进程模块列表
- 白名单维护:建立完善的白名单策略,明确允许加载的模块
- 路径规范:将应用程序放置在规范的目录中,避免路径混淆
进阶防御策略
- 多层检测:结合线程检测、ETW监控、模块检测形成多层次防御体系
- 行为分析:不仅检查模块存在,还要分析模块的行为特征
- 基线建立:为正常应用建立模块基线,发现偏差时告警
实战应用场景
- 游戏反外挂:实时监控游戏进程,检测第三方注入模块
- 安全软件:作为进程保护的一部分,防止恶意代码注入
- 企业安全:监控关键业务进程,发现异常模块加载
下节课预告:攻击者的新思路 - Module Stomping
虽然我们的模块归属检测很有效,但攻击者总能找到新的绕过方法。下节课,作为攻击者,我们将学习一种更加隐蔽的注入技术:Module Stomping(模块践踏)。
Module Stomping 的核心思想:
- 不创建新的模块,而是”践踏”现有的合法模块
- 将恶意代码加载到已存在的系统模块内存空间中
- 从模块列表角度看,一切正常,没有新增模块
这种技术能够完美绕过我们的模块归属检测,因为它根本不会在进程模块列表中留下痕迹。我们将学习如何:
- 选择目标系统模块
- 在合法模块的内存空间中注入恶意代码
- 修改模块内存保护属性
- 实现代码执行
Module Stomping 代表了注入技术的一个重要发展方向:从”添加”到”伪装”的转变。这要求防御者必须从模块检查转向内存完整性检查。
下节课关键词: Module Stomping、内存伪装、代码注入、绕过检测
互动时间
💬 评论区讨论:
思维扩展:除了路径检查,还有哪些方法可以判断模块的可信度?
实战思考:如果恶意DLL将自身伪装成系统DLL(比如放在System32目录下),我们的检测器还能有效工作吗?应该如何加强检测?
技术探讨:实时监控虽然能快速发现问题,但也会消耗系统资源。你能在性能和检测效果之间找到平衡点吗?比如设计一个智能的检测频率调节机制?
扩展应用:这个检测原理能否应用到其他领域?比如检测注册表项、服务、计划任务等系统组件的异常?
🔥 下期预告关键词: Module Stomping、内存伪装、现有模块利用
评论 “模块检测“ 让我知道你在等更新!