Windows内核驱动中枚举进程的几种方式
遍历所有可能的PID
这种方式很简单,先给定一个PID最大值,从PID=0开始,使用PsLookupProcessByProcessId获取PID对应的EPROCESS结构。
示例代码:
/* 通过暴力枚举所有可能的PID,并调用PsLookupProcessByProcessId函数 来获取进程的EPROCESS 这种方法性能堪忧,PID并非连续整数;进程退出后,其PID可能会被复用,这 可能会让我们得到过时的EPROCESS */#include<ntifs.h>// 未文档化的函数EXTERN_C NTKERNELAPI UCHAR*PsGetProcessImageFileName(IN PEPROCESS pEprocess);// 未文档化的函数EXTERN_C NTKERNELAPI HANDLEPsGetProcessInheritedFromUniqueProcessId(IN PEPROCESS pEprocess);PEPROCESSLookupProcess(HANDLE Pid){PEPROCESS pEprocess=nullptr;NTSTATUS status=PsLookupProcessByProcessId(Pid,&pEprocess);if(NT_SUCCESS(status)){returnpEprocess;}returnnullptr;}voidEnumProcess(){PEPROCESS pEprocess=NULL;for(inti=0;i<1000000;i+=4){pEprocess=LookupProcess((HANDLE)i);if(pEprocess!=nullptr){DbgPrintEx(DPFLTR_IHVDRIVER_ID,DPFLTR_INFO_LEVEL,"Process Name: %s, PID: %d, PPID: %d\r\n",PsGetProcessImageFileName(pEprocess),PsGetProcessId(pEprocess),PsGetProcessInheritedFromUniqueProcessId(pEprocess));ObDereferenceObject(pEprocess);}}}voidDriverUnload(PDRIVER_OBJECT pDrvObj){DbgPrintEx(DPFLTR_IHVDRIVER_ID,DPFLTR_INFO_LEVEL,"Uninstall Driver Is Ok\n");}EXTERN_C NTSTATUSDriverEntry(PDRIVER_OBJECT pDrvObj,PUNICODE_STRING pRegPath){UNREFERENCED_PARAMETER(pRegPath);pDrvObj->DriverUnload=DriverUnload;EnumProcess();returnSTATUS_SUCCESS;}注意,PID是+4递增的。在Windows系统中,PID应该都是4的倍数。
ZwQuerySystemInformation
对于这个函数,官方文档中说:
[ZwQuerySystemInformation 自Windows 8起不再可用。 请改用本主题中列出的备用函数。]
经过实验发现,对于枚举进程,这个函数还是可以使用的:
下面是示例代码:
#include<ntddk.h>// 定义 SYSTEM_INFORMATION_CLASS 枚举typedefenum_SYSTEM_INFORMATION_CLASS{SystemProcessInformation=5,// 进程信息SystemProcessesAndThreadsInformation=5// 与上面等价,不同文档可能命名不同[reference:1][reference:2]}SYSTEM_INFORMATION_CLASS;// 定义 SYSTEM_PROCESS_INFORMATION 结构[reference:3][reference:4]typedefstruct_SYSTEM_PROCESS_INFORMATION{ULONG NextEntryOffset;// 下一个结构体的偏移量ULONG NumberOfThreads;ULONG Reserved[6];LARGE_INTEGER CreateTime;LARGE_INTEGER UserTime;LARGE_INTEGER KernelTime;UNICODE_STRING ImageName;// 进程名称KPRIORITY BasePriority;HANDLE UniqueProcessId;// 进程IDHANDLE InheritedFromUniqueProcessId;// 父进程IDULONG HandleCount;ULONG SessionId;ULONG_PTR PageDirectoryBase;VM_COUNTERS VmCounters;SIZE_T PrivatePageCount;IO_COUNTERS IoCounters;// 后面还有线程信息数组,此处省略}SYSTEM_PROCESS_INFORMATION,*PSYSTEM_PROCESS_INFORMATION;// 声明 ZwQuerySystemInformation 函数EXTERN_C NTKERNELAPI NTSTATUSZwQuerySystemInformation(IN SYSTEM_INFORMATION_CLASS SystemInformationClass,IN OUT PVOID SystemInformation,IN ULONG SystemInformationLength,OUT PULONG ReturnLength OPTIONAL);// 枚举进程的核心函数VOIDEnumProcesses(){NTSTATUS status;ULONG bufferSize=0;PVOID buffer=NULL;PSYSTEM_PROCESS_INFORMATION pInfo=NULL;// 第一步:先调用一次,获取所需的缓冲区大小// 传入 NULL 和 0,函数会返回 STATUS_INFO_LENGTH_MISMATCH 并填充所需大小[reference:5][reference:6]status=ZwQuerySystemInformation(SystemProcessInformation,NULL,0,&bufferSize);if(status!=STATUS_INFO_LENGTH_MISMATCH){KdPrint(("ZwQuerySystemInformation 获取大小失败: 0x%X\n",status));return;}// 第二步:分配足够的内存// 内核驱动中使用 ExAllocatePoolWithTag 分配内存buffer=ExAllocatePoolWithTag(PagedPool,bufferSize,'mEnP'// 自定义标签,用于内存调试);if(!buffer){KdPrint(("内存分配失败\n"));return;}RtlZeroMemory(buffer,bufferSize);// 第三步:再次调用,获取实际的进程信息[reference:8][reference:9]status=ZwQuerySystemInformation(SystemProcessInformation,buffer,bufferSize,NULL);if(!NT_SUCCESS(status)){KdPrint(("ZwQuerySystemInformation 查询失败: 0x%X\n",status));ExFreePoolWithTag(buffer,'mEnP');return;}// 第四步:遍历链表,输出每个进程的信息[reference:10][reference:11]pInfo=(PSYSTEM_PROCESS_INFORMATION)buffer;ULONG processCount=0;while(TRUE){processCount++;// 进程ID为0的是 System Idle Process[reference:12]if(pInfo->UniqueProcessId==0){KdPrint(("[%6d] System Idle Process\n",pInfo->UniqueProcessId));}else{KdPrint(("[%6d] %wZ\n",pInfo->UniqueProcessId,&pInfo->ImageName));}// 如果 NextEntryOffset 为 0,说明已到达链表末尾[reference:13]if(pInfo->NextEntryOffset==0){break;}// 移动到下一个结构体[reference:14][reference:15]pInfo=(PSYSTEM_PROCESS_INFORMATION)((PUCHAR)pInfo+pInfo->NextEntryOffset);}KdPrint(("进程总数: %d\n",processCount));// 第五步:释放内存[reference:16]ExFreePoolWithTag(buffer,'mEnP');}// 驱动入口点EXTERN_C NTSTATUSDriverEntry(IN PDRIVER_OBJECT DriverObject,IN PUNICODE_STRING RegistryPath){UNREFERENCED_PARAMETER(DriverObject);UNREFERENCED_PARAMETER(RegistryPath);KdPrint(("=== 驱动加载,开始枚举进程 ===\n"));EnumProcesses();KdPrint(("=== 进程枚举完成 ===\n"));// 注意:此示例在 DriverEntry 中直接执行后返回// 实际驱动通常需要创建设备对象,在分发函数中调用returnSTATUS_SUCCESS;}原理:它读取的是活动进程链表
ZwQuerySystemInformation(信息类SystemProcessInformation)在内核中的实现逻辑大致如下:
- 锁定数据源:它首先会获取内核全局的
PsActiveProcessHead双向链表头的锁。 - 遍历链表:从链表头开始,沿着每个
EPROCESS结构中的ActiveProcessLinks成员,逐个向后遍历。 - 收集并拷贝:每遍历到一个
EPROCESS,就从中摘取进程ID、名称、线程数等信息,填充到SYSTEM_PROCESS_INFORMATION结构中,并拷贝到调用者提供的缓冲区。 - 返回结果:直到遍历完整个链表,将组装好的信息返回给调用者。
这个API本质上就是PsActiveProcessHead链表的“官方只读查看器”。它不依赖任何其他索引,链表里有什么,它就返回什么。
被欺骗的原因:“断链”隐藏(DKOM)
既然它的数据完全来自链表,攻击手段就非常清晰了——直接操作内核内存中的链表指针,这种技术被称为直接内核对象操作(DKOM,Direct Kernel Object Manipulation)。
恶意驱动(如Rootkit)的典型操作如下:
- 找到目标进程的
EPROCESS结构(例如通过PsLookupProcessByProcessId)。 - 修改该结构中的
ActiveProcessLinks指针:- 将前一个进程的
Blink(向后指针)指向后一个进程。 - 将后一个进程的
Flink(向前指针)指向前一个进程。
- 将前一个进程的
- 相当于把目标进程的
EPROCESS节点,从双向链表的“环”中摘除(Unlink)。
结果:
- 目标进程依然在内存中正常运行,CPU调度、内存管理都正常,PID也有效。
- 但
PsActiveProcessHead链表里完全没有它的痕迹。
此时,ZwQuerySystemInformation去遍历链表,自然就会直接跳过这个被摘除的节点,因此任务管理器、Process Explorer(如果也依赖此API)或其他安全软件都无法看到它。
补充:NtGetNextProcess呢?
Windows 10引入的NtGetNextProcess底层依然依赖PsActiveProcessHead链表遍历,因此同样会被传统的“断链”技术欺骗。它只是解决了遍历时的竞争条件(比如枚举过程中进程退出导致的崩溃),并未解决数据源本身的局限性。
总结
ZwQuerySystemInformation枚举进程,本质上是在遍历系统公开的链表索引;而进程隐藏技术做的,正是从该索引中把自己移除——所以它天然受到欺骗。想要在驱动中做对抗检测,就必须绕过链表,直接从内核更底层的句柄表(PspCidTable)或内存池中扫描EPROCESS特征。
PspCidTable句柄表
这个符号是没有被导出的,但是我们可以使用PsLookupProcessByProcessId中的特征码,来找到该符号的地址:
我们可以使用MmGetSystemRoutineAddress得到PsLookupProcessByProcessId地址,然后搜索特征码即可,不依赖特定符号,通用性强,能适配大部分Windows版本。
为什么PspCidTable能对抗DKOM隐藏?
- 链表(
PsActiveProcessHead):是为了方便枚举而维护的“索引目录”,摘掉节点不影响进程运行,适合隐藏。 - 句柄表(
PspCidTable):是内核用于通过PID查找对象的“核心哈希表”。如果从这张表里删除条目,系统将无法通过PID找到该进程,会导致OpenProcess、TerminateProcess等操作失败,极易引发系统蓝屏或崩溃。
因此,恶意程序通常只敢断链,不敢删表。所以枚举PspCidTable能发现那些“从链表消失”但“仍在句柄表”中的进程。
PsActiveProcessHead
这个和使用ZwQuerySystemInformation原理是一样的,就不多说了。