1. 缘起:为什么还需要第二种进程枚举视角

在之前为 SnowSword 实现隐藏进程检测时,我用的方案是暴力扫描 PID:以 4 为步长枚举 0 ~ 0x20000,逐个调用 PsLookupProcessByProcessId 反查 EPROCESS。这个方案能用,但有几个明显的局限:

  1. 扫描范围是”假设”不是”机制”——0x20000 上限写死,高 PID 进程可能漏检;
  2. 大量无效查询——有效 PID 中仍有大量未分配槽位,每次都要完整走一次查询;
  3. 依赖 API 语义——PsLookupProcessByProcessId 本身是导出函数,如果攻击者 Hook 了内核查询路径,返回结果同样不可信。

由此想到一个问题:能不能不经过任何查询 API,直接读取 Windows 内核自己维护的 PID → 进程对象映射?这个数据结构就是 PspCidTable。

核心思路:公开 API 是访问内核数据结构的一个入口,直接遍历 PspCidTable 可以降低对单一查询 API 的依赖;但它不能保证绕过对内核数据结构本身的篡改,也不能消除私有结构随系统版本变化带来的兼容性风险。

2. PspCidTable 是什么

PspCidTable 是 ntoskrnl.exe 的私有全局变量,也是进程和线程共用的 CID(Client ID)表。PID 与 TID 都在其中。它不是普通用户态进程句柄表,下面的字段和编码说明也必须以具体 Windows 版本的实测结果为准。

它的类型是 HANDLE_TABLE(未导出结构),遍历的关键字段是 TableCode:

  • 低 2 位:表级数——0 单级、1 二级、2 三级;
  • 高位:表基址(读取时用 TableCode & ~3 抹零低 2 位)。

单级/二级/三级结构的关系:

1
2
3
单级: TableCode → Entry[](直接索引)
二级: TableCode → 二级表指针[512] → Entry[]
三级: TableCode → 三级表指针[512] → 二级表指针[512] → Entry[]

其中 Entry 每项 16 字节(x64)。x64 Win10 上 PspCidTable 通常为三级结构。

3. 如何定位 PspCidTable

PspCidTable 是未导出变量,不能直接引用。最直接的办法是从公开导出函数反向追踪。我这里用的是两步跳板定位法:

  1. 在 PsLookupProcessByProcessId 开头 0x100 字节内搜特征码 0xE8(call 指令),解析相对偏移,得到它内部调用的 PspReferenceCidTableEntry;
  2. 在 PspReferenceCidTableEntry 内搜特征码 48 8B 05(mov rax, [rip+disp32]),再解析相对偏移,就得到 PspCidTable 变量的运行时地址。

调试时的核心反汇编输出如下:

1
2
3
fffff806`ca714a2e 488b0583126b00  mov rax,qword ptr [nt!PspCidTable (fffff806`cadc5cb8)]
...
fffff803`16425cc4 e8a7010000 call nt!PspReferenceCidTableEntry (fffff803`16425e70)

小技巧:48 8B 05 在函数里可能命中多处,先通过 call 跳板锁定 PspReferenceCidTableEntry,再在其内部搜 mov,命中率更高——把调用链当路标。

核心实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
PVOID GetPspCidTable() {
PVOID PsLookupProcessByProcessIdAddr = (PVOID)PsLookupProcessByProcessId;
PUCHAR StartSearchAddress = NULL;

// 第一步:在 PsLookupProcessByProcessId 内搜索 call 指令(0xE8)
UCHAR pSpecialCode1[1] = { 0xE8 };
PVOID result = SearchSpecialCode(PsLookupProcessByProcessIdAddr, 0x100, pSpecialCode1, 1);
if (result != NULL) {
// call 相对偏移:目标地址 = 指令地址 + 5 + disp32
LONG offset = *(PLONG)((PUCHAR)result + 1);
StartSearchAddress = (PUCHAR)((PUCHAR)result + 5 + offset); // 得到 PspReferenceCidTableEntry
} else {
StartSearchAddress = (PUCHAR)PsLookupProcessByProcessIdAddr; // 降级:直接从头搜索
}

// 第二步:在 PspReferenceCidTableEntry 内搜索 mov rax, [rip+disp32](48 8B 05)
UCHAR pSpecialCode2[3] = { 0x48, 0x8B, 0x05 };
result = SearchSpecialCode(StartSearchAddress, 0x100, pSpecialCode2, 3);
if (NULL == result) return NULL;

LONG offset = *(PLONG)((PUCHAR)result + 3);
return (PVOID)((PUCHAR)result + 7 + offset); // mov rax,[rip+disp] 指令长 7 字节
}

(关键逻辑如上,完整工程代码参照 LyShark实现 补齐。)

4. 遍历 PspCidTable

拿到 PspCidTable 地址后,按 TableCode 的级数逐级遍历。整体流程:

1
2
3
4
5
6
7
8
9
PspCidTable
↓ 解析 TableCode(级数 + 基址)
遍历有效 Entry
↓ 解码 Entry 得到对象体地址
ObGetObjectType 判断是否为进程对象
↓
handle 值即 PID,收进结果数组
↓
生成第二份进程列表 B

4.1 解析 TableCode

注意 PspCidTable 变量里存的是 HANDLE_TABLE*,取 TableCode 要两级解引用:先读变量得表地址,再偏移 +8 读 TableCode 字段。

1
2
3
4
5
6
ULONG64 ul_tableCode = *(PULONG64)(((ULONG64)*(PULONG64)TableBase) + 8);
INT i_low2 = ul_tableCode & 3; // 表级数

if (i_low2 == 0) parse_table_1(ul_tableCode & ~3, 0, 0, Callback, Context); // 单级
else if (i_low2 == 1) parse_table_2(ul_tableCode & ~3, 0, Callback, Context); // 二级
else if (i_low2 == 2) parse_table_3(ul_tableCode & ~3, Callback, Context); // 三级

4.2 逐级遍历

三级表:在当前观察到的 x64 版本中,每层通常是 512 项的指针数组(每项 8 字节),逐级取指针,最底层是 Entry 数组(每项 16 字节)。这里的层级、数量和字段布局不是稳定的公开接口,必须按目标系统版本验证。MmIsAddressValid 只能作为有限的预检,不能替代对象生命周期管理或并发同步。

4.3 Entry 解码(当前版本假设)

在当前参考实现假设下,PspCidTable 使用“锁定句柄”格式,Entry 的低 16 位是引用计数或属性位,对象体地址需要这样解码(右移 16 位并按 16 字节对齐):

1
2
ULONG64 ul_decode = (LONG64)ul_recode >> 0x10;
ul_decode &= 0xfffffffffffffff0;

4.4 Client ID 与 PID 的对应关系

在 CID 表语义下,条目的 Client ID 数值可以对应 PID 或 TID;只有通过对象类型判断为进程对象后,才能将其作为 PID 收集。当前参考实现假设每个 Entry 承载 4 个 PID 槽位,handle 编码为:

1
handle = i * 4 + 1024 * index1 + 512 * index2 * 1024

PID 必然是 4 的倍数——这也解释了之前暴力扫描为什么能以 4 为步长对齐。进程对象判断用对象管理器 API,配合异常保护:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static BOOLEAN ProcessEnumCallback(HANDLEINFO* pHandleInfo, PVOID Context) {
if (!pHandleInfo->Object || !MmIsAddressValid(pHandleInfo->Object))
return TRUE; // 无效 Entry 跳过
_try {
if (ObGetObjectType(pHandleInfo->Object) == *PsProcessType) {
// handle 直接作为 PID 存入数组
EnumContext->ProcessArray[EnumContext->ProcessCount++] = pHandleInfo->Handle;
}
}
_except(EXCEPTION_EXECUTE_HANDLER) {
// 对象可能已被释放,异常时跳过而非蓝屏
}
return TRUE;
}

5. 与现有方案的关系和验证意义

PID 暴力枚举的表面形式是遍历 PID 数值,但每次查询仍然依赖 PsLookupProcessByProcessId 访问内核维护的 CID 映射。因此,它与直接遍历 PspCidTable 并不是两个独立的数据源。

两种方案的主要区别在于访问路径:

  • 间接 CID 查询:遍历候选 PID,通过公开 API 查询对应的进程对象;
  • 直接 CID 表遍历:定位并解析私有的 PspCidTable,再根据对象类型筛选进程对象。

6. 实验设计

状态 间接 CID 查询 直接 CID 表遍历 预期结论
正常进程 ✓ ✓ 正常
ActiveProcessLinks 隐藏 ✗ ✓ 检出
PspCidTable 隐藏 ✓ ✗ 检出
两边同时隐藏 ✗ ✗ 多源方案仍有盲区

从最后一行可以得出结论:检测机制不是”万能探测器”,而是攻击面与数据源之间的博弈——每多一个可信数据源,攻击者就得多篡改一个地方,但盲区始终存在。

7. 遇到的问题

  1. 两级解引用:一开始只解了一次引用,把 PspCidTable 变量地址当成了表地址,遍历全乱;后来意识到它是 HANDLE_TABLE*。
  2. 遍历次数困惑:参考实现里一级表循环写的是 for (i < 512) // 256?,作者自己也标了疑惑——表大小 4K / 每项 16B 应该是 256 项,实际循环却写 512 次,这部分涉及每个 Entry 承载 4 个 handle 的映射,还需要再验证。
  3. 并发修改:遍历过程中 TableCode 和各级表可能被句柄增删或扩容修改。未经确认不应直接依赖内部锁;当前方案只能接受轻微不一致,后续需要评估版本相关的快照、重试或结果去重策略。

8. 展望

  • 在 Win10、Win11 等目标版本上分别确认 HANDLE_TABLE 布局、TableCode 层级、Entry 数量和对象指针编码;
  • 为直接遍历逻辑增加版本识别、特征匹配失败处理、遍历边界检查和结果去重,避免把私有结构当作稳定接口;
  • 通过正常进程创建和退出、PID 较大进程、并发表扩容等场景验证遍历稳定性;

参考

  • Windows Internals, 7th Edition(PspCidTable / HANDLE_TABLE 相关章节)
  • ReactOS 源码(HANDLE_TABLE / PspCidTable 的开放实现参考)
  • LyShark 公开示例:PspCidTable 定位与三级表遍历参考实现(https://www.lyshark.com/post/93beefb4.html)
  • 本系列前篇:SnowSword–进程隐藏检测实现(PID 暴力扫描方案)

相关项目与联系

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

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

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