ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++进程隐藏实战:从用户态HOOK到内核DKOM摘链

C++进程隐藏实战:从用户态HOOK到内核DKOM摘链

简介:面向Windows平台下使用C++的开发者,这套进程隐藏实现方案详细演示了如何借助系统API让程序在任务管理器等工具中不可见。资源围绕创建进程、显示窗口、获取模块函数等关键操作展开,分别介绍创建时隐藏与运行后隐藏两种思路:前者在启动进程时设置启动信息结构体中的显示窗口标志,并指定为隐藏;后者则通过打开进程句柄并修改窗口属性来实现。同时说明进程隐藏并非绝对安全,高级监控工具仍可发现,并强调必须遵守法律与道德规范。压缩包共包含四十五个文件,以C++源代码、头文件、可执行程序、工程配置、调试记录和界面资源为主,整体大小五点三九兆字节,涵盖多种Visual Studio工程格式,可直接编译运行,也可对照源码学习。已有两千七百四十二人学习下载,工程结构完整,特别适合理解Windows进程创建与窗口管理机制的开发者,同时可作为二次开发的参考模板。

1. 进程隐藏到底藏的是什么:先搞清谁在“看”你的进程

在任务管理器里把一个进程藏起来,听起来像恶意软件才需要做的事,但攻防演练、EDR 规则验证、反病毒样本分析里,进程隐藏也是正经的安全研究课题。所谓进程隐藏,并不是让进程停止运行,而是让系统正常的进程枚举接口“查无此人”:任务管理器、tasklist 列不出来,第三方进程工具也看不到它,可进程的线程还在跑、端口还在听。这篇文章用 C++ 把这件事完整落地一遍:先从用户态 HOOK 讲清怎么骗过 NtQuerySystemInformation,再进内核态摘掉 EPROCESS 链表节点,最后整理我实际踩过的五个坑。适合想搞懂进程枚举机制、又要做授权环境下对抗验证的开发者读。

2. 进程为什么会暴露:枚举 API、内核链表与两条隐藏路线

2.1 任务管理器凭什么列出我的进程:从快照 API 到内核查询

任务管理器列进程,靠的不是自己偷偷遍历,它调用系统公开的进程枚举接口。常见路径是 CreateToolhelp32Snapshot 建一个快照,然后用 Process32First / Process32Next 一条条拉出来;第三方工具也会用 EnumProcesses 这类 API。这两套 API 的底层最终都会汇到 ntdll.dll 的 NtQuerySystemInformation,按 SystemProcessInformation 信息类向内核查询,内核把每个进程的 EPROCESS 信息整理成一条条记录返回。

所以进程暴露的链路其实很短:应用层 API → ntdll 导出函数 → 内核查询 → 返回进程列表。这条链路上任何一环都能做文章,但不同环节的改动,影响面和隐蔽性天差地别。

另外,任务管理器只是“看”的一种方式。杀毒软件和 EDR 不会只依赖这一条链,它们还会注册进程回调(比如进程创建/退出通知)、读内核的线程列表、扫描对象管理器里的句柄表。一个进程如果只是从任务管理器里消失,杀毒软件照样能发现它。这也是很多人做进程隐藏容易产生的错觉:任务管理器看不见,就以为系统里完全不存在。

2.2 进程在内核里的户口本:EPROCESS 与 ActiveProcessLinks

每个进程在内核里都有一个 EPROCESS 结构,可以理解为进程的“户口本”。进程的 PID、父进程 PID、进程名、访问令牌、句柄表指针全在里面。内核通过 EPROCESS 里的 ActiveProcessLinks 双向链表,把所有进程串成一条全局链。系统创建进程时把新节点链到链尾,进程退出时再把节点摘掉。

任何进程枚举逻辑,最终大多是沿着这条链或者基于它复制出的快照在走。换句话说,只要让目标节点的 ActiveProcessLinks 从链表上消失,枚举接口就会认为这个进程不存在。这条链也是后面内核态 DKOM 隐藏的切入点。

这里有个容易被忽略的细节:进程退出时,系统会操作 ActiveProcessLinks 把节点从链上摘除。如果节点已经被你提前摘掉,进程退出时的清理逻辑就会操作一个并不在链上的节点,行为不可预期,严重时直接蓝屏。所以做摘链隐藏,必须先想好恢复方案,这不是可选项,是必选项。

2.3 两条隐藏路线:用户态 HOOK 与内核态摘链

针对上面的暴露链路,常见方案有两类。用户态 HOOK 是把自定义代码接到 NtQuerySystemInformation 的调用路径上,让它返回给调用者的进程列表里没有目标进程。实现门槛低,在应用层就能调试,但它有进程边界:HOOK 只对调用了被 HOOK 模块的进程生效。任务管理器看不到,是因为它加载了你的 HOOK DLL;别的工具没加载,照样能看到目标进程。

内核态摘链是直接操作 EPROCESS 的 ActiveProcessLinks,让内核链表本身失去目标节点。一改全局生效,所有走链表遍历的枚举接口都查不到。代价是风险高,摘错一次就可能蓝屏,而且依赖这条链的功能会连带失效,比如调试器附加、进程树计算、父子进程关系展示。

两条路没有绝对好坏。真实对抗里两者常常叠加:先在内核层把进程从主链摘掉,再在用户态处理掉基于快照、句柄表的补查,最后还要处理进程名、窗口、调试端口这些“周边暴露”。后面的章节先给两条主线的可复现代码,再讲坑。

2.4 选型决策:什么时候用 HOOK,什么时候只能上内核

对比维度用户态 HOOK内核态摘链
生效范围只对注入 HOOK 的进程生效全局生效
实现难度低,应用层可调试高,需要驱动与内核调试
稳定性风险低,出错最多列表乱高,摘错直接蓝屏
恢复难度卸载 DLL 即可必须把链表节点恢复回去
典型场景单工具验证、测试环境攻防对抗、EDR 验证

我一般这样判断:如果在自己的测试环境里验证某个查看器能不能发现额外进程,用户态 HOOK 足够,开发周期以天计。如果要做真正的全局隐藏,或者要验证 EDR 对隐藏行为的检测能力,只能上内核 DKOM,而且必须在虚拟机里先跑通蓝屏恢复流程。两者不是替代关系,而是递进关系。

3. 用 C++ 实现用户态进程隐藏:HOOK NtQuerySystemInformation 的完整落地

3.1 先看清目标结构:SYSTEM_PROCESS_INFORMATION 与遍历规则

NtQuerySystemInformation 在 SystemProcessInformation 信息类下返回的是一串首尾相连的 SYSTEM_PROCESS_INFORMATION 结构。每个结构描述一个进程,末尾的 NextEntryOffset 是相对当前结构起始地址到下一个结构的偏移,等于 0 表示这是最后一个进程。这个字段是整个隐藏方案的核心:只要在返回缓冲区里跳过目标进程对应的那一项,枚举者就只能拿到去掉目标后的完整列表。

typedef struct _SYSTEM_PROCESS_INFORMATION { ULONG NextEntryOffset; // 到下一个进程记录的偏移,0 表示链表结束 ULONG NumberOfThreads; // 线程数 LARGE_INTEGER WorkingSetPrivateSize; ULONG HardFaultCount; ULONG NumberOfThreadsHighWatermark; ULONG CycleTime; LARGE_INTEGER CreateTime; LARGE_INTEGER UserTime; LARGE_INTEGER KernelTime; UNICODE_STRING ImageName; // 进程名,注意是宽字符 KPRIORITY BasePriority; HANDLE UniqueProcessId; // 也就是 PID HANDLE InheritedFromUniqueProcessId; // 后续还有内存统计、IO 统计等字段,隐藏时用不到,省略 } SYSTEM_PROCESS_INFORMATION;

遍历时从缓冲区起始地址开始,每次读当前项的 NextEntryOffset,为 0 就停,不为 0 就加上这个偏移跳到下一项。有两个边界要注意:第一,某些系统进程的 ImageName 是空的,这时不要用字符串比较,直接用 UniqueProcessId 匹配;第二,NextEntryOffset 是相对偏移而不是绝对地址,重组缓冲区时如果直接把结构体拷来拷去,这个相对偏移仍然有效,不用重新换算,但最后一项必须手动置 0,否则调用者会越界读。

3.2 最小可跑的 HOOK 代码:真实地址替换与进程列表重组

HOOK 的目标是 ntdll.dll 导出的 NtQuerySystemInformation。先拿到原始函数地址,再用 API Hook 库把它替换成自己的实现。下面这个代码块是完整的过滤逻辑,可以直接编译成 DLL 后注入到目标查看进程里。

#include <windows.h> #include <winternl.h> #include <vector> // 与 ntdll 中 NtQuerySystemInformation 保持一致的函数原型 typedef NTSTATUS(WINAPI* pfnNtQuerySystemInformation)( SYSTEM_INFORMATION_CLASS SystemInformationClass, PVOID SystemInformation, ULONG SystemInformationLength, PULONG ReturnLength); pfnNtQuerySystemInformation RealNtQuerySystemInformation = NULL; DWORD g_targetPid = 0; volatile LONG g_inHook = 0; // 在返回缓冲区里剔除目标进程记录,返回重组后的有效长度 ULONG FilterProcessList(PBYTE buffer, ULONG length) { ULONG readOffset = 0; std::vector<BYTE> temp(length); // 先拷贝到临时缓冲,避免边读边改 while (readOffset + sizeof(SYSTEM_PROCESS_INFORMATION) <= length) { PSYSTEM_PROCESS_INFORMATION pInfo = (PSYSTEM_PROCESS_INFORMATION)(buffer + readOffset); ULONG next = pInfo->NextEntryOffset; ULONG thisLen = (next == 0) ? (length - readOffset) : next; if ((HANDLE)g_targetPid != pInfo->UniqueProcessId) { CopyMemory(temp.data() + tempLen, buffer + readOffset, thisLen); tempLen += thisLen; } if (next == 0) break; readOffset += next; } // 重组后的链表,最后一项的 NextEntryOffset 必须置 0 ULONG cur = 0; while (cur < tempLen) { PSYSTEM_PROCESS_INFORMATION p = (PSYSTEM_PROCESS_INFORMATION)(temp.data() + cur); ULONG next = p->NextEntryOffset; if (next == 0 || cur + next >= tempLen) { p->NextEntryOffset = 0; break; } cur += next; } CopyMemory(buffer, temp.data(), tempLen); return tempLen; } // Hook 后的替换函数 NTSTATUS WINAPI HookedNtQuerySystemInformation( SYSTEM_INFORMATION_CLASS SystemInformationClass, PVOID SystemInformation, ULONG SystemInformationLength, PULONG ReturnLength) { // 只处理进程信息查询;其它信息类直接放行 if (SystemInformationClass != SystemProcessInformation || g_targetPid == 0 || g_inHook) { return RealNtQuerySystemInformation(SystemInformationClass, SystemInformation, SystemInformationLength, ReturnLength); } InterlockedExchange(&g_inHook, 1); NTSTATUS status = RealNtQuerySystemInformation(SystemInformationClass, SystemInformation, SystemInformationLength, ReturnLength); if (NT_SUCCESS(status) && SystemInformation != NULL) { ULONG realLen = (ReturnLength != NULL) ? *ReturnLength : SystemInformationLength; ULONG newLen = FilterProcessList((PBYTE)SystemInformation, realLen); if (ReturnLength != NULL) *ReturnLength = newLen; } InterlockedExchange(&g_inHook, 0); return status; }

这段代码的关键逻辑有三处。第一,RealNtQuerySystemInformation 必须从 ntdll.dll 的导出表中取,代码里用 GetModuleHandleW 加 GetProcAddress 完成,替换时用现成的 API Hook 库把导出函数入口改成 HookedNtQuerySystemInformation。第二,g_inHook 是递归保护标志,防止在调用原始函数的过程中又回到 Hook 入口形成死循环,实际项目中这个标志还要配合线程 ID 判断,避免多线程误伤。第三,过滤时先把整块数据复制到临时缓冲,再拷回原缓冲区,不要在原缓冲上就地删除,否则后续项的偏移全乱。

3.3 注入范围与线程安全两个必调参数

参数含义注意事项
g_targetPid要隐藏的进程 PID设为 0 表示关闭隐藏,可运行时动态切换
SystemInformationClass信息类编号只有值为 5(SystemProcessInformation)时处理
ReturnLength返回的实际数据长度重组后必须同步修改,否则调用者拷贝出错
g_inHook递归保护标志Interlocked 操作,防止回调重入

注入方式常见的做法是 CreateRemoteThread 加 LoadLibrary,把 DLL 塞进任务管理器之类的目标查看进程。要注意 32 位和 64 位的位数匹配:64 位任务管理器只能注入 64 位 DLL,32 位注入器塞不进 64 位进程。另一个容易翻车的地方是调用者用两次查询来探测长度:第一次传空缓冲区拿 ReturnLength,第二次再传真正缓冲区。HOOK 后第一次查询返回的 ReturnLength 也要被过滤,否则会多传一个进程大小的长度,调用者按这个长度拷贝时会把缓冲末尾的脏数据也读走。

我建议在 DLL 里导出一个设置函数,比如 SetHiddenPid(DWORD pid),外部注入后主动调用一次,而不是在 DllMain 里隐式判断。DllMain 里做 Hook 是一种玄学,加载器锁冲突时可能直接卡死进程,把 Hook 动作放到注入线程里执行要稳得多。

4. 内核态 DKOM 隐藏:从 EPROCESS 摘链看直接内核对象操作

4.1 DKOM 为什么能“一次隐藏,全局生效”

DKOM 全称是 Direct Kernel Object Manipulation,直接操作内核对象。用户态 HOOK 改的是 API 的返回结果,内核里进程链表还是完整的;DKOM 则直接把目标进程的 EPROCESS 节点从 ActiveProcessLinks 双向链表里摘下来。摘完之后,任何基于这条链的枚举都看不到它,这就是“全局生效”的原因。

代价也很直接:进程的父子关系、调试器附加、按进程树结束进程这类操作,都依赖进程链表能遍历到目标节点。节点不在链上,这些功能会连带失效。另外,进程退出时内核会操作链表把节点摘掉,如果节点提前被你摘了,退出清理逻辑拿到一个已经不在链上的节点,轻则逻辑异常,重则蓝屏。所以 DKOM 隐藏必须有严格的恢复路径,驱动卸载前必须把节点插回原位置。

4.2 用驱动模块摘掉 ActiveProcessLinks:代码与参数说明

这里给一个驱动模块里的最小实现,用 PsLookupProcessByProcessId 拿 EPROCESS 指针,再操作它的 ActiveProcessLinks。注意不同系统版本里 ActiveProcessLinks 在 EPROCESS 中的偏移不一样,实战驱动里一般用内核符号或者运行时特征匹配来定位,下面为示意直接把结构缩短了。

#include <ntddk.h> // 简化示意:真实 EPROCESS 很长,ActiveProcessLinks 偏移随版本变化 typedef struct _EPROCESS { // 前面省略大量字段…… LIST_ENTRY ActiveProcessLinks; // 双向链表节点 // 后面省略…… } EPROCESS, *PEPROCESS; KSPIN_LOCK g_hideLock; LIST_ENTRY g_savedLinks; // 保存摘下来的节点,恢复时用 PEPROCESS g_targetProcess = NULL; NTSTATUS HideProcess(DWORD pid) { PEPROCESS target = NULL; NTSTATUS status = PsLookupProcessByProcessId((HANDLE)pid, &target); if (!NT_SUCCESS(status)) return status; // 持有目标进程引用,直到恢复后再解引用 g_targetProcess = target; KIRQL oldIrql; KeAcquireSpinLock(&g_hideLock, &oldIrql); // 保存原节点,并把前驱和后继直接接起来 g_savedLinks = target->ActiveProcessLinks; PLIST_ENTRY prev = target->ActiveProcessLinks.Blink; PLIST_ENTRY next = target->ActiveProcessLinks.Flink; prev->Flink = next; next->Blink = prev; // 清理目标节点自身指针,避免残留引用 target->ActiveProcessLinks.Flink = NULL; target->ActiveProcessLinks.Blink = NULL; KeReleaseSpinLock(&g_hideLock, oldIrql); return STATUS_SUCCESS; } VOID UnhideProcess() { if (g_targetProcess == NULL) return; KIRQL oldIrql; KeAcquireSpinLock(&g_hideLock, &oldIrql); // 先把前后节点重新指向目标,再恢复目标自身的链表指针 PLIST_ENTRY prev = g_savedLinks.Blink; PLIST_ENTRY next = g_savedLinks.Flink; prev->Flink = &g_targetProcess->ActiveProcessLinks; next->Blink = &g_targetProcess->ActiveProcessLinks; g_targetProcess->ActiveProcessLinks = g_savedLinks; KeReleaseSpinLock(&g_hideLock, oldIrql); // 恢复后再解引用,保证目标进程在这期间不会因引用计数归零被销毁 ObDereferenceObject(g_targetProcess); g_targetProcess = NULL; }

摘链的核心是双向链表的常规操作:先把前驱的 Flink 指向后继,再把后继的 Blink 指向前驱,目标节点就从链上断开了。恢复顺序相反,先让前驱后继重新指向目标节点,再恢复目标节点自身的 Flink 和 Blink。KeAcquireSpinLock 是为了避免摘链瞬间有其他线程正在遍历这条链,不加锁在压力测试下大概率翻车。

PsLookupProcessByProcessId 返回的 EPROCESS 指针带有引用计数,隐藏期间必须一直持有,恢复后再 ObDereferenceObject。如果摘链后立刻解引用,目标进程可能随时被系统销毁,g_targetProcess 就成了悬空指针,恢复时直接蓝屏。这是内核方案里最容易踩的隐性坑。

4.3 摘链隐藏的代价:恢复链表、测试签名与蓝屏边界

内核摘链不是一劳永逸。驱动卸载前必须调用 UnhideProcess 把节点插回去,否则进程退出时链表清理逻辑会操作一个失联节点,系统直接 BugCheck。实际开发中我习惯做一个配套的恢复工具,单独加载一个只做恢复的驱动,专门处理“隐藏驱动已经蓝屏或卡死”的场面。

加载驱动本身也有门槛。测试机器上要先开启测试签名模式,否则驱动加载被拒;正式环境里无签名驱动基本加载不进去。开发期我一般全程在虚拟机里操作,快照随手打,蓝屏了回滚快照继续调,不要拿日常办公机器做实验,这是血泪经验。

还有一层边界容易被忽略:摘链隐藏挡得住“主动枚举”,挡不住“基于事件的检测”。进程创建回调、线程创建回调、对象管理器访问这些路径不走进程链表,杀毒软件通过回调照样知道进程存在。所以内核摘链适合作为隐藏方案的一层,而不是全部。

5. 进程隐藏避坑指南:五条踩坑记录与处理办法

5.1 现象:HOOK 后整个进程列表被截断,只剩几条记录

某次我把 HOOK DLL 注入任务管理器后,进程列表从几十条变成三条,剩下的进程全不见了。原因不在过滤逻辑,而在最后一项的 NextEntryOffset 没有置 0,调用者沿着偏移读到缓冲区外,提前终止了遍历。

处理办法是在 FilterProcessList 末尾强制把重组后的最后一项 NextEntryOffset 写成 0,并且在测试时先打印原始链表和重组后的偏移值逐项对比。改完后再用任务管理器快照对比进程数,确认不是“少了一条”而是“完整列表去掉目标”。

5.2 现象:摘链后虚拟机瞬间蓝屏

第一次写内核摘链时,我在驱动里直接把 System 进程当目标试,加载驱动后系统直接蓝屏。原因是 System 进程被摘链后,内核调度和进程管理逻辑大量依赖这条链,节点失联导致遍历拿到悬空指针。

后来我只对测试用的普通应用进程做隐藏,并且摘链前检查 PID,拒绝所有小于一定数值的系统关键进程。另外蓝屏后看 dump,大多是链表遍历在 KeAcquireSpinLock 之外竞争导致的,把摘链和恢复都收敛到同一把自旋锁里,问题就消失了。

5.3 现象:任务管理器不显示目标进程,第三方工具仍能看到

用户态 HOOK 只对注入了 DLL 的进程生效。任务管理器被注入了,看不到目标;另一个没被注入的进程查看工具,直接走自己的枚举路径,目标进程原样出现在列表里。

这种不一致会让人误以为 HOOK 失败,实际上它是“生效范围”问题。要覆盖更多查看工具,要么把 DLL 注入到系统里所有可能被用来查进程的进程,要么直接换内核摘链方案从全局链表上断开。做验证时先明确目标:是要骗过某一个工具,还是要骗过整个系统。

5.4 现象:隐藏后守护进程找不到目标,退出命令也失效

把进程从链表摘掉后,所有按进程名或 PID 重新枚举的守护逻辑全部失效。原来守护进程每分钟 CreateToolhelp32Snapshot 一次确认目标还活着,现在快照里没有目标,守护进程立刻触发重启逻辑,结果拉起了一个重复实例。

处理办法是隐藏前让守护进程拿到目标进程的句柄并保持打开,后续用 WaitForSingleObject 等句柄信号判断存活,而不是反复枚举。退出控制也改成显式信号,比如命名事件或文件标记,隐藏后的进程自己监听这个信号再退出。

5.5 现象:杀毒软件直接杀掉主程序和驱动

Hook ntdll、加载无签名驱动、进程被摘链,这三个行为单独拿出来都是安全软件的高危特征,叠加在一起基本等于给自己贴了恶意标记。开发期最好在隔离虚拟机里做,杀毒软件装不装都随缘;要带到别的环境验证,提前在安全软件里加白名单。

这也是我后来调整工作方式的原因:隐藏方案本身没有对错,但“在什么环境验证、是否获得授权”决定了这个技术是测试工具还是恶意样本。先确认授权边界再动手,能省掉后面所有解释成本。

6. 隐藏效果验证与后续对抗:从任务管理器到内核调试器

6.1 验证隐藏是否生效的最小检查链

先用系统自带工具验证常规枚举是否失效。命令行下用 PID 和进程名分别查一次:

tasklist /fi "PID eq 1234" tasklist /fi "IMAGENAME eq demo.exe"

返回“没有运行的任务”说明应用层枚举已经看不到目标。再打开任务管理器确认列表里没有目标进程。但到这里只能证明“常规工具不可见”,不能证明“内核不可见”。更严格的验证要在虚拟机里挂内核调试器,查看目标进程 EPROCESS 的 ActiveProcessLinks 是否已经被改写,前驱和后继是否已经绕过目标节点直接相连。

第三方进程查看工具也要交叉验证一遍,它能看到说明还有暴露面没堵上。完整的验证顺序是:任务管理器 → tasklist → 第三方工具 → 内核调试器,逐层确认,哪一层还看得到就补哪一层。

6.2 隐藏的后续对抗:当进程既要藏又要正常运行

隐藏一旦生效,进程就进入“除了看不见,其它都正常”的状态。真正的对抗实践中,单纯隐藏 PID 通常不够,我一般还会叠加三层:一是进程名伪装,把目标进程名改成与被控机器上常见系统进程相似的名字;二是窗口和终端隐藏,避免桌面上多个不明窗口;三是启动行为和通信特征收敛,让进程的资源占用曲线看起来像一个闲置服务。

每一层都有验证方法:进程名伪装后重新跑 6.1 的检查链;窗口隐藏后截屏确认;通信特征收敛后用流量查看工具对比基线。没有验证的隐藏都是自我安慰。

我现在的习惯是每次做进程隐藏验证前,先在虚拟机里准备好恢复脚本,确认能在一个命令内完成驱动卸载和链表恢复,再开始测试。这个习惯把蓝屏从“事故”变成“一次普通调试”,省下了大量重装系统的时间。隐藏技术越往下做,越需要敬畏系统底层的约定,希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进