XFS 报错但根因不在 XFS:一次 uprobe 越界写排查

Phenomenon

一周内,两台配置相近的 Linux 虚拟机先后出现业务无响应。控制台最后的关键日志来自 XFS:文件系统发现 inode 元数据损坏,随后主动执行 forced shutdown。

XFS console corruption log

从现象上看,这很像 XFS bug、虚拟磁盘写错位置,或者底层存储损坏。但这次真正写坏 XFS 元数据的并不是 XFS。

最终定位到 Linux kernel 的 uprobe 参数抓取路径:第三方安全 Agent 注册的 uprobe 抓取了超过一页大小的参数,store_trace_args() 在边界检查前已经把数据写出 per-CPU buffer。溢出的内容落到相邻内存后,XFS write verifier 成了第一个发现损坏的组件。

换句话说,XFS 是报警的人,不是制造问题的人。

Start from the XFS error

XFS 检测到元数据不符合预期时会主动关闭文件系统,防止错误继续扩大。因此,xfs_do_force_shutdown 说明 XFS 看到了损坏,但不能直接证明损坏由 XFS 产生。

面对这种问题,最开始需要同时保留几条假设:

  1. XFS 自身逻辑错误。
  2. virtio-blk、宿主机或存储后端写错位置。
  3. 内存硬件或 DMA 写坏数据。
  4. 第三方内核模块破坏内存。
  5. Linux 通用内核路径发生越界写或 use-after-free。

这里最容易犯的错误,是看到 XFS 日志就只沿着文件系统代码向下查。内存损坏问题里,打印错误的函数经常只是受害者。

Find the data signature

真正改变排查方向的是损坏数据的内容。

在异常的 inode metadata buffer 中,可以看到类似路径和 JSON 结束符的数据片段。它们不像 XFS 元数据,也不像随机 bit flip,更像某个用户态程序正在处理的字符串。

这个特征缩小了问题范围:

  • 如果是硬件随机错误,数据通常不会形成可读的 JSON 结构。
  • 如果是普通的 XFS 逻辑错误,也不应该把用户态字符串写进 inode metadata。
  • 更合理的方向,是某条从用户态取字符串的内核路径越过了自己的 buffer 边界。

此时再向前检查控制台日志,可以找到 uprobe 相关 warning,并且触发进程来自第三方安全 Agent。warning、进程和损坏数据的内容开始形成同一条证据链。

The uprobe buffer

uprobe 可以在用户态程序的指定地址放置探针。探针触发时,内核会读取寄存器或用户态内存中的参数,再把结果写入 tracing buffer。

相关路径使用一页大小的 per-CPU 临时 buffer。问题发生时,代码大致遵循下面的顺序:

  1. 根据 trace event 和动态参数计算 dsize
  2. 调用 store_trace_args() 把参数写入 ucb->buf
  3. 返回上层后才检查总长度是否超过 PAGE_SIZE

这类检查虽然存在,但没有保护作用:当检查执行时,越界写已经发生。

上游修复把检查移动到了 store_trace_args() 之前,并在长度超过一页时截断 dsize

#define MAX_UCB_BUFFER_SIZE PAGE_SIZE

ucb->dsize = tu->tp.size + dsize;
if (WARN_ON_ONCE(ucb->dsize > MAX_UCB_BUFFER_SIZE)) {
ucb->dsize = MAX_UCB_BUFFER_SIZE;
dsize = MAX_UCB_BUFFER_SIZE - tu->tp.size;
}

store_trace_args(ucb->buf, &tu->tp, regs, NULL, esize, dsize);

对应的上游提交是 373b9338c972 - uprobe: avoid out-of-bounds memory access of fetching args,该问题也被登记为 CVE-2024-50067

uprobe out-of-bounds write mechanism

Why XFS was damaged

这次抓取的参数比一页 buffer 多出少量字节。这些字节不会停在 ucb->buf 的结尾,而是继续写入相邻内存。

被覆盖的内存后来作为 XFS inode metadata buffer 使用。XFS 在 write verifier 中检查 magic、字段和结构一致性时发现内容已经损坏,于是执行 forced shutdown。

目前的证据能够确认:

  • uprobe 参数长度超过一页。
  • 旧代码先写入、后检查,存在确定的越界窗口。
  • 损坏区中出现了与触发进程参数一致的用户态字符串特征。
  • 上游存在与代码路径和现象匹配的修复。

还有一个可以继续补强的物证:从 vmcore 中取出触发 CPU 的 uprobe_cpu_buffer 物理页,再与受损 XFS buffer 的物理页比较,确认两者在故障现场是否直接相邻。它不改变修复方向,但能把最后一段内存落点推断变成可审计的现场证据。

Resolution

短期处理:

  1. 升级或停用会触发该路径的第三方 Agent 探针。
  2. 在受影响内核上回合上游补丁。
  3. 避免继续运行相同版本、相同探针配置的机器。

长期处理:

  1. 为生产虚拟机配置可用的 kdump,并验证 vmcore 确实可以写出。
  2. 保留串口或控制台日志,不能只依赖重启后仍存在的本地日志。
  3. 记录第三方安全、监控 Agent 的版本和变更时间。
  4. 将上游 commit、CVE 和内部内核版本建立对应关系。

Debugging notes

这次问题留下几个比较有用的排查原则。

Error reporter is not always the root cause

文件系统、allocator 和 verifier 经常是最先发现内存损坏的组件。看到错误后,需要回答“谁发现了损坏”和“谁制造了损坏”两个不同的问题。

Read before the fatal line

fatal error 往往出现在证据链的末端。向前读取 50 到 200 行,重点寻找 warning、异常进程名、寄存器中的长度值以及第三方模块或 tracing 事件。

Read corrupted bytes as data

不要只把损坏区域当作十六进制。尝试转成 ASCII、路径、JSON 或已知结构体。用户态字符串出现在内核元数据中,本身就是很强的方向性证据。

A bounds check after memcpy is not a bounds check

检查存在不等于检查有效。代码审计时必须确认长度验证是在写入之前还是之后,以及动态字符串、数组的累计长度是否可能超过固定 buffer。

当日志、数据签名、源码和上游补丁已经相互吻合时,也不要把暂未直接验证的物理页相邻关系写成既成事实。把缺口和验证方法留下来,事故结论才方便复查。