PspCidTable 进程枚举学习记录 —— 从 API 逆向到内核对象表遍历
1. 缘起:为什么还需要第二种进程枚举视角
在之前为 SnowSword 实现隐藏进程检测时,我用的方案是暴力扫描 PID:以 4 为步长枚举 0 ~ 0x20000,逐个调用 PsLookupProcessByProcessId 反查 EPROCESS。这个方案能用,但有几个明显的局限:
- 扫描范围是”假设”不是”机制”——
0x20000上限写死,高 PID 进程可能漏检; - 大量无效查询——有效 PID 中仍有大量未分配槽位,每次都要完整走一次查询;
- 依赖 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 | 单级: TableCode → Entry[](直接索引) |
其中 Entry 每项 16 字节(x64)。x64 Win10 上 PspCidTable 通常为三级结构。
3. 如何定位 PspCidTable
PspCidTable 是未导出变量,不能直接引用。最直接的办法是从公开导出函数反向追踪。我这里用的是两步跳板定位法:
- 在
PsLookupProcessByProcessId开头0x100字节内搜特征码0xE8(call指令),解析相对偏移,得到它内部调用的PspReferenceCidTableEntry; - 在
PspReferenceCidTableEntry内搜特征码48 8B 05(mov rax, [rip+disp32]),再解析相对偏移,就得到PspCidTable变量的运行时地址。
调试时的核心反汇编输出如下:
1 | fffff806`ca714a2e 488b0583126b00 mov rax,qword ptr [nt!PspCidTable (fffff806`cadc5cb8)] |
小技巧:
48 8B 05在函数里可能命中多处,先通过call跳板锁定PspReferenceCidTableEntry,再在其内部搜mov,命中率更高——把调用链当路标。
核心实现:
1 | PVOID GetPspCidTable() { |
(关键逻辑如上,完整工程代码参照 LyShark实现 补齐。)
4. 遍历 PspCidTable
拿到 PspCidTable 地址后,按 TableCode 的级数逐级遍历。整体流程:
1 | PspCidTable |
4.1 解析 TableCode
注意 PspCidTable 变量里存的是 HANDLE_TABLE*,取 TableCode 要两级解引用:先读变量得表地址,再偏移 +8 读 TableCode 字段。
1 | ULONG64 ul_tableCode = *(PULONG64)(((ULONG64)*(PULONG64)TableBase) + 8); |
4.2 逐级遍历
三级表:在当前观察到的 x64 版本中,每层通常是 512 项的指针数组(每项 8 字节),逐级取指针,最底层是 Entry 数组(每项 16 字节)。这里的层级、数量和字段布局不是稳定的公开接口,必须按目标系统版本验证。MmIsAddressValid 只能作为有限的预检,不能替代对象生命周期管理或并发同步。
4.3 Entry 解码(当前版本假设)
在当前参考实现假设下,PspCidTable 使用“锁定句柄”格式,Entry 的低 16 位是引用计数或属性位,对象体地址需要这样解码(右移 16 位并按 16 字节对齐):
1 | ULONG64 ul_decode = (LONG64)ul_recode >> 0x10; |
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 | static BOOLEAN ProcessEnumCallback(HANDLEINFO* pHandleInfo, PVOID Context) { |
5. 与现有方案的关系和验证意义
PID 暴力枚举的表面形式是遍历 PID 数值,但每次查询仍然依赖 PsLookupProcessByProcessId 访问内核维护的 CID 映射。因此,它与直接遍历 PspCidTable 并不是两个独立的数据源。
两种方案的主要区别在于访问路径:
- 间接 CID 查询:遍历候选 PID,通过公开 API 查询对应的进程对象;
- 直接 CID 表遍历:定位并解析私有的
PspCidTable,再根据对象类型筛选进程对象。
6. 实验设计
| 状态 | 间接 CID 查询 | 直接 CID 表遍历 | 预期结论 |
|---|---|---|---|
| 正常进程 | ✓ | ✓ | 正常 |
| ActiveProcessLinks 隐藏 | ✗ | ✓ | 检出 |
| PspCidTable 隐藏 | ✓ | ✗ | 检出 |
| 两边同时隐藏 | ✗ | ✗ | 多源方案仍有盲区 |
从最后一行可以得出结论:检测机制不是”万能探测器”,而是攻击面与数据源之间的博弈——每多一个可信数据源,攻击者就得多篡改一个地方,但盲区始终存在。
7. 遇到的问题
- 两级解引用:一开始只解了一次引用,把
PspCidTable变量地址当成了表地址,遍历全乱;后来意识到它是HANDLE_TABLE*。 - 遍历次数困惑:参考实现里一级表循环写的是
for (i < 512) // 256?,作者自己也标了疑惑——表大小 4K / 每项 16B 应该是 256 项,实际循环却写 512 次,这部分涉及每个 Entry 承载 4 个 handle 的映射,还需要再验证。 - 并发修改:遍历过程中
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
