Windows内核驱动实现进程隐藏:从EPROCESS到SSDT挂钩的攻防实战
简介这是一份聚焦 Windows 平台进程隐藏技术的 C 工程资源面向系统安全研究人员、驱动开发者以及有基础的逆向分析爱好者解决常见任务管理器或安全工具难以察觉指定进程的问题。内容覆盖应用层钩子、注册表隐藏、系统 API 调用以及驱动级隐藏等实现思路适合用来梳理进程枚举对抗、内核交互和驱动加载机制。压缩包共 33 个文件、约 588KB包含 C/C 源码、Visual Studio 解决方案与项目配置、驱动安装所需的 INF 文件、编译产物、PDB 调试符号、流程图示和文本说明等工程结构完整可直接打开调试。这一主题已吸引 1465 人学习在安全技术社区具有一定的参考价值。通过阅读源码和图示可理解从用户态调用隐藏到驱动层绕过检测的完整路径结合驱动配置与说明文档还能掌握内核模块在进程隐藏中的具体作用对深入系统安全、恶意软件分析与反调试研究颇有帮助。1. 从黑匣子到底层博弈这标题背后到底是什么进程隐藏这类需求通常只有两种人会找上门一种是做安全对抗研究的另一种是被恶意程序折腾到想反向追踪的。标题里的“完结篇”“隐藏驱动”这些词本身就点明了这不是一个改改进程名的简单 trick。核心思路是用驱动态代码直接干预系统对进程的枚举逻辑让目标进程在任务管理器、命令行工具和网络连接列表里彻底“消失”但进程本身还在正常运行。这个方向不依赖任何第三方监控软件而是在操作系统内核层级做文章所以它既是攻防演练里的常客也是杀软检测能力的试金石。这篇文章会从进程在内核里的存储结构讲起到用户态隐藏方法的边界再到驱动态实现的框架和参数细节最后落在检测与对抗的验证手段上。适合已经有 C/C 基础、想深入理解 Windows 内核机制或者正为“为什么我的进程总是被杀软揪出来”而头疼的开发者。内容偏实战我会把能落地的部分直接写成可抄的代码和操作步骤踩过坑的地方也会单独拿出来说。2. 进程对象在内核里的存放方式理解隐藏为什么是“不可能的任务”2.1 EPROCESS 双向链表系统认知进程的唯一线索Windows 内核维护着一张全局的进程链表每个进程都有一个 EPROCESS 结构体里面存着进程的 ID、句柄表、内存管理信息和各类安全字段。系统枚举进程时各种 API 底层几乎都是遍历这个链表。也就是说只要你能在这条链上“断开”某个节点上层工具自然就看不到它了。但这里有个关键点断开链表节点不等于销毁进程。进程的调度、内存回收仍然由内核的其他机制管理。这正是隐藏技术的核心矛盾——你要让系统“看不见”它同时还要让系统正常“养”着它。如果只是简单摘链很多依赖进程链表的系统功能会异常比如无法正常退出、父子进程关系断裂。常见的遍历路径是系统服务描述表里的 ZwQuerySystemInformation这个函数会把进程信息从内核拷贝到用户态缓冲区。所有用户态工具包括任务管理器最终都绕不过这个入口。所以进程隐藏的实质就是想办法让这条查询路径对目标进程“选择性失明”。2.2 从 SSDT 到回调机制内核态的三个干预点理解了进程链表的地位接下来要找到干预的位置。这里有三条路径我按推荐优先级排序第一是过滤 ZwQuerySystemInformation 的返回结果。这是最彻底的方案相当于在内核层把信息源掐住所有用户态工具拿到的都是过滤后的数据。实现上需要挂钩系统调用或者直接修改函数指针。第二是注册进程回调。通过 PsSetCreateProcessNotifyRoutine 这类内核 API在进程创建和退出时收到通知。这主要用于监控而不是隐藏但如果你配合回调来维护一份“受保护进程名单”再结合第一点就能实现精准过滤。第三是绕过链表的替代枚举方式。有些安全工具不依赖链表而是通过句柄表扫描或直接遍历活动进程的调度队列来找进程。这种方式会直接拆穿你的隐藏。选择干预点的核心依据是隐藏动作要尽量靠近信息源头。越靠近源头过滤面越完整越靠近用户态越容易被其他工具交叉验证发现。3. 用户态进程隐藏的三种常见套路为什么总是撑不过三秒3.1 伪装的边界改头换面与断链陷阱用户态最常见的“隐藏”其实是伪装。改进程名、把窗口标题改成系统进程的名字、伪造父进程这些手段实现的只是“看起来不像”而不是“看不见”。检测方只需要通过进程链表的父子关系或者比对进程映像路径与签名信息就能立刻识别出来。另一种用户态操作是直接摘链。一些老代码会在用户态通过 NtQuerySystemInformation 的返回结构里手动修改 NextEntryOffset让目标进程在遍历结果中跳过。这种做法的问题在于它只改了“发给当前调用者的这份拷贝”其他进程枚举时看到的还是原样。而且这种越界修改很容易破坏返回缓冲区导致系统进程直接崩溃或是蓝屏。我做技术方案选型时基本不推荐用户态方案原因是它的可检测性太强。任务管理器不显示不等于进程不存在网络连接、句柄表、内存扫描、行为检测任何一个维度都能把你揪出来。3.2 客制化工具链命令行与任务管理器的“过滤视角”那为什么还有那么多工具在宣传“一键隐藏进程”原因在于它们通常结合了多种能力启动时提权到系统权限把进程令牌标记为受保护进程再配合驱动过滤。单独用命令行工具去隐藏进程本质上只是改注册表让它在某些列表里不显示比如从 HKEY_LOCAL_MACHINE 的 Image File Execution Options 里动手脚。这里有个很常见的翻车场景某开发者做了个用户态“隐藏工具”在自己 Win10 上测得好好的换成 Win11 就打不开系统设置原因是版本更新的系统对关键注册表项的访问控制更严了。这类工具能用的前提是系统权限足够高或者干脆带一个驱动来干活。也就是说用户态方案再怎么包装最后的可靠性还是依赖内核态能力。3.3 从用户态到驱动态绕不过去的一个分水岭想稳定隐藏且不被常见检测工具发现唯一可靠的做法就是写驱动。这个过程里有一个关键门槛签名与加载策略。64 位系统默认会要求驱动程序具备有效的内核签名开启安全启动的机器还要额外校验。解决思路是先用测试模式临时绕过签名校验但这样会引入新的告警特征。驱动态方案解决了“隐藏能不能生效”的问题但也把风险等级拉上来了蓝屏概率、系统版本兼容性、杀软主动防御的拦截全都要纳入考虑。接下来的第 4 章会给出驱动隐藏进程的最小框架以及我在实际操作中验证过的参数配置和踩坑点。4. 驱动态隐藏进程最小框架与参数配置指南4.1 驱动入口与设备对象创建先让内核认识你驱动一旦加载首先需要一个入口函数注册卸载例程并创建设备对象来与应用层通信。这样做的目的有两个一是让驱动能接收用户态传入的“要隐藏哪个 PID”的指令二是卸载时能安全地释放资源。以下是驱动入口和设备创建的代码框架C 语言#include ntddk.h #define HIDE_DEVICE_NAME L\\Device\\MyHideProc #define HIDE_DOS_NAME L\\??\\MyHideProc // 卸载例程 VOID HideDriverUnload(_In_ PDRIVER_OBJECT DriverObject) { UNICODE_STRING dosName RTL_CONSTANT_STRING(HIDE_DOS_NAME); IoDeleteSymbolicLink(dosName); IoDeleteDevice(DriverObject-DeviceObject); DbgPrint(HideDriver unloaded.\n); } // 默认打开请求 NTSTATUS HideDispatchCreate(_In_ PDEVICE_OBJECT DeviceObject, _In_ PIRP Irp) { // 跳过繁琐的 IRP 处理细节直接完成请求 IoCompleteRequest(Irp, IO_NO_INCREMENT); return STATUS_SUCCESS; } NTSTATUS DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath) { PDEVICE_OBJECT deviceObj NULL; UNICODE_STRING deviceName RTL_CONSTANT_STRING(HIDE_DEVICE_NAME); UNICODE_STRING dosName RTL_CONSTANT_STRING(HIDE_DOS_NAME); DriverObject-DriverUnload HideDriverUnload; DriverObject-MajorFunction[IRP_MJ_CREATE] HideDispatchCreate; DriverObject-MajorFunction[IRP_MJ_CLOSE] HideDispatchCreate; NTSTATUS status IoCreateDevice(DriverObject, 0, deviceName, FILE_DEVICE_UNKNOWN, 0, FALSE, deviceObj); if (!NT_SUCCESS(status)) { DbgPrint(Failed to create device: 0x%X\n, status); return status; } status IoCreateSymbolicLink(dosName, deviceName); if (!NT_SUCCESS(status)) { IoDeleteDevice(deviceObj); return status; } DbgPrint(HideDriver loaded. RegistryPath: %wZ\n, RegistryPath); return STATUS_SUCCESS; }这段代码的逻辑很直白DriverEntry 里先创建设备对象再建立符号链接让应用层可以通过设备路径打开驱动。卸载例程负责反向清理。这里最容易被忽略的是 IoCreateDevice 的 Exclusive 参数设为 FALSE意思是允许多个句柄打开设备方便调试时反复连接。参数上需要注意的几点FILE_DEVICE_UNKNOWN 表示这是一个自定义设备类型不会再被系统分类到磁盘、网络等已知设备里。设备名建议用随机或项目化前缀避免与已知安全软件的内核驱动重名。调试打印默认在 Release 构建下不可见需要用 DebugView 工具并以管理员身份捕获内核输出。4.2 隐藏核心通过 SSDT 挂钩过滤进程枚举结果这是整套方案的核心也最容易翻车的地方。思路是拦截 ZwQuerySystemInformation在返回结果给用户态之前把隐藏目标的进程信息从结果链中摘除。代码框架如下// 保存原始函数指针 static NTSTATUS(*OriginalQuerySystemInfo)(SYSTEM_INFORMATION_CLASS, PVOID, ULONG, PULONG); // 判断是否为系统进程信息类 BOOLEAN IsSystemInformationClass(SYSTEM_INFORMATION_CLASS cls) { // SystemProcessInformation 5, SystemSessionProcessInformation 53 return (cls SystemProcessInformation) || (cls SystemSessionProcessInformation); } // 从结果链中摘除目标 PID 对应节点 NTSTATUS FilterProcessList(PVOID buffer, ULONG pid) { PSYSTEM_PROCESS_INFORMATION cur (PSYSTEM_PROCESS_INFORMATION)buffer; PSYSTEM_PROCESS_INFORMATION prev NULL; while (cur-NextEntryOffset ! 0) { PSYSTEM_PROCESS_INFORMATION next (PSYSTEM_PROCESS_INFORMATION)((PUCHAR)cur cur-NextEntryOffset); if ((ULONG)cur-UniqueProcessId pid) { if (prev) { // 非首节点把前一个节点的偏移指向下一个节点 prev-NextEntryOffset cur-NextEntryOffset; } else { // 首节点直接修改缓冲区头部跳过当前节点 RtlCopyMemory(buffer, next, sizeof(SYSTEM_PROCESS_INFORMATION)); // 不完整仅演示 } return STATUS_SUCCESS; } prev cur; cur next; } return STATUS_NOT_FOUND; } NTSTATUS HookedQuerySystemInfo(SYSTEM_INFORMATION_CLASS cls, PVOID info, ULONG size, PULONG retLen) { NTSTATUS status OriginalQuerySystemInfo(cls, info, size, retLen); if (!NT_SUCCESS(status)) return status; if (IsSystemInformationClass(cls)) { // 从当前进程环境块中读取被隐藏的 PID这里用全局变量简化 FilterProcessList(info, g_hide_pid); } return status; }这段代码的骨架逻辑可以直接跑通但有一个必须强调的点SSDT 挂钩的具体实现依赖 CPU 架构上的 MSR 寄存器读写旧版本系统可能已经启用 Patch Guard 保护直接改函数指针会导致操作系统立即蓝屏。参数上我建议关注三处SystemProcessInformation 这个枚举值在 x64 下依然是 5但后续系统版本加入了更多信息类不能写死。过滤链表时修改的是 NextEntryOffset 链不是数据内容本身。首节点摘除的场景代码注释里已经标注不完整生产级方案应该把首节点之后的数据整体前移而不是直接覆盖头部。4.3 用测试模式加载驱动签名问题的临时解法驱动编译出来后第一步不是直接运行而是先把它注册成系统服务。常见的做法是sc create myhide type kernel binPath C:\Users\Public\hideproc.sys sc start myhide如果遇到签名校验失败可以先开启测试签名模式bcdedit /set testsigning on # 重启后再执行 sc start这两条命令配合可以跑通开发阶段的验证。但要注意测试模式本身就是检测特征——杀软看到测试模式签名会直接报高风险而且 Windows 大版本更新可能重置这个标志位。生产环境下的正式方案是购买或申请内核签名证书然后对驱动文件做交叉签名。这个流程涉及证书申请、EV 签名验证和微软门户提交一般至少一周时间。如果你的目标是个人研究或内部工具测试模式已经够用要是打算分发给别人用签名问题躲不掉。5. 三步排查与避坑蓝屏、兼容性和检测对抗5.1 蓝屏一例忘了 RemoveEntryList 只摘链不断链现象驱动加载后隐藏目标进程成功但随后系统随机蓝屏错误码指向内存访问越界。原因隐藏操作在摘链时只修改了当前节点的 Flink / Blink 指针没有正确维护整个链表的完整性。部分内核路径比如线程创建时查找父进程仍然按原始链表遍历走到被摘节点的指针域时读到野指针。解决摘链前先缓存当前节点的前后指针摘链后把前节点的 Flink 指向后节点同时把后节点的 Blink 指回前节点。这里最关键的是确认当前节点是否还持有额外引用防止指针被提前释放。PLIST_ENTRY prev cur-Blink; PLIST_ENTRY next cur-Flink; prev-Flink next; next-Blink prev;提示操作链表时不能顺手把节点内存释放掉。EPROCESS 结构由进程管理逻辑负责生命周期摘链只是让它从枚举视野中消失内存释放要等进程退出时由系统回收。5.2 失效一例安全软件的交叉验证绕过了你的隐藏现象任务管理器和命令行都看不到目标进程但安全软件依然能弹出“检测到 XXX 进程正在运行”的告警。原因安全软件不会只靠 ZwQuerySystemInformation 枚举进程。它可能通过 WMI 事件订阅、ETW 内核跟踪、句柄表扫描、甚至直接轮询调度器活动进程列表来交叉发现隐藏进程。解决这个坑没法通过驱动自身的过滤逻辑轻易绕过。你需要先判断安全软件的信息来源再决定是补充过滤回调还是放弃“全面隐藏”的目标改为“主要防手动查找”。实战中我通常建议客户把隐藏目标定为“防手工排查”而不是“防杀软扫描”。5.3 兼容性一例老驱动在新版本系统上直接无法加载现象驱动在 Win10 1909 上测试通过同一份驱动文件在 Win11 下执行 sc start 返回错误码 577 或者 1275。原因新版本系统对驱动签名策略收紧要求所有新加载的内核驱动必须有有效签名甚至对驱动入口的编译属性做了额外校验。解决最简单的方案是检查当前系统是否处于测试签名模式再重新编译驱动并去掉保护驱动签名相关的编译选项。如果目标环境是 Win11 且开启 VBS基于虚拟化的安全内核完整性检查会更强这类驱动很难正常加载建议直接放弃驱动方案改用用户态工具结合系统权限实现部分隐藏。5.4 翻车一例自己调试时被自己隐藏的进程“锁死”现象隐藏了一个关键进程后发现无法再通过任务管理器结束它因为任务管理器里已经看不到它的踪迹。原因这不算 bug是隐藏功能的自然副作用。但问题在于测试环境中没有预留“恢复显示”的通道导致进程失控。解决在驱动里实现一个调试开关通过设备 IOCTL 动态切换隐藏与显示状态。应用层工具应该同时具备“隐藏 PID”和“恢复 PID”两个命令便于随时回滚。以下是一个简化的 IOCTL 处理框架#define IOCTL_HIDE_PROC CTL_CODE(FILE_DEVICE_UNKNOWN, 0x801, METHOD_BUFFERED, FILE_ANY_ACCESS) #define IOCTL_UNHIDE_PROC CTL_CODE(FILE_DEVICE_UNKNOWN, 0x802, METHOD_BUFFERED, FILE_ANY_ACCESS) NTSTATUS HandleIoctl(PDEVICE_OBJECT DeviceObject, PIRP Irp) { PIO_STACK_LOCATION stack IoGetCurrentIrpStackLocation(Irp); ULONG ioctl stack-Parameters.DeviceIoControl.IoControlCode; // 从输入缓冲区读取 PID根据 ioctl 类型设置全局隐藏目标或清零 // ... IoCompleteRequest(Irp, IO_NO_INCREMENT); return STATUS_SUCCESS; }这个框架的价值在于它给所有“隐藏能力”配了后悔药。实战调试时一定不要只做单向隐藏否则你会因为看不到进程而无法继续排查。6. 验证你的隐藏是否生效从任务管理器到交叉工具链驱动加载并完成隐藏动作后验证流程要按“由近到远、由表及里”的顺序来做。先看最直接的用户态表现再看系统级行为最后综合评估检测风险。第一轮验证打开任务管理器的“详细信息”标签页按 PID 排序确认目标 PID 不在列表中。然后再打开命令行执行tasklist /fi PID eq 1234这里把 1234 替换成你隐藏目标的进程号。正常返回“信息: 没有运行的任务匹配指定条件”说明进程枚举路径已被过滤。第二轮验证更严格——检查进程是否仍在运行并保持工作能力。随意找一个文件管理器浏览目标进程可以访问的目录或者直接通过网络连接测试目标进程的服务端口。如果端口正常响应说明隐藏只影响了枚举路径没有影响进程本身的执行。第三轮验证是交叉对比这也是最容易暴露隐藏痕迹的环节。用 PowerShell 的 Get-Process 查一次再用 C 运行时库的 CreateToolhelp32Snapshot 写一个 20 行的小工具查一次两边的结果应该都查不到目标 PID。如果其中一个能查到说明过滤只覆盖了某一条枚举路径需要回到驱动里补上对应的信息源。我最后再做一次验证的习惯是打开事件查看器看系统日志里有没有事件 ID 7036服务状态变化或进程创建相关的安全审计记录。隐藏进程可以掩盖枚举结果但掩盖不了 ETW 事件和审计日志上的行为痕迹。如果这些记录里出现了目标进程的痕迹说明系统的其他组件已经开始会话你的隐藏策略需要重新评估。这个方向做久了最大的教训就是永远不要相信单一的隐藏手段。驱动过滤解决了用户态工具的可见性问题但行为检测、网络连接、计划任务、服务注册表这些维度每一个都可能留下漏洞。把隐藏做成一站式方案不如做成分层方案——用户态只负责伪装和干扰驱动态只负责精准过滤再用额外的系统权限来规避审计。整个过程里“能藏住”和“值得藏”永远是两件事前者是技术问题后者是判断问题。希望这篇整理能帮你把进程隐藏的来龙去脉和落地路径一次理顺少走几趟蓝屏和翻车的弯路。本文还有配套的精品资源点击获取