跳到主要内容

6.4 解读内核 SLUB 调试错误报告

好了,现在我们在内核的引导参数里加了 slub_debug=FZPU,也成功触发了一些 bug。就像我们在上一节末尾看到的,SLUB 调试机制确实抓到了坏人,并且吐出了一堆看起来很吓人的错误日志。

但这堆日志到底在说什么?

别急,这就是我们这一节要干的事:像法医解剖报告一样,逐行解读 SLUB 的调错输出。你会发现,这张错误报告里密密麻麻写满了线索——从哪个缓存出事了,到具体是哪一行代码写的那个非法字节,全都在里面。

既然我们手里已经有了 run_tests 脚本,那就直接把几个典型的测试案例抓出来,对着日志看。

读懂「越界写」的报告

我们先从测试用例 #5.2 开始。这是一个典型的向右越界写(Right OOB Write)——也就是我们在合法的 slab 对象末尾之后写了数据。在 Table 6.2 里标记为 [V4] 的那个。

一旦你执行这个操作,SLUB 调试框架就会立刻跳出来,非常大声地抱怨。它的输出大概长这样(别被篇幅吓到,我们一段段拆):

(图 6.3 插入位置:SLUB 捕获右侧越界写的报错截图第一部分)

第一行:谁报的警

BUG kmalloc-32 (Tainted: G B OE ): Right Redzone overwritten

紧跟在 BUG 后面的,是出事的 slab 缓存的名字——这里是 kmalloc-32。 这很合理,因为我们的测试代码恰好就是动态申请了 32 字节(kmalloc(32, ...))。 随后的 Right Redzone overwritten 就是诊断结论了:右侧红区被覆盖。 这非常直白——我们在 dynamic_mem_oob_right() 函数里干的事正是这个:我们在第 32 个字节的位置写了数据(合法范围本来是 0~31)。

顺便说一句,这里出现的 Tainted: ... 是内核的污染标志。因为我们加载了非官方的测试模块,内核觉得自己「脏」了,这个标记会告诉你内核是否还处于「可信」状态。

第二行:案发现场坐标

INFO: 0x00000000d969b0bf-0x00000000d969b0bf

这行 INFO 给出了被破坏内存区域的起始和结束地址。 你可能会觉得这地址看起来有点奇怪——不像平常看到的内核虚拟地址。没错,这些地址是被哈希过的。这是出于安全考虑,防止攻击者通过日志直接推断出内存布局从而泄露信息。别忘了,我们是在生产环境的内核上跑的测试。

第三行:凶手是谁

INFO: dynamic_mem_oob_right+0x39/0x9c [test_kmembugs]

这是整个报告里最有价值的信息之一。 它告诉我们,错误的访问发生在代码的哪个位置。格式是标准的 <函数>+偏移/长度 [模块名]。 翻译成人话就是:错误发生在 test_kmembugs 模块中的 dynamic_mem_oob_right() 函数里,相对于函数起始地址偏移 0x39 字节的地方(内核估算这个函数总长 0x9c)。 (下一章我们会详细讲怎么利用这个偏移量精准定位源代码行数,先记下这个技能点。)

这行的右边通常还会显示进程上下文——PID 和它跑在哪个 CPU 核心上。

第四行:回溯栈

接下来是一长串内核栈回溯:

kmem_cache_alloc_trace+0x40b/0x450
dynamic_mem_oob_right+0x39/0x9c [test_kmembugs]
dbgfs_run_testcase+0x4d9/0x59a [test_kmembugs]
vfs_write+0xca/0x2c0
[...]

读这个栈帧有个规矩:从下往上读,并且忽略掉以 ? 开头的行。 你可以很清楚地看到调用链:用户空间通过 vfs_write 进入内核,最终在 dynamic_mem_oob_right() 这里翻了车。

第五行:谁放的毒

INFO:Freed in kvfree+0x28/0x30 [...]

SLUB 还很贴心地(如果它能贴的话)告诉你是谁释放了这块内存。这个信息通常能帮你定位罪魁祸首——因为在大多数情况下,释放内存的任务和当初申请它的任务是同一个。

在这行 INFO: 下面,通常会跟着释放时的调用栈。不过要注意,这个「谁释放」的信息并不总是准确的,有时候可能会拿不到。


接下来的部分更有意思,是一堆统计数据和内存快照。

(图 6.4 插入位置:SLUB 报错截图第二部分,显示被破坏的红区、对象内存和填充内容)

这里展示了 slab 的统计信息、那个倒霉对象的状态,以及最关键的内存内容。你可以看到左右红区的值、填充字节以及实际内存区域的内容。

让我们回头看看那段测试代码:

// ch5/kmembugs_test.c
int dynamic_mem_oob_right(int mode)
{
volatile char *kptr, ch = 0;
char *volatile ptr;
size_t sz = 32;
kptr = (char *)kmalloc(sz, GFP_KERNEL);
// [...]
ptr = (char *)kptr + sz + 3; // right OOB

// [...]
} else if (mode == WRITE) {
/* 这里的 OOB 访问不会被 UBSAN 抓到,但会被 KASAN/SLUB 抓到! */
*(volatile char *)ptr = 'x'; // invalid, OOB right write

/* 而下面这些 OOB 访问会被 KASAN/UBSAN 抓到。
结论:只有基于索引的访问才会被 UBSAN 抓。 */
kptr[sz] = 'x'; // invalid, OOB right write
}
}

我们在代码里高亮的那两处,故意写了一个非法的 x(ASCII 0x78)。 现在回到图 6.4,我在那张图里圈出来的错误写操作,你就能清晰地看到那个 0x78 了——这确实是我们的测试代码写进去的。这就是铁证。

关于毒

你还会在日志里看到各种各样的魔数,这就是 SLUB 的 **Poisoning(投毒)**机制在起作用(因为我们开了 P 标志):

  • 0x6b:这是用来初始化合法 slab 内存区域的值,代表「未初始化」。
  • 0xa5:这是结束标记的投毒值。
  • 0x5a:这是代表「使用未初始化内存」的投毒值。

这些数字不是乱来的,它们是内存侦探手里的指纹库。

最后的部分

(图 6.5 插入位置:SLUB 报错截图第三部分,显示 CPU 寄存器、修复信息等)

在常规的内核 bug 报告末尾,你通常会看到 CPU 寄存器的值。

但最下方那两行才是 SLUB 的神来之笔:

FIX kmalloc-32: Restoring 0x00000000d969b0bf-0x00000000d969b0bf

因为我们开了 F 标志(健全性检查),内核不仅报错,还试图修复现场。它会尝试把那个被破坏的 slab 对象恢复到「正确」的状态(比如把被覆盖的红区填回去)。 当然,这不总是能成功的,而且这种修复只是为了防止内核立刻崩溃,并不代表问题消失了。 日志里还会提示 ... not freed,这也很正常——因为这份报告是在对象还没被释放的时候就打出来的(我们的代码稍后才会调用 kfree)。

读懂「释放后重用」(UAF)的报告

接下来看看 Use-After-Free(UAF),也就是 Table 6.2 里标记为 [V5] 的那个案例。

UAF 的报错长这样:

BUG kmalloc-32 (Tainted: G B OE ): Poison overwritten
--------------------------------------------
INFO: 0x00000000d969b0bf-0x00000000d969b0bf @offset=872. First byte 0x79 instead of 0x6b
INFO: Allocated in uaf+0x20/0x47 [test_kmembugs] age=5 cpu=5 pid=2306

模板和刚才一样,但诊断结论变了:Poison overwritten(投毒被覆盖)。 为什么?因为在 uaf() 测试用例里,我们先 kfree 了这块内存,然后又往里面写了一个字节!

这里的 INFO 行给出了被破坏区域的哈希地址,还有一个非常关键的信息:offset=872First byte 0x79 instead of 0x6b。 意思是:在偏移 872 的地方,本该是投毒值 0x6b 的地方,现在变成了 0x79(这正是我们写进去的字符 y 的 ASCII 值)。

随后的 INFO 行显示了分配发生的位置(uaf+0x20/0x47),以及进程 PID。 在这之下,照例会有栈回溯,以及释放时的信息:

INFO: Freed in uaf+0x34/0x47 [test_kmembugs] age=5 cpu=5 pid=2306

这里你会发现一个有趣的事实:分配和释放都在同一个函数 uaf 里,甚至连 PID 都没变。这就是典型的自作自受——分配它的是它,释放它的也是它,破坏它的还是它。

读懂「重复释放」(Double-Free)的报告

最后快速看一下 Double-Free(重复释放),Table 6.2 里标记为 [V6] 的案例。内核是这样报告的:

BUG kmalloc-32 (Tainted: G B OE ): Object already free
--------------------------------------------
INFO: Allocated in double_free+0x20/0x4b [test_kmembugs] age=1 cpu=5 pid=2330

结论很直白:Object already free(对象已经释放了)。 后面的模板格式和你刚才学到的一模一样。你可以尝试自己解读一下:这是谁干的?在哪个函数?

关于未初始化读取(UMR)

我们之前其实已经见过 SLUB 怎么处理未初始化内存读取(UMR,Table 6.2 里标记为 [V3][V7] 的测试)。 当你开启了 slub_debug,虽然这不一定总是触发一个醒目的 BUG 报告,但如果你 dump 出那段内存,你会看到里面全是毒值 0x6b。 这就是 SLUB 在低调地告诉你:「兄弟,这块内存还没初始化呢,你读出来的东西都是垃圾。」

到这里,你应该能总结出一个规律: 虽然 SLUB 调试框架能抓到 slab 内存上绝大部分的破坏问题,但它对越界读似乎没什么办法。 记不住?没关系,回头看一眼 Table 6.2,那几片空白的 [ ] 就是 SLUB 的盲区。如果你想抓到越界读,你得请出下一章的大杀器——KASAN。