C++实现DLL劫持检测:进程模块枚举与路径比对
简介这是一款面向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::vectorstd::wstring modules; // 完整路径按加载顺序保存 }; std::vectorProcessInfo EnumerateProcesses() { std::vectorProcessInfo 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。如果白名单里只写死 System3232 位进程的 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:c17 /EHs /MT /O2 /DUNICODE /D_UNICODE /W4 ^ main.cpp /Fe:dllcheck.exe参数说明/std:c17 启用 C17主要为了用 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-jsonJSON 格式输出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 访问映射到 SysWOW64Module32FirstW 返回的路径已经是被重定向后的结果。如果白名单比对只拿 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 加载一次观察行为。这套流程和这份资源本身没有直接关系但应对生产环境排查时链路是通的。从那以后我每次跑这类运行时排查前都强制走一遍三件事确认当前终端是管理员会话、确认目标进程位数、把基线目录检查为只读。这三件事看着基础但每次翻车的根因都出在这三个点上。希望帮到你。本文还有配套的精品资源点击获取