Incident Forensics · 2026-06-12 · Windows Internals

一次 79GB 内核内存泄漏的侦破实录

当任务管理器找不到凶手时,去内核里找。一台 96GB 内存的工作站被吃到 93%,而所有进程加起来才 10GB——这篇是从第一现场到根因闭环的完整破案记录。

案发2026-06-12 系统Windows 11 · 96GB RAM 凶器NTFS File Control Block 真凶一个三周前的 junction
79.2 GB
非分页池峰值
67.3 GB
NtFC tag 独占
16.8
IO ops/sec 峰值
21
从埋雷到爆炸

01第一现场:账对不上的内存

系统说用了 86.5GB,进程列表只认领 10GB

事情从一个再普通不过的观察开始:内存占用 85%,然后是 93%。96GB 的工作站,这个数字意味着接近 90GB 内存已被占用。第一反应当然是打开任务管理器,按内存排序,找那个失控的进程——然后第一个反常出现了:最大的单个进程只有 2GB。把所有进程的内存全部加起来,也只有 10GB 左右。

# 所有进程工作集求和
(Get-Process | Measure-Object WorkingSet64 -Sum).Sum / 1GB
# → 约 10 GB

# 但系统实际已用
# → 86.5 GB(占 96GB 的 90%+)

76GB 的差额凭空消失。它不属于任何进程,任务管理器的进程列表对它完全失明。机主 Carrey 此时说了一句话,事后看是全案最准的直觉:

"内存是被锁住了吗?"—— 案发时用户的原话

"锁住"这个词选得惊人地精确。因为消失的内存确实去了一个被锁住、不能换出、不属于任何进程的地方。查一个大多数人从没看过的指标:

(Get-CimInstance Win32_PerfRawData_PerfOS_Memory).PoolNonpagedBytes / 1GB
# → 69 GB(正常值:1–3 GB)

非分页池(Nonpaged Pool),69GB。正常机器上这个数字是 1 到 3GB。差额找到了下落——但这是个什么地方?

不能腾退的档案室

类比 · Analogy 把物理内存想象成一栋写字楼。普通进程租的工位,物业(操作系统)随时可以把不常用的腾退到地下仓库(页面文件)。但楼里有一间内核专用档案室——存放的是操作系统自己随时要用的急件,规定永远不准搬走、不准腾退。这间档案室就是非分页池。

准确地说:非分页池是内核为驱动程序保留的内存区域,存放那些必须常驻物理内存、不能换出到页面文件的数据结构。它有三个让排查者头疼的性质:不属于任何进程(任务管理器进程列表看不到)、不能换页(物理内存被实打实占住,这就是"锁住")、没有进程退出会自动回收它。换句话说:凶手在内核里,而我们手里的侦查工具全是用户态的。

02内核取证:pool tag 定罪

给 69GB 验明正身,不装 WDK 也能做

幸运的是,内核记账很规矩。驱动每次从非分页池申请内存,都要登记一个四字符的 pool tag——相当于档案室里每个柜子贴的部门标签。把所有标签按占用量排序,谁在疯狂堆档案就一目了然。传统做法要装 WDK 用 poolmon,但其实一段 PowerShell P/Invoke 就够:

# 调 ntdll 的 NtQuerySystemInformation,
# 信息类 SystemPoolTagInformation = 22,
# 返回每个 pool tag 的分配次数与字节数。
[ntdll]::NtQuerySystemInformation(22, $buf, $len, [ref]$ret)
# 解析 SYSTEM_POOLTAG 数组 → 按 NonPagedUsed 排序

排行榜出来的瞬间,案件性质就清楚了——这不是"很多东西都在涨",而是一个 tag 的独角戏

排名Pool Tag非分页池占用含义
1NtFC67.3 GBNTFS File Control Block
2(第二名)296 MB正常水位

第一名是第二名的两百多倍。NtFC 这个 tag 属于 NTFS 文件系统驱动,FC 指 File Control Block(文件控制块,FCB)

NtFC:文件世界的登记表

类比 · Analogy 每当有程序打开一个文件,NTFS 驱动就在内核档案室里为这个文件建一份物业登记表:它在磁盘哪个位置、谁正在用、加了什么锁。文件用完、缓存淡出,登记表才注销。67GB 的登记表意味着——有程序在以疯狂的速度打开海量文件,而这些档案迟迟不被释放

到这里,凶器已经验明:FCB 雪崩。但 FCB 是 NTFS 替别人建的档案——NTFS 自己不会无缘无故扫盘。谁在打开这些文件?侦查必须回到用户态。

03追踪扫盘者

十五个同名进程里找一个纵火犯

找"谁在疯狂开文件",最直接的探针是每进程 IO 计数器:

Get-Counter '\Process(*)\IO Data Operations/sec'
# claude → 125,000 ops/sec
# 参照系:正常进程通常是几百

一个叫 claude 的进程,每秒 12.5 万次 IO 操作,比正常进程高出三个数量级。嫌疑人锁定了?没有——这台机器是重度 AI 工具工作站,跑着 15 个 claude 进程:若干个命令行会话,加上 Claude Desktop 桌面版全家桶。性能计数器里它们全叫 claudeclaude#1claude#2……名字完全无法对应到具体哪一个。

这里有个实用技巧:同名多实例时,用 ID Process 计数器把实例名翻译回 PID:

# 实例名 → PID 对照表
Get-Counter '\Process(claude*)\ID Process'
# claude#3 → PID 18244,再拿 PID 去查命令行和父进程

接下来是经典的排除法:逐个杀掉那些挂了一两天的旧 CLI 会话,每杀一个就回头看泄漏曲线。结果诡异——泄漏没停。更诡异的是,高 IO 像是会"接力":杀掉一个,下一个进程的 IO 立刻飙上来。

侦查笔记

事后复盘,"接力"是个误读:不是高 IO 在进程间转移,而是本来就有多个并发源,杀掉最响的一个,第二响的才浮出水面。排除法没有错,但解读现象时差点被它带偏。

04真凶现身与第一次止血

杀进程能止血,但尸体留在档案室里

排除完所有 CLI 会话后,剩下的嫌疑人只有一组:Claude Desktop——微软商店安装的桌面应用。它的主进程此刻的读数是 16.8 万 ops/sec,全场最高。杀掉它,泄漏曲线的反应不是放缓,而是瞬间归零——连续采样,非分页池增长 0 MB。

凶手抓到了。但案子只破了一半,因为此时非分页池已经堆到 79.2GB,而且一动不动。这是 FCB 泄漏最讨厌的性质:

类比 · Analogy 杀掉进程相当于把疯狂往档案室塞登记表的员工开除了——但已经塞进去的 79GB 档案不会自己消失。内核档案室没有"一键清空",唯一的回收方式是把整栋楼推倒重盖:重启。

重启。开机后内存占用从 93% 回落到 31%,非分页池回到 1.48GB——教科书般的正常值。止血完成,案卷似乎可以合上了:凶手是 Claude Desktop,疯狂扫盘导致 FCB 雪崩,杀进程加重启解决。

如果故事到这里结束,这只是一篇普通的排障记录。真正值钱的部分在后面。

05反转:用户的一句话

"是点了更新之后才出问题的"

就在准备结案时,Carrey 补了一条关键证词:

"是点了它的更新之后才出问题的,更新之前一直正常。"—— 关键线索,直接改写侦查方向

这句话信息量极大。"Claude Desktop 有 bug"和"Claude Desktop 更新后开始出问题"是两个完全不同的案情——后者意味着存在一个明确的触发事件,且"凶手是这个应用本身"的结论可能太粗糙了。验证方法很直接:重新启动桌面版,盯着计数器。

# 重新启动 Claude Desktop 后的实测
30 秒内   → IO 飙回 200,000 ops/sec
3 分钟内  → 非分页池泄漏 +1974 MB

复发。三分钟泄漏近 2GB,照这个速度一夜就能重演 79GB 惨案。但这次有备而来——直接去翻应用自己的日志,抓到了实锤:

# Claude Desktop 内置组件 CCD(Claude Code 引擎)日志,反复出现:
Download attempt 3/3 failed —
ENOENT: no such file or directory,
mkdir 'C:\Users\...\AppData\Roaming\Claude\claude-code\2.1.170'

内置组件在反复尝试安装新版引擎 2.1.170,每次都在 mkdir 这一步失败,报 ENOENT——"路径不存在"。失败,重试,再失败,每一轮重试都伴随疯狂的文件扫描。泄漏的发动机找到了。

但最大的谜团也在这里:这个目录明明存在。亲手 ls 过,路径完整、权限正常。一个存在的目录,为什么 mkdir 会报"不存在"?

06谜底:三周前埋下的传送门

凶手不是 bug,是"新版本 × 历史改动"的化学反应

回查这条路径的历史,答案浮出水面。5 月 22 日——案发三周前——为了给 C 盘瘦身,Roaming\Claude 数据目录被整个搬到了 D 盘,原位置留了一个 NTFS junction(目录链接)指过去。

类比 · Analogy junction 相当于在原地留了一扇传送门:门牌还挂在 C 盘老地址,推门进去其实到了 D 盘。对绝大多数程序来说传送门完全透明——它们根本不知道自己被传送了。三周来所有软件相安无事,正是因为传送门工作得太好了。

但 Claude Desktop 不是"绝大多数程序"。它是 Microsoft Store 安装的 MSIX 打包应用——这类应用的文件访问要经过商店沙箱的虚拟文件系统(VFS)层:相当于它不直接推门,而是让一个中介替它跑腿。而这个中介有个隐蔽的脾气:对 junction 传送门后面的路径执行 mkdir 时,会报 ENOENT。目录对你我存在,对它的中介"不存在"。

至此时间线完美闭环:

为什么三周都没事?更新前的旧引擎 2.1.111 早已装好,运行时根本不需要在那条路径下新建目录——传送门后面只读不写,中介不挑事。为什么点更新后立刻出事?新版要安装 2.1.170,第一步就是在传送门后面 mkdir——中介罢工,安装失败,进入失败重试循环,每轮重试伴随海量文件扫描,FCB 雪崩,非分页池两天堆到 79GB。

本案核心洞察

真正的根因不是单一 bug,而是"新版本行为 × 三周前的环境改动"的组合。单看任何一半都无害:junction 搬家用了三周毫无问题;CCD 自动更新在正常路径上也工作正常。两者相遇才爆炸——这类组合型根因,恰恰是只盯着"最近改了什么"的排查最容易漏掉的。

07根治与验证

拆掉传送门,用数字宣布结案

根治方案毫无悬念:拆掉 junction,把目录搬回 C 盘原位。三周前搬走时它还是 C 盘瘦身的"大头",此刻只剩 1.61GB——为这点空间留一扇会炸的传送门,完全不值。

搬回后再次启动桌面版,这次日志的画风完全不同:

# CCD 一次安装成功:
Installed at ...\Roaming\Claude\claude-code\2.1.170\claude.exe
# 还顺手清理了旧版 2.1.111

最后是量化验收——结案不能靠"看起来好了",要靠数字:持续监控 3 分钟,非分页池增长 0 MB。对照事故期间同窗口约 1800MB 的涨幅,判决毫无争议。彻底修复。

事故时间线

时间事件角色
5/22C 盘瘦身,Roaming\Claude 搬到 D 盘,原位留 junction埋雷
6/10点击 Claude Desktop 更新,新引擎 2.1.170 安装失败进入重试循环触发
6/10–6/12失败重试 + 疯狂扫盘持续两天,非分页池累积到 79GB累积
6/12 下午侦破:非分页池 → NtFC tag → IO 计数器 → 锁定桌面版;重启止血止血
6/12 复测复发(3 分钟 +1974MB),日志锁定 ENOENT,根因指向 junction定罪
6/12 收尾拆 junction 搬回目录,一次安装成功,3 分钟 0 MB 增长根治

08可复用的侦破方法论

下次再遇到"内存去哪了",照这张单子走

排查路径清单

  • 内存高但进程对不上账 → 先查非分页池。
    (Get-CimInstance Win32_PerfRawData_PerfOS_Memory).PoolNonpagedBytes,超 3–4GB 即异常,进程列表看不到它是正常的。
  • 非分页池异常 → pool tag 排行定位驱动。
    P/Invoke 调 NtQuerySystemInformation(SystemPoolTagInformation=22),无需装 WDK / poolmon。一家独大的 tag 就是凶器。
  • 找扫盘者 → 每进程 IO 计数器。
    Get-Counter '\Process(*)\IO Data Operations/sec',高出正常值三个数量级的就是嫌疑人;同名多进程用 \Process(name*)\ID Process 翻译回 PID。
  • 杀进程只能止血。
    非分页池里已堆积的 FCB 不会随进程退出释放,只能重启回收。止血和根治是两件事。
  • 复发判据要量化。
    启动可疑应用后监控 3 分钟:非分页池涨幅 <100MB 为正常(本案事故期同窗口约 1800MB)。"感觉没事了"不算数。
  • 给 Microsoft Store / MSIX 应用的数据目录做 junction 搬家 = 定时炸弹。
    商店应用的 VFS 层对 junction 后路径的 mkdir 会报 ENOENT。系统级目录迁移务必记录在案——出怪病时第一个回查。

三条元教训

一、用户的模糊直觉可能比仪表盘更准。"内存被锁住了吗"——一个自认电脑小白的提问,精确命中了非分页池"不可换出"的本质;后来"点了更新才出问题"更是直接扭转结案方向。倾听原话,别急着翻译成自己熟悉的术语。

二、"杀了 A,高 IO 跑到 B"不一定是接力。更可能本来就有多个并发源,杀掉最大的之后第二名才可见。排除法本身没问题,警惕的是对中间现象的过度解读。

三、根因常常是组合,不是单点。"新版本行为 × 历史环境改动"——任何一半单独存在都无害。所以排查到"哪个程序"只是中场,追到"为什么是现在、为什么是这台机器"才算终场。


案卷归档:凶器 NtFC,凶手 VFS × junction,
动机——三周前一次好心的 C 盘瘦身。