项目地址:https://gitee.com/le-ziyi/SnowSword

1. 为什么需要检测隐藏进程

在开发 SnowSword 之前,我曾使用过一些 ARK 类工具分析 Windows 系统。但在实际测试过程中发现,不同工具对于进程枚举结果可能存在差异。因此,我希望实现一种多视角交叉验证的方法,而不是依赖单一 API。

众所周知,部分恶意程序会通过 Hook R3 API(Process32Next、NtQuerySystemInformation)、Hook R0 API(ZwQuerySystemInformation)或篡改 R0 内核结构(EPROCESS 摘链、Hook ZwQuerySystemInformation)来隐藏进程,避免被用户通过系统自带的任务管理器等普通安全工具发现其活动。但正所谓”天网恢恢,疏而不漏”,我们可以使用一种交叉对比进程枚举结果的方法找出恶意程序的隐藏进程。

1.1 为什么需要多视角枚举?

Windows中不存在一个绝对可信的进程观察视角。

用户态工具、对象管理器、内核进程管理机制分别维护着不同的信息来源。
如果攻击者只修改其中一种视角,例如Hook某个用户态接口,
其他来源仍可能暴露异常。

因此,SnowSword的设计思路不是寻找一个万能的进程枚举接口,
而是从多个角度获取进程信息,并通过结果差异发现异常。

2. 不同视角下的进程枚举机制

2.1 用户态 API 枚举

  • Process32First + Process32Next:Windows 用户态最常见的进程枚举接口。但是它依赖用户态 API 调用链,如果攻击者 IAT Hook / Inline Hook 它或者更底层的数据来源,返回结果可能已经不可信。因此 SnowSword 中并不将其作为最终判断依据,而是作为一个”普通用户看到的世界”。

  • NtQuerySystemInformation(SystemProcessInformation, …):Native API(由于不存在于导入库中,需要自行通过 GetModuleHandle + GetProcAddress 动态调用),可以获取更多进程信息,但由于它已十分出名,所以通常也会被列入 hook 名单。

2.2 内核态 API 枚举

  • ZwQuerySystemInformation(SystemProcessInformation, …):内核最常见的进程枚举手段,获取的进程信息同 R3 的 NtQuerySystemInformation(SystemProcessInformation, ...),但相较 R3 更可信。

3. SnowSword 实现方案

3.1 用户态获取进程列表

  1. 调用 Process32First + Process32Next 获取进程列表 a,作为进程列表基线用于交叉检测隐藏进程。

  2. 除了传统进程枚举 API 外,SnowSword 还尝试从系统句柄角度观察进程。Windows 中的进程对象通常会对应一个 Process 类型句柄,因此可以通过 SystemHandleInformation 获取全局句柄表,再筛选其中的 Process 和 Thread 类型。通过句柄反向获得 PID,形成进程列表 b。最后去重。

3.2 驱动获取进程列表

Windows 内核分配 PID 时采用 4 字节对齐的增量分配策略——PID 的高 30 位用于索引进程对象数组,低 2 位固定为 0。因此合法 PID 必然是 4 的倍数,以 4 为步长暴力枚举可以跳过 75% 的无效值。

具体实现:以 4 为步长暴力枚举 0 ~ 0x20000(131072)的 PID,尝试调用 PsLookupProcessByProcessId 获取进程对象指针,成功且 PsGetProcessExitStatus 返回值等于 STATUS_PENDING(为了排除部分僵尸进程)则将 PID 插入进程列表 c。

该方法本质上利用了系统 PID 管理机制进行间接验证,与直接遍历 PspCidTable 相比实现简单,但效率和兼容性存在限制。

最初考虑过直接遍历 EPROCESS 链表,但是该方案存在两个问题:

  1. 不同 Windows 版本结构变化较大。
  2. 如果攻击者采用 DKOM 隐藏,同样可能影响该链表。

因此 SnowSword 选择通过 PID 反查方式,利用系统自身 PID 管理机制获取进程对象。虽然扫描范围有限,但不依赖固定结构偏移。

3.3 用户态与内核态结果比较

比较进程列表 a、b、c,在 UI 中标记出现在列表 b/c 但未出现在列表 a 的隐藏进程。

3.4 隐藏进程检测实验

隐藏进程检测实验截图

截图说明:

  • SnowSword 进程列表(上方):可以看到 TestAPI.exe(PID 9812,黄色高亮),以及多个 (Exited) 进程(已退出但内核对象尚未销毁的僵尸进程)。
  • 任务管理器(下方):无法看到 TestAPI.exe,因为已被 InlineHook 摘除。
  • TestAPI 窗口(右下):提供”Hook”按钮,用于触发 DLL 注入。

我编写了一个测试程序 TestAPI.exe(PID 9812),并开发了一个 InlineHook.dll。通过 TestAPI,将 InlineHook.dll 分别注入到**任务管理器(Taskmgr.exe)**和 SnowSword 两个进程中。

InlineHook.dll 的核心逻辑是 Hook ntdll.dll 中的 NtQuerySystemInformation 函数,当查询 SystemProcessInformation(进程列表)时,将 TestAPI.exe 从返回的链表数据中”摘除”。核心逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// 注意:本代码假设被隐藏进程不会出现在链表的第一个节点
// (SystemProcessInformation 的头节点通常是 System Idle Process)
// 生产环境需要处理头节点被隐藏的情况

NTSTATUS NTAPI Fake_NtQuerySystemInformation(
SYSTEM_INFORMATION_CLASS SystemInformationClass,
PVOID SystemInformation,
ULONG SystemInformationLength,
PULONG ReturnLength
)
{
NTSTATUS Status = Old_NtQuerySystemInformation(
SystemInformationClass,
SystemInformation,
SystemInformationLength,
ReturnLength
);

if (!NT_SUCCESS(Status) || SystemInformationClass != SystemProcessInformation)
return Status;

// 双指针法:pNextOffset 指向前一个节点的 NextEntryOffset 字段(或头指针)
PULONG pNextOffset = (PULONG)&((PSYSTEM_PROCESS_INFORMATION)SystemInformation)->NextEntryOffset;
PSYSTEM_PROCESS_INFORMATION Current = (PSYSTEM_PROCESS_INFORMATION)SystemInformation;

while (Current)
{
BOOL Hide = (Current->ImageName.Buffer &&
_wcsicmp(Current->ImageName.Buffer, L"TestAPI.exe") == 0);

if (Hide)
{
if (Current->NextEntryOffset == 0)
{
// 尾节点:将前一个节点的偏移设为 0
*pNextOffset = 0;
break;
}
else
{
// 中间节点:跳过当前节点
*pNextOffset += Current->NextEntryOffset;
Current = (PSYSTEM_PROCESS_INFORMATION)((PUCHAR)Current + Current->NextEntryOffset);
continue;
}
}

// 推进
if (Current->NextEntryOffset == 0)
break;
pNextOffset = &Current->NextEntryOffset;
Current = (PSYSTEM_PROCESS_INFORMATION)((PUCHAR)Current + Current->NextEntryOffset);
}

return Status;
}

实验结果:

工具 是否看到 TestAPI.exe 原因
被注入的任务管理器 ❌ 看不到 NtQuerySystemInformation 被 Hook,链表被篡改
被注入的 SnowSword ✅ 能看到 使用句柄枚举 + 内核驱动反查,不依赖被 Hook 的 R3 API

这就验证了单一 R3 API Hook 无法对抗多视角交叉检测的观点。

在驱动层枚举时,我们通过 PsGetProcessExitStatus 过滤掉 STATUS_PENDING 以外的进程,避免将已退出进程误判为隐藏进程。而用户态的 Process32Next 通常不会返回这些已退出进程,这也解释了为什么 SnowSword 的进程列表比任务管理器”更长”——它包含了更多内核视角的信息。

4. 开发过程中遇到的问题

4.1 比较结果

一开始我想当然以为直接取列表 a 和列表 b/c 的差集即可,但 UI 上会出现重复显示的隐藏进程,排查后才发现是因为一个进程可能:被多个进程打开句柄,或是拥有多个线程句柄。所以 Handle-PID 不是简单的一对一关系,需要使用 HashSet 保存 PID。

4.2 僵尸进程判断问题

测试过程中发现,部分已经退出的进程对象仍然可能短时间存在。因此不能简单认为 PsLookupProcessByProcessId 成功即可,需要结合 PsGetProcessExitStatus 进一步判断。

5. 后续优化方向

目前 SnowSword 的隐藏进程检测仍存在限制:

  1. PID 扫描范围固定
  2. 依赖 PsLookupProcessByProcessId
  3. 无法发现所有内核级隐藏方式

后续计划:

  • 解析 PspCidTable 的三级表结构获取进程列表
  • 增加线程交叉验证

参考

相关项目与联系

项目SnowSword —— Windows 10/11 x64 内核分析与 Anti-Rootkit 工具,源码与发布包在 Gitee。

技术合作Windows 内核 / 驱动开发 / 逆向分析 / 兼容性开发,范围与边界见 技术合作。

联系2160752730@qq.com · QQ 2160752730 · 微信 lst_3258110