☰
Windows内核驱动实现进程隐藏:从EPROCESS到SSDT挂钩的攻防实战
2026/10/20 10:52:49 网站建设 项目流程

简介:这是一份聚焦 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 事件和审计日志上的行为痕迹。如果这些记录里出现了目标进程的痕迹,说明系统的其他组件已经开始会话,你的隐藏策略需要重新评估。

这个方向做久了,最大的教训就是:永远不要相信单一的隐藏手段。驱动过滤解决了用户态工具的可见性问题,但行为检测、网络连接、计划任务、服务注册表这些维度,每一个都可能留下漏洞。把隐藏做成一站式方案,不如做成分层方案——用户态只负责伪装和干扰,驱动态只负责精准过滤,再用额外的系统权限来规避审计。整个过程里,“能藏住”和“值得藏”永远是两件事,前者是技术问题,后者是判断问题。

希望这篇整理能帮你把进程隐藏的来龙去脉和落地路径一次理顺,少走几趟蓝屏和翻车的弯路。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询