简介:这是一款面向Windows安全研究与系统管理人员的C++命令行小工具,用于检测当前运行进程中的DLL劫持情况。工具提供/unsigned与/verbose两个可选参数:前者仅标记包含未签名DLL的进程,后者会列出在多个搜索顺序位置中发现的所有DLL,便于排查非标准加载路径;描述中还给出了结合/unsigned与/verbose定位CWD远程型劫持的用法建议。压缩包体积约20KB,共5个文件,主体为3个cpp源码文件,辅以License授权说明与README.md使用文档,结构精简,适合阅读、编译并二次定制。已有285人浏览学习,对安全开发、红队评估或Windows进程排查感兴趣的读者,可直接查看源码中的检测逻辑与命令行实现,也可据此扩展为自动化审计工具。
1. DLL 劫持检测:为什么查正在运行的进程比扫文件目录更接近真相
生产环境某个进程突然加载了不明模块,同事第一反应是扫磁盘上的 DLL,半天没结果。反过来枚举正在运行的进程和它已加载的模块,第一次跑就揪出来一个从临时目录加载的可疑 DLL。这是我拆这套 DLL 劫持检测资源时的真实感受:与其对着静态目录猜,不如直接看运行时里已经发生的动作。这套资源用 C++ 实现,做一件很聚焦的事——枚举 Windows 上正在运行的进程,遍历每个进程已加载的模块,把模块路径和 KnownDlls 映射、系统目录做比对,把不符合预期的加载路径标出来。适合两类人:做进程排查的运维或安全工程师,以及想拿一个完整 Windows 工程当参考的初学者。
2. 先看劫持面:加载顺序、KnownDlls 与搜索路径规则
2.1 进程加载 DLL 的搜索顺序:被忽略的优先路径
Windows 加载一个不带全路径的 DLL 时,走的是一套固定顺序:应用所在目录、系统目录 System32、16 位系统目录、Windows 目录、进程当前工作目录、PATH 环境变量里的目录。攻击者把同名 DLL 放到应用目录或临时目录,只要这个 DLL 不在 KnownDlls 名单里,进程启动时就会先命中攻击者放的副本。这个机制不是漏洞,是设计如此,但很多业务程序依赖“当前目录优先”的旧习惯,于是劫持就有机可乘。
检测工具要做的第一件事,就是把进程已经加载的模块路径收集起来,看有没有落在不该出现的位置。注意这里的“不该出现”不是看文件名像不像系统文件,而是看加载起点是不是系统目录。很多文章一上来就教人对比哈希或签名,但第一次排查时最该先做的是路径比对——路径不对,后续哈希和签名都免谈。我一般会让工具在输出路径的同时记录模块的基地址和大小,这两个字段后面做白名单和基线对比都有用。
2.2 KnownDlls:检测工具手里最硬的一张“白名单”
KnownDlls 分两层:注册表项和 NT 命名空间目录。注册表里保存的是 DLL 名字列表,NT 命名空间下则是系统启动阶段建好的 section 对象。ntdll 在解析 DLL 加载时会优先检查模块名是否命中 KnownDlls,命中就直接映射系统已经建好的内存段,根本不会再去磁盘搜索路径。
这就是为什么直接劫持 kernel32.dll、user32.dll 这类名字,在正常情况下很难生效——系统根本不会去搜路径,除非注册表里的 KnownDLLs 配置本身被动过手脚。“KnownDLLs 被改了”和“KnownDLLs 里多了一个异常条目”这两个现象,检测工具都应该给最高优先级告警,因为这通常意味着系统已经被做过了更深的处理,不是普通应用层劫持。
所以在这套资源里,KnownDlls 名单是第一层白名单:模块名命中 KnownDlls,且路径落在系统目录里,直接放行。但注意它只是核心名单,覆盖面比 System32 小得多,大量第三方组件的 DLL 不在名单里,这些才是要重点盯的对象。实操时我会把判定做成三层,这个展开放在第 3 章讲。
提示:KnownDlls 不是“全系统 DLL 白名单”,它只覆盖系统启动早期加载的一小部分关键 DLL。拿它当唯一基线会漏掉大量第三方 DLL 的可疑加载。
2.3 方案选型:为什么选择 Toolhelp32 快照而不是其他枚举方式
做进程模块枚举,常见的路子有几种:WTSEnumerateProcesses 只能拿到进程基本信息,拿不到模块列表;NtQuerySystemInformation 能拿系统句柄信息但要处理 32/64 位差异,代码量上去了;Toolhelp32 在 Windows XP 之后就一路稳定可用,Process32FirstW、Module32FirstW 配合快照句柄,是信息最全、写法最直接的一套 API。
这套资源选的就是 Toolhelp32。快照初始化代码如下:
HANDLE snap = CreateToolhelp32Snapshot( TH32CS_SNAPPROCESS | TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, 0); if (snap == INVALID_HANDLE_VALUE) { // 大概率是权限不足,或当前会话状态特殊 return 1; }逻辑说明:TH32CS_SNAPPROCESS 负责给进程表拍快照,TH32CS_SNAPMODULE 负责给指定进程的模块表拍快照,TH32CS_SNAPMODULE32 是为了能在 64 位系统上枚举到 32 位进程的模块。最后一个参数传 0 表示对所有进程生效;如果传具体 PID,后续再通过指定进程句柄拿模块列表。要注意的是,这个快照句柄用完后必须 CloseHandle,后面做定时扫描时如果不释放,句柄泄漏到几千个,枚举结果会开始飘。
3. 核心实现:进程快照、模块遍历与路径比对三分法
3.1 进程枚举与进程记录结构体
拿到快照句柄后,第一步是枚举所有进程并保存基本信息,方便后面按 PID 逐个处理模块。这里要保留的字段不光是进程名和 PID,最好把进程的位数也记下来——后面解释路径时要用。
struct ProcessInfo { DWORD pid = 0; std::wstring name; bool is_wow64 = false; // true 表示 32 位进程跑在 64 位系统上 std::vector<std::wstring> modules; // 完整路径,按加载顺序保存 }; std::vector<ProcessInfo> EnumerateProcesses() { std::vector<ProcessInfo> result; HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); if (snap == INVALID_HANDLE_VALUE) return result; PROCESSENTRY32W pe = { sizeof(pe) }; if (Process32FirstW(snap, &pe)) { do { ProcessInfo info; info.pid = pe.th32ProcessID; info.name = pe.szExeFile; result.push_back(std::move(info)); } while (Process32NextW(snap, &pe)); } CloseHandle(snap); return result; }逻辑说明:这里用 PROCESSENTRY32W 而不是不带 W 的版本,是为了保证中文路径和宽字符进程名不乱码。进程名只是给人看的,真正识别靠 PID,所以在每个结构体里把 PID 单独存一份。至于进程位数判断,Commonly 是调用 IsWow64Process2 拿 PROCESS_INFORMATION 里的 Wow64 标志,也顺带拿到原生架构信息。
3.2 模块遍历与路径收集
进程列表有了之后,逐个进程枚举模块。这里有个关键点:64 位系统上遍历 32 位进程模块时,快照标志要额外带上 TH32CS_SNAPMODULE32,否则 Module32FirstW 会返回空。
void CollectModules(ProcessInfo& info) { HANDLE snap = CreateToolhelp32Snapshot( TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, info.pid); if (snap == INVALID_HANDLE_VALUE) return; MODULEENTRY32W me = { sizeof(me) }; if (Module32FirstW(snap, &me)) { do { info.modules.push_back(me.szExePath); } while (Module32NextW(snap, &me)); } CloseHandle(snap); }参数说明:第一个参数是快照类型,第二个参数是目标进程 PID。这里没有再带 TH32CS_SNAPPROCESS,因为给单个进程做模块快照不需要进程表。szExePath 是模块的完整路径,如果拿不到完整路径,szModule 至少还有模块名,实际处理时可以把拿不到路径的模块单独归到一个类别里,后面第 5 章避坑部分会详细说这种情况。
3.3 三类路径判定规则与位宽处理
路径收集完,进入核心判定环节。这套资源用的不是一刀切黑名单,而是三层判定,每层解决一类问题:
| 判定层级 | 条件 | 判定结果 | 说明 |
|---|---|---|---|
| 第一层 | 模块名命中 KnownDlls,且路径在 System32/SysWOW64 | 白名单 | 系统关键 DLL,加载方式符合预期 |
| 第二层 | 模块名不在 KnownDlls,但路径在 System32/SysWOW64 | 白名单 | 非 KnownDlls 但仍是系统组件,需结合签名 |
| 第三层 | 路径在其他位置(应用目录、临时目录、用户目录等) | 可疑输出 | 交给人工进一步核验 |
位宽处理是这套判定里最容易翻车的地方。32 位进程在 64 位系统上加载系统 DLL 时,系统做了文件系统重定向,路径显示为 C:\Windows\SysWOW64\xxx.dll 而不是 System32。如果白名单里只写死 System32,32 位进程的 system32 路径会全部误报。所以判定前先检查 is_wow64,按进程位数分别拿 System32 和 SysWOW64 两个基准路径做比对。
bool IsSystemPath(const std::wstring& path, const std::wstring& sys_dir) { std::wstring lower_path = path; std::wstring lower_sys = sys_dir; // 统一小写再比较,避免大小写差异造成的误报 std::transform(lower_path.begin(), lower_path.end(), lower_path.begin(), ::towlower); std::transform(lower_sys.begin(), lower_sys.end(), lower_sys.begin(), ::towlower); return lower_path.find(lower_sys) == 0; }逻辑说明:路径比较前先统一转小写,Windows 文件系统不区分大小写,但字符串比较区分,不做这一步会有漏报。这里用了前缀匹配而不是精确相等,因为系统目录下有大量子目录,模块可能从 System32\drivers、System32\wbem 等子路径加载。对从 System32 之外加载、但文件名和 KnownDlls 命中一致的情况,要单独标记为高危,因为这种场景常见于 DLL 搜索路径劫持的典型手法。
4. 把检测跑起来:编译参数、命令行用法与输出解读
4.1 编译参数:静态链接与字符集设置
这套资源是纯 C++ 工程,编译时我习惯用 Visual Studio 的开发者命令行,一条 cl 命令搞定,不依赖 IDE 工程文件。推荐参数如下:
cl /std:c++17 /EHs /MT /O2 /DUNICODE /D_UNICODE /W4 ^ main.cpp /Fe:dllcheck.exe参数说明:/std:c++17 启用 C++17,主要为了用 std::wstring、std::vector 这些 STL 容器和保证代码里的结构化绑定等语法能过;/MT 是关键,表示静态链接 CRT,这样拷到目标机器上不会因为缺 vcruntime140.dll 直接起不来;/DUNICODE 和 /D_UNICODE 把 Windows API 的窄字符入口全部切到宽字符版本,代码里写的 CreateToolhelp32Snapshot 实际会展开成 CreateToolhelp32SnapshotW;/W4 开四级警告,虽然警告多,但能提前暴露像“模块名没判空直接进白名单”这种隐患。
4.2 命令行参数:从全量扫描到定向排查
编译好的可执行文件支持一组命令行参数,这是这套资源里最实用的部分,不用改代码就能切换扫描范围:
| 参数 | 含义 | 示例 | 说明 |
|---|---|---|---|
| -all | 扫描全部进程 | dllcheck.exe -all | 默认模式,输出所有进程的可疑模块 |
| -pid | 只扫指定进程 | dllcheck.exe -pid 1234 | 定位单个进程时效率最高 |
| -name | 按进程名过滤 | dllcheck.exe -name svchost.exe | 同名多实例场景,比如多个 svchost |
| -json | JSON 格式输出 | dllcheck.exe -all -json | 方便接脚本和日志平台 |
| -base-dir | 指定额外比对目录 | dllcheck.exe -all -base-dir D:\apps | 把特定业务目录加入白名单 |
| -whitelist | 指定白名单文件 | dllcheck.exe -all -whitelist wl.txt | 每行一个路径前缀,用于收敛误报 |
入口参数解析的逻辑不复杂,核心在 wmain 里逐个比对参数前缀,把参数拼成一个配置结构体。命令行这块最容易被忽略的是参数传了但没生效——比如 -pid 后面跟了非数字字符,我一般会让程序直接退出并打一行提示,而不是静默忽略,静默忽略在排查时会让人误以为参数生效了。
4.3 输出解读:从一行可疑路径反推加载链
正常扫描结果的每一行输出会包含进程名、PID、模块路径、判定类别四个字段。手动排查时,我习惯从上往下看类别字段,优先处理标成“HIGH”的条目:路径落在临时目录、下载目录、用户 Roaming 目录的,基本可以判定为高度可疑。
explorer.exe (PID 4820) HIGH C:\Users\Public\Temp\winmm.dll explorer.exe (PID 4820) INFO C:\Windows\System32\kernel32.dll svchost.exe (PID 1320) LOW C:\Program Files\Common Files\srv.dll解读时有个小技巧:不要只看这一个进程。这套资源输出的是全量进程表,把同时间段内所有“HIGH”模块路径拉出来对比,如果多个不同进程都从同一个非系统目录加载 DLL,说明这个目录很可能是被集中投递的载荷目录。我在实际排查中遇到过三次这种模式,最典型的一次是某软件卸载残留目录里被种了一个 DLL,三个进程启动时都会去读那个目录。
提示:LOW 级别的输出不代表绝对安全,只能说明路径不在标准搜索路径之外。真正的判定还要结合模块签名和文件时间戳一起看,工具只负责把候选集缩小到人肉能看过来的规模。
5. 避坑与排查:权限不足、32/64 位重定向、可信路径误报
5.1 现象:模块列表读不全,进程枚举出来但模块数组为空
原因:目标进程以高权限运行,当前终端没有管理员权限。服务进程和以 SYSTEM 身份运行的进程,模块列表在低权限下读取时会被阻止,CreateToolhelp32Snapshot 返回的句柄有效,但 Module32FirstW 只有第一条,或者直接返回 ERROR_ACCESS_DENIED。解决:用管理员会话重新运行工具。桌面运维场景里这个坑最常见,因为大部分排查都是从自己的普通用户会话开始的。我一般会让工具在启动时检查当前令牌里是否有管理员组 SID,没有就直接提示,避免用户白跑一轮。
5.2 现象:32 位进程的 System32 路径全部被判定为可疑
原因:64 位系统上运行 32 位进程时,文件系统重定向把 System32 访问映射到 SysWOW64,Module32FirstW 返回的路径已经是被重定向后的结果。如果白名单比对只拿 System32 一个基准,所有 32 位进程的系统模块都会报错。解决:枚举进程时先判断 is_wow64,根据进程位数分别使用 C:\Windows\System32 和 C:\Windows\SysWOW64 作为系统目录基准。这里还有一个更隐蔽的子坑:少数老程序用 Sysnative 别名访问真实 System32,路径前缀是 C:\Windows\Sysnative,判定规则里最好把三种前缀都覆盖到。
5.3 现象:杀软或 EDR 的钩子 DLL 被批量报为可疑
原因:第三方安全软件为了监控进程行为,会往每个进程注入 DLL,这些 DLL 通常从自己的安装目录加载,路径既不在 KnownDlls 也不在系统目录,判定规则会毫不犹豫地把它们标成可疑。解决:把安全软件的安装目录通过 -base-dir 或白名单文件加入比对范围。这个操作需要小心,白名单不能做成前缀匹配的全局生效,否则攻击者把 DLL 放到这个目录下也能蒙混过关。我建议白名单文件里支持精确路径和前缀两条规则,精确匹配优先,前缀匹配需要单独标记。
5.4 现象:反复运行几次,输出的模块数量不一样
原因:快照句柄泄漏。做定时扫描时,每次 CreateToolhelp32Snapshot 返回的句柄都必须 CloseHandle,漏一次内存里就多一个句柄,进程句柄表满之后,新快照拿到的模块列表会开始不稳定。解决:所有分支都要确保释放句柄,包括 Module32FirstW 返回 FALSE 提前退出的情况。代码里比较好的写法是 RAII 封装,或者至少确认每个 GetLastError 分支都走到 CloseHandle。
5.5 现象:服务进程看不到
原因:Session 0 隔离。普通管理员会话无法直接访问 Session 0 里服务的模块列表,快照能枚举到进程但拿不到模块。解决:检测工具本身必须以服务方式运行,或者用计划任务以 SYSTEM 身份跑,把输出写到文件再回传。这个坑在第一次上手时最容易被忽略,因为从进程列表看,服务进程是存在的,看起来一切正常,唯独模块列表是空的。
6. 进阶用法:定时快照对比模块指纹,让检测结果可复核
6.1 把输出沉淀成基线 CSV
单次扫描只能发现问题,重复出现的“正常异常”会把精力磨光。我在用这套资源做持续监控时,会把首次扫描结果推成基线文件,之后每次扫描只对比增量。基线文件格式简单,足够后续脚本处理就行:
process,pid,module_path,module_size,status explorer.exe,4820,C:\Windows\System32\kernel32.dll,100352,known explorer.exe,4820,C:\Windows\System32\user32.dll,153600,known explorer.exe,4820,C:\Users\Public\Temp\winmm.dll,8704,suspicious逻辑说明:第二列 PID 在进程重启后会变,所以基线对比不按 PID 匹配,而是按进程名加模块路径的组合来判断新增和消失。module_size 字段保存模块文件大小,如果路径相同但大小发生变化,说明 DLL 文件被替换过,即使路径在白名单里,这个变化也值得单独标记。首次跑出来的基线目录要放在只读位置,我一般放到 D:\baseline\ 下一个单独文件夹,避免后续输出把基线覆盖掉。
6.2 增量比对与复核流程
增量比对的核心是看三类变化:新增模块、消失模块、大小变化模块。新增模块是重点,尤其路径落在多层子目录下的;消失模块大多对应进程退出或被杀掉,不单独处理;大小变化要回查文件属性里的修改时间,如果和业务发版时间对不上,就该溯源。复核时我会按顺序做三步:先看路径是否落在系统目录,再看文件是否带数字签名,最后在隔离环境里用加载器把可疑 DLL 加载一次观察行为。这套流程和这份资源本身没有直接关系,但应对生产环境排查时,链路是通的。
从那以后,我每次跑这类运行时排查前都强制走一遍三件事:确认当前终端是管理员会话、确认目标进程位数、把基线目录检查为只读。这三件事看着基础,但每次翻车的根因都出在这三个点上。希望帮到你。
本文还有配套的精品资源,点击获取