简介:这是一份面向具有C++基础、希望深入Windows底层机制的开发者的隐藏进程示例源码包,通过完整工程演示如何让指定进程在任务管理器等界面中不可见,帮助读者学习进程管理、系统调用与权限控制的实际用法。压缩包共14个文件,核心为3个C++源文件与3个头文件,并包含dsp、dsw等VC工程配置、rc资源脚本及用于说明的文本,整体大小仅32KB,文件类型区分明确,便于按模块参看;已有155人学习下载。源码涉及OpenProcess、SetProcessWindowStation等关键API的调用,以及窗口站与桌面切换、权限提升、反调试等底层操作,从代码中可学习隐藏进程的典型实现路径,并理解系统调用与用户态程序之间的协作关系;附带说明文档还能提供使用背景与注意事项,方便读者按需修改实验。需要强调的是,这类技术存在被滥用于恶意软件隐藏的风险,学习与实践务必严格遵守法律和职业道德。
1. 进程隐藏不只是藏隐私:Hide_Sample.zip 解决的是"被看见"的问题
拿到 Hide_Sample.zip 这类示例包的人,通常不是在写恶意软件,而是在做红队评估、EDR 能力验证或者防御对抗研究。进程隐藏这个动作,本质是在回答一个问题:一个进程能不能在常规观测手段面前不被看见?如果任务管理器看不到、Process Explorer 看不到、Sysinternals 全家桶也扫不到,这个隐藏才算初步立住。包里通常不外乎三条技术路线:断掉 PEB 模块链、Hook 系统查询接口、在内核态操作进程链表。适合谁?做授权安全测试、评估主机防御盲区的一线人员,或者想搞明白"为什么有些进程宁可自曝也要藏着"的逆向入门者。先说结论:进程隐藏不是隐身衣,它只是降低暴露面的手段,理解这一点比会写代码更重要。
2. 先搞清任务管理器怎么"看见"进程:两层枚举路径与隐藏生效的三个切入点
2.1 用户态入口:CreateToolhelp32Snapshot 的遍历行为
任务管理器、tasklist、大多数进程查看工具在用户态拿进程列表,走的是 CreateToolhelp32Snapshot。这个 API 的行为是拍一张当前系统的进程快照,然后用 Process32First、Process32Next 逐条读出来。快照里不止有 PID 和进程名,还会带出父进程 PID、线程数、内存占用等信息。
HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); PROCESSENTRY32 pe = { 0 }; pe.dwSize = sizeof(PROCESSENTRY32); if (Process32First(hSnap, &pe)) { do { // 第一次遍历到目标进程做到标记,后续逻辑判断是否展示 if (_wcsicmp(pe.szExeFile, L"target.exe") == 0) { wprintf(L"PID=%lu PPID=%lu\n", pe.th32ProcessID, pe.th32ParentProcessID); } } while (Process32Next(hSnap, &pe)); } CloseHandle(hSnap);这段代码就是绝大多数枚举工具的用户态骨架。关键点在于:CreateToolhelp32Snapshot 内部并不是自己去内核里翻进程对象,而是调用了 NtQuerySystemInformation,把系统返回的进程信息拷贝到用户态缓冲区。也就是说,无论工具怎么包装,走到最底层都是同一个系统服务。我们做隐藏,第一反应自然是"让这个查询接口返回的结果里没有我"。
2.2 内核态入口:NtQuerySystemInformation 如何串起进程链表
NtQuerySystemInformation 的信息类参数用到 SystemProcessInformation(值为 5)时,返回的是一段连续缓冲区,里面是 SYSTEM_PROCESS_INFORMATION 结构的数组。每个结构体末尾有个 NextEntryOffset 字段,指向下一个进程信息结构的偏移,为 0 表示枚举结束。真实的内核实现里,这个函数会遍历系统活动进程链表——也就是从 EPROCESS 结构上的 ActiveProcessLinks 字段串起来的那条双向链表。
这条链路非常重要:任务管理器显示的内容来自 NtQuerySystemInformation,而 NtQuerySystemInformation 读取的又是 EPROCESS 的 ActiveProcessLinks。所以"隐藏进程"本质上只有三个下手的位置:最上层让 API 返回结果失真、中间层让系统服务遍历不到目标节点、最底层直接修改链表本身。用户态 Hook 属于第一个,PEB 断链属于第二个(但只影响模块枚举路径),DKOM 摘链属于第三个。搞清楚敌人怎么看你,才知道该在哪个环节藏。
2.3 隐藏的切入点:PEB、API 返回值和 EPROCESS 链,分别能被谁看到
三个切入点的效果差异很大,不能混为一谈。PEB 断链影响的是依赖 PEB 里 Ldr 模块信息的工具,比如某些进程查看器在显示进程路径时要读 PEB,断链后读不到模块路径,但进程仍然会显示在列表里。Hook NtQuerySystemInformation 影响所有走这条 API 的枚举工具,覆盖面最广,进程列表里直接消失。EPROCESS 摘链影响的是内核里遍历 ActiveProcessLinks 的逻辑,任务管理器这类依赖系统服务的工具就看不到了。
但要清醒:三种做法都没有动进程对象本身。进程在内核里依然存在,调度照常,句柄照常,只是"按链表巡游"看不到它。EDR 产品早就学乖了,它们不会只依赖一条枚举路径。把三个切入点和"谁看不见"对应起来,是选型的第一步。做测试时我一般先问一句:要防的是任务管理器,还是 Process Explorer,还是内核调试器?答案直接决定方案复杂度。
3. 用户态隐藏落地方案:断链 PEB 与 Hook 查询接口的最小实现
3.1 断链 PEB:从任务管理器进程列表里拿下指定进程
断链 PEB 的隐藏逻辑很简单:Windows 加载器在进程启动时,会把加载的模块串成几条链表,挂在 PEB(进程环境块)的 Ldr 结构上。工具想显示进程路径或模块列表时,会遍历这条链。我们把目标进程的模块节点从链表里摘掉,它就从"模块视角"里消失了。
PPEB pPeb = NtCurrentTeb()->ProcessEnvironmentBlock; PLIST_ENTRY pHead = &pPeb->Ldr->InLoadOrderModuleList; PLIST_ENTRY pCur = pHead->Flink; while (pCur != pHead) { PLDR_DATA_TABLE_ENTRY pEntry = CONTAINING_RECORD(pCur, LDR_DATA_TABLE_ENTRY, InLoadOrderModuleList); // 找到目标模块:比较 DLL 名称,不区分大小写 if (wcsstr(pEntry->BaseDllName.Buffer, L"target.exe")) { // 让前一节点的 Flink 跨过本节点,指向后一节点 pCur->Blink->Flink = pCur->Flink; // 让后一节点的 Blink 指回前一节点,完成双向脱离 pCur->Flink->Blink = pCur->Blink; // 自己指向自己,标记该节点已不在任何链上 pCur->Flink = pCur->Blink = pCur; break; } pCur = pCur->Flink; }这段代码里最容易被忽略的是最后一步:把节点的 Flink 和 Blink 都指向自己。如果不做这一步,链表的完整性虽然不受影响,但后续如果有人遍历时误入了这个悬空节点,会把已经脱链的 Flink 当成有效地址去解引用,直接访问违例。断链后,Process Explorer 在显示进程路径时会读不到 target.exe 的映像路径。但这个方案的局限也很明显:它只影响模块链,进程本身的 PID 还在。任务管理器不读模块链,所以它照样能看到进程,只是看不到模块路径。断链更适合做辅助手段,配合其他隐藏手法用。
3.2 Hook NtQuerySystemInformation:在枚举结果里抹掉 PID
Hook NtQuerySystemInformation 是用户态隐藏的另一个分支。思路不是让进程从链表里脱开,而是让查询接口的返回结果"看似正常"。常见做法是用 Detours 或 MinHook 挂钩 NtQuerySystemInformation,在原始函数返回后,对缓冲区里的进程结构数组做一次过滤,把目标 PID 对应的那一条信息抹掉。
// 过滤回调函数:在 NtQuerySystemInformation 返回后执行 PVOID FilterSystemProcessInfo(PVOID buf, ULONG bufSize, ULONG targetPid) { SYSTEM_PROCESS_INFORMATION *pPrev = NULL; SYSTEM_PROCESS_INFORMATION *pCur = (SYSTEM_PROCESS_INFORMATION *)buf; ULONG_PTR endAddr = (ULONG_PTR)buf + bufSize; while ((ULONG_PTR)pCur < endAddr && pCur->NextEntryOffset != 0) { if (pCur->UniqueProcessId == targetPid) { if (pPrev != NULL) { // 前一项的偏移累加上当前项的偏移,实现“跳过” pPrev->NextEntryOffset += pCur->NextEntryOffset; } // 当前项被跳过后的衔接细节:注意保持尾部偏移为 0 if (pCur->NextEntryOffset == 0 && pPrev != NULL) { pPrev->NextEntryOffset = 0; } break; } pPrev = pCur; pCur = (SYSTEM_PROCESS_INFORMATION *)((PUCHAR)pCur + pCur->NextEntryOffset); } return buf; }这里最容易写错的是偏移累加逻辑。SYSTEM_PROCESS_INFORMATION 是变长结构,后面跟着线程信息,NextEntryOffset 表示从当前结构起点到下一条结构的距离。抹掉当前项时,前一结构的 NextEntryOffset 要加上当前结构的 NextEntryOffset,这样后续遍历才不会错位。如果目标项的首项,直接把缓冲区起点向后挪会出现前面有一段残余垃圾,更稳的做是整段 memmove,并把缓冲区长同步缩小。实际工程里很少有人用用户态钩子做长期隐藏,因为它对调用方可见:检查 Ldr 的模块列表或内存里的 IAT 就能发现挂钩痕迹,而且现在已经有很多检测工具专门扫用户态 inline Hook 的指令头是否被改写成 jmp。
3.3 用户态做法的上限:内存式枚举与句柄遍历为什么拦不住
用户态隐藏的天花板不是代码写得不好,而是观察者根本不用你藏的那条路。直接读内核内存的工具,比如 WinDbg 的 !process 0 0,它会从 PspCidTable 或活动进程链里枚举进程,用户态 Hook 完全不影响它。按句柄遍历的工具走 NtQueryObject 枚举系统的句柄表,进程对象只要有句柄存在就能被看到。ETW 的 Microsoft-Windows-Kernel-Process 事件会在进程创建时留下痕迹,这属于"日志视角",过滤 API 管不到。WMI 的 Win32_Process 也有独立的事件存储。
所以我对用户态隐藏的定位一直很明确:它能应付任务管理器、tasklist、绝大多数图形化进程查看器,适合在隔离测试环境里验证"降低暴露面"的效果,但不适合对抗有安全产品监控的环境。做红队评估时,用户态隐藏往往只作为整体方案里的一个小环节,用来拖慢分析人员的手工排查节奏,真正的重头戏还是在内核态。
4. 内核态隐藏落地方案:摘除 ActiveProcessLinks 的 DKOM 过程
4.1 定位 EPROCESS:从 PID 到 ActiveProcessLinks 偏移
内核态隐藏的核心思路是直接从链表层面把进程拿掉。所有活动进程都挂在 EPROCESS 的 ActiveProcessLinks 字段构成的双向链表上。摘掉这个节点,常规枚举路径就找不到目标进程。第一步是拿到目标进程的 EPROCESS 地址。
PEPROCESS eproc = NULL; NTSTATUS status = PsLookupProcessByProcessId((HANDLE)targetPid, &eproc); if (!NT_SUCCESS(status)) { DbgPrint("PsLookupProcessByProcessId failed: 0x%X\n", status); return status; } // ActiveProcessLinks 的偏移因系统版本而异,务必用工具确认后再写死 LONG activeLinksOffset = 0x448; // 示例值,不同 build 差异很大 PLIST_ENTRY pEntry = (PLIST_ENTRY)((PUCHAR)eproc + activeLinksOffset); // 打印当前进程的链上前驱和后继,确认偏移是否有效 DbgPrint("Flink: 0x%p Blink: 0x%p\n", pEntry->Flink, pEntry->Blink); // 使用完毕要释放引用 ObDereferenceObject(eproc);ActiveProcessLinks 偏移是整个方案里最容易翻车的地方。Windows 10 的不同 build 之间偏移量可能差几十个字节,Windows 11 又变了一次。写死一个偏移就期望在所有机器上跑通,纯属玄学。我一般会在双机调试环境里先用 dt _EPROCESS 命令确认当前系统符号里的偏移值,再决定要不要编译进驱动。如果目标系统没有符号,就从已知的几个 build 版本里推一个接近值,然后加载驱动后在调试器里核对链表指针是否指向合法地址——宁可先验证再上线,也不想把测试机弄蓝屏。
4.2 摘链操作:双向链表删除、自旋锁与恢复时机
摘链的代码模型很简单,就是标准双向链表删除。真正麻烦的是并发安全:在摘链的瞬间,可能有其他线程正在遍历这条链表,比如任务是管理器刚好在刷新列表。如果不做同步,读到的指针可能处于中间状态,直接蓝屏。常见做法是提升 IRQL 到 DISPATCH_LEVEL 禁用当前 CPU 上的线程调度,或者用内核自旋锁保护链表操作。
// 在驱动例程的上下文里摘除进程链节点 LIST_ENTRY *pEntry = (LIST_ENTRY *)((PUCHAR)eproc + activeLinksOffset); // 提升 IRQL,减少并发遍历打断链表操作的概率 KIRQL oldIrql = KeRaiseIrqlToDpcLevel(); // 保存相邻节点指针 PLIST_ENTRY pFlink = pEntry->Flink; PLIST_ENTRY pBlink = pEntry->Blink; // 前驱节点的 Flink 指向后继节点 pFlink->Blink = pBlink; // 后继节点的 Blink 指向前驱节点,双向链恢复 pBlink->Flink = pFlink; // 目标节点的 Flink/Blink 指向自己,标记为脱链 pEntry->Flink = pEntry->Blink = pEntry; // 恢复原来的 IRQL KeLowerIrql(oldIrql);摘链后目标进程依旧正常运行,调度的优先级、内存状态、句柄、线程都不受影响,受影响的是所有依赖 ActiveProcessLinks 的遍历逻辑。这里有一个重要边界:如果进程本身要退出,脱链后系统的进程清理逻辑在卸载进程对象时也可能遍历链表,某些场景下会因找不到节点而出问题。所以摘链方案不是"摘了就不管",驱动力必须在进程退出前把节点放回链表,保证生命周期结束时的清理路径是完整的。放回的操作就是删除的逆过程:把 Flink 和 Blink 重新接到原位置,需要注意进程创建和销毁的时序。
4.3 加固思路:ObRegisterCallbacks 与创建进程回调配合使用
摘链能解决"列表里看不见",但看不见不代表不能操作。安全产品可以直接用 PspCidTable 按 PID 找到进程对象,也可以注册进程创建回调(PsSetCreateProcessNotifyRoutine),在进程刚创建时就拿到通知。这些路径不依赖 ActiveProcessLinks,摘链对它们无效。所以我做内核态隐藏时,从来不会只靠 DKOM 一招,通常是三层配合:第一层摘 ActiveProcessLinks 解决常规枚举,第二层用 ObRegisterCallbacks 拦截对目标进程的句柄操作,第三层注册生命周期回调,监控进程退出状态以便及时恢复链表。
ObRegisterCallbacks 的作用是注册一个回调,每当有人尝试打开进程句柄时,回调会收到请求,可以决定放行还是拒绝。对目标 PID,直接让它返回 STATUS_ACCESS_DENIED。这样就算枚举到了进程,打开句柄失败也会让后续的内存读取、线程操作无从下手。这个组合比单纯摘链抗造得多。不过要注意,ObRegisterCallbacks 在 Windows 7 之后的很多版本上使用门槛低,但要处理 ALTITUDE 值冲突,数值注册太高会被其他安全软件抢先拦截注册失败。调这个函数本身不需要签名驱动,这也是一部分测试工具选择它的原因。
5. 隐藏后的自证与高频翻车点排查:哪些工具还能看见你
5.1 用 Process Explorer 和内核调试器验证隐藏是否生效
写完隐藏逻辑,第一件事不是看效果,而是确认"在哪个视角生效、在哪个视角失效"。只拿任务管理器验证远远不够,我习惯准备一张验证清单:任务管理器、tasklist、Process Explorer、PowerShell Get-Process、内核调试器的 !process 命令,逐个记录目标进程出现与消失的情况。
# 站在普通用户视角验证隐藏效果 tasklist /fi "imagename eq target.exe" Get-Process | Where-Object { $_.ProcessName -eq 'target' }如果这两条命令看不到目标进程,说明用户态的枚举路径已经被成功阻断。接下来用 Process Explorer 再确认一次,默认视图下它也是走系统服务枚举进程,看不到是正常的。真正分水岭在内核调试器:在 WinDbg 里执行 !process 0 0 target.exe,它走的是活动进程链。如果这条命令也看不到,说明摘链生效了。但我还会执行 !handle 0 0 target 或者直接看 PspCidTable,看到进程仍然存在时不要惊讶——这就是隐藏的边界:脱离了链表,但没有脱离对象管理器。
5.2 翻车点 1:摘链后进程退不干净,系统里留下退不掉的"幽灵"
现象是目标进程在任务管理器里消失得很成功,但等它自然退出时,进程对象没有正常释放,过一会儿你用 Windows 自带资源监视器或者内核调试器能看到僵尸进程堆积。原因是摘链破坏了链表关系,进程对象在退出清理阶段按链表找节点时发现目标不在链上,某些清理逻辑被跳过。另一个常见原因是目标进程被其他组件持有引用,比如你的测试工具自己打开过它的句柄没有关闭。
解决办法分两步。第一,摘链时保留原始 Flink/Blink,进程退出前先把节点放回链表,让系统走正常清理路径。第二,隐藏进程退出前,由驱动进程主动通知目标线程做一次常规退出流程,不要直接调 NtTerminateProcess 强制结束。我在自己的测试里吃过这个亏,最后养成的习惯是给驱动加一个卸载恢复函数:在驱动卸载时遍历隐藏列表,把每个脱链节点放回原位,保底避免残留。
5.3 翻车点 2:偏移写错直接蓝屏,0xD1 的定位思路
现象是驱动一加载就蓝屏,或者一操作目标进程就蓝屏,错误码常见是 0xD1(DRIVER_IRQL_NOT_LESS_OR_EQUAL)或者 0x50(PAGE_FAULT_IN_NONPAGED_AREA)。原因是 ActiveProcessLinks 偏移在当前 Windows build 上不对,你拿着错误的偏移去解引用内存,读出来的所谓 Flink/Blink 根本不是链表节点的地址,一操作就访问违例。这也是摘链方案最常见、最劝退新手的坑。
解决思路是不要凭记忆写偏移。在双机调试环境里先打开内核调试器,用 dt _EPROCESS 命令确认 ActiveProcessLinks 在当前系统的实际偏移,再把值填进代码。更稳的做法是运行时动态判断:读取几个已知版本的特征偏移,通过遍历链表的合法性校验来选择合适的偏移值。我见过不少驱动直接写了硬编码偏移,换一台 Windows 10 22H2 之外的机器就崩,这属于典型的"能用但不敢复用"代码。注意 0xD1 蓝屏时调试器里看崩溃指令地址,如果落在链表操作那几行,基本可以断定是偏移问题。
5.4 翻车点 3:EDR 不靠进程链也能发现你,回调与 ETW 是盲区
现象是隐藏之后任务管理器和 Process Explorer 都看不到,但安全产品照样弹告警,日志里明确记录了进程名和 PID。原因是现代 EDR 的进程监控早就依赖多种信息来源:内核回调(PsSetCreateProcessNotifyRoutine)在进程创建时就会通知注册者,ETW 的进程事件会记录完整的创建过程,还有一些产品直接扫描 EPROCESS 结构本身做一致性校验,检测到 ActiveProcessLinks 异常断开就会标记可疑。
解决思路是调整预期。进程隐藏解决的是"交互式排查"层面的问题,不解决"主动监控"层面的问题。如果你在评估 EDR 产品的能力边界,隐藏方案更应该用来测试它的检测深度,而不是指望真的绕过它。做这类测试建议在隔离的授权环境里进行,不要拿隐藏能力去做未经授权的对抗,那不是技术问题,是合规问题。
5.5 翻车点 4:调试器视角下隐藏"失效",PspCidTable 是绕不开的名单
现象是 Windows 内核调试器下,!process 0 0 看不到目标进程,但用 !handle 遍历句柄表时,目标进程依然存在。原因是对象管理器有独立的 ID 表(PspCidTable),每个内核对象在创建时都会登记进去。摘掉 ActiveProcessLinks 只影响了进程链的遍历,PspCidTable 不会被摘链行为改动,内核调试器完全可以走对象管理器的路径反查进程。
这个坑没有解决办法,只能接受。隐藏的本质是降低可见性,不是消灭对象。内核对象依然存在,句柄依然存在,内核调试器更是站在上帝视角。如果你认为摘链后所有人都不该找到它,那是理解有误。我做方案设计时会把 PspCidTable 的存在当成边界条件,而不是当成 bug 去处理。理解了这条,你就不会为他人的"从内核里还能找到"而焦虑了。
6. 再进一步:用"过滤式"方案替代破坏式摘链,并做一套隐藏自检
DKOM 摘链的破坏性已经说得很清楚了:并发风险、偏移依赖、进程退出时的清理异常、EDR 的一致性校验。所以我现在的做法已经很少直接摘链,而是转向更保守的方案——在系统服务层做过滤,保留链表完整性,从根源避免那些蓝屏和残留坑。
内核态过滤的技术路线是在系统进程枚举返回路径上做拦截。常见做法是通过修改系统服务表的入口来过滤 SystemProcessInformation 的返回结果,但 PatchGuard 对系统服务表的校验让这个方案变得很冒险。更推荐用 Minifilter 或者内核回调配合的"旁路过滤"思路:不破坏链表,而是让目标进程的枚举结果在返回用户态之前被抹掉。这样进程对象仍然在链上,生命周期完全正常,不会出现退不干净的问题。拍板之前要做一次成本评估:摘链方案侵入性大但实现直接,过滤方案安全性高但依赖内核版本和符号。
配套的隐藏自检也比较重要,我习惯在测试环境里跑这样一组命令,输出验证矩阵:
| 观察视角 | 工具 | 断链 PEB 效果 | 摘链 EPROCESS 效果 | 内核过滤方案效果 |
|---|---|---|---|---|
| 用户态枚举 | 任务管理器 / tasklist | 看不到路径,PID 可见 | 完全不可见 | 完全不可见 |
| 用户态枚举 | Process Explorer | DLL 视图受影响 | 完全不可见 | 完全不可见 |
| 内核链遍历 | WinDbg !process 0 0 | 可见 | 不可见 | 不可见 |
| 对象管理器 | WinDbg !handle / PspCidTable | 可见 | 可见 | 可见 |
| 事件日志 | ETW 进程事件 | 可见 | 可见 | 可见 |
看到这张表,你就明白隐藏方案的真实边界在哪里。它能让交互式工具和常规枚举失效,但对象管理器、ETW、内核调试器仍然可以看到进程,这是隐藏技术的物理边界。
我早期做摘链方案翻车无数次,每次都栽在偏移和并发这两个问题上,后来养成的习惯是:先在双机调试里确认偏移,再在驱动里加一枝完整的恢复路径,最后跑完自检测试矩阵才算收工。把"隐藏"当成一个工程问题来看,就不会被它的神秘感带偏。这个方向适合在授权环境、隔离环境里做能力验证,也适合逆向研究者研究系统机制。希望帮到你。
本文还有配套的精品资源,点击获取