7.2 生成一个简单的内核 Bug 和 Oops
既然工具都就位了,是时候动手干坏事了。
有一句老话叫「以毒攻毒」。为了捕捉内核 Bug,我们得先学会怎么制造一个。别担心,这对在座的各位来说应该不是难事——甚至有点让人兴奋。
教学界最经典的入门 Bug,毫无疑问就是那个恶名昭著的空指针解引用(NULL pointer dereference)。你肯定听说过,甚至可能已经在用户空间领教过它的威力(Segfault 还记得吗?)。但这次,我们要把战场转移到内核态。
计划很简单:
- 写一个故意解引用 NULL 指针的内核模块,我们称之为
oops_tryv1。 - 看着它崩溃,观察内核的 reacts。
- 进阶到
oops_tryv2,用三种不同的方式把内核炸上天。
但在此之前,我们得先搞清楚两个基本问题:上一节提到的 procmap 到底能干什么? 以及 所谓的 NULL 陷阱页到底是个什么鬼东西?
The procmap utility —— 把内存画出来
上一节我们把 procmap 安装好了,现在来看看它到底有什么用。
简单来说,它的作用是把一个进程的虚拟地址空间(VAS)——包括用户空间和内核空间——可视化成一张地图。/proc/<pid>/maps 虽然也能看,但那是纯粹的文本,读起来很累,尤其是当你面对几百行映射的时候,脑子很容易过载。procmap 能帮你一眼看穿哪里是山(已映射内存),哪里是坑(稀疏区域)。
它的 GitHub 页面写得很清楚:
procmap 是一个命令行工具,用于可视化 Linux 进程的完整内存映射,包括内核和用户空间的虚拟地址空间(VAS)。
它会输出一个按虚拟地址从高到低排列的垂直图表。最关键的是,它有智能去识别稀疏区域(Sparse Region / Hole),并且在 64 位系统上,它能向你展示那个巨大的非规范区域——在 x86_64 上,这个「空洞」大约有 16,384 PB 之大。
工具本身还在持续开发中,有些许 caveat,但用来做我们的实验已经绰绰有余了。
What's this NULL trap page anyway?
好了,切入正题。我们要触发空指针解引用,首先得知道 NULL 指针到底指向哪里。
在所有基于 Linux(以及几乎所有现代虚拟内存操作系统)的系统上,内核会把进程可用的虚拟内存区域一分为二:用户空间 VAS 和 内核空间 VAS。这通常被称为 VM split(虚拟内存切分)。
在 x86_64 上,每个进程的完整 VAS 大小是 $2^{64}$ 字节。 这听起来是个天文数字——没错,就是 16 EB(Exabyte)。1 EB = 1,024 PB = 100 万 TB。这个空间大到根本用不完。
实际上,内核在 x86_64 上默认只用了其中很小的一部分:
- 内核 VAS:128 TB,锚定在 VAS 的顶部(从
0xffffffffffffffff一直到0xffff800000000000)。 - 用户 VAS:128 TB,锚定在 VAS 的底部(从
0x00007fffffffffff一直到0x0)。
哪怕加起来也就 256 TB,相对于 16 EB(16,384 PB)的总容量,我们只用了大约 0.0015%。
Think about This
64 位的地址空间大到奢侈。我们甚至还没用到它的万分之零二。剩下的空间干什么去了?它们构成了巨大的地址「空洞」,任何对这些空洞的访问都会导致内存访问违例。
回到我们的主题:NULL 指针。
在用户 VAS 的最底端,也就是虚拟地址 0x0 到 0x4095 的这一页,被称为 NULL 陷阱页(NULL trap page)。
让我们运行 procmap 来看看它长什么样(假设你的 shell 进程 PID 是 1076):
$ procmap --pid=1076
[...]
你会看到一张图,最底部的一小块区域就是它。如果你仔细看上一节的图(图 7.1),你会注意到在 bash 进程的 mappings 下面,有一块标为 [NULL trap page] 的区域。
这个区域的工作原理很简单:它的权限全是 ---(无读、无写、无执行)。
这意味着,任何进程(或线程)试图读写这块区域,都会触发 MMU 的拒绝。
它是怎么「陷阱」的?
让我们把这个过程拆解一下,这很重要:
- 尝试访问:进程试图读写地址
0x0(或者这一页里的任何字节,比如0x100)。 - MMU 审查:CPU 的 MMU(内存管理单元)接管这个虚拟地址,准备把它翻译成物理地址。它会检查页表项。
- 权限拒绝:MMU 发现这一页的所有权限位都是 0。这不是「允许但未映射」,而是「明确禁止」。
- 触发异常:MMU 立即举手投降,向操作系统抛出一个异常。在 x86 上,这通常是一个 General Protection Fault(通用保护故障) 或者 Page Fault(缺页异常)。
- 内核介入:操作系统的异常处理函数被唤醒。这个函数运行在肇事进程的上下文中。
- 判决:内核发现:「原来是用户态的小可爱在搞事,访问了一个不该访问的地址。」
- 处决:内核给该进程发送一个致命信号——SIGSEGV。
- 结局:如果你写过 C 语言,你应该很熟悉这个结果:进程收到信号,通常会选择自我了断,控制台打印出那句经典的
Segmentation fault (core dumped)。
当然,进程也可以安装信号处理器来「捕获」这个信号,做些清理工作,但最终它还是得死。
现在我们理解了 NULL 陷阱页的机制。接下来,我们要做点出格的事:在内核模式下,无视这个规则,强行读写 NULL 地址。
A simple Oops v1 —— dereferencing the NULL pointer
这是我们的第一个受害者,oops_tryv1。它的逻辑很简单:读取或写入 NULL 地址。
如前所述,任何对 NULL 陷阱页的访问都会触发 MMU 报错。这在内核模式下也不例外。
下面是核心代码(你可以去 GitHub 上 clone 完整代码):
// ch7/oops_tryv1/oops_tryv1.c
[...]
static bool try_reading;
module_param(try_reading, bool, 0644);
MODULE_PARM_DESC(try_reading,
"Trigger an Oops-generating bug when reading from NULL; else, do so by writing to NULL");
我们定义了一个布尔模块参数 try_reading。
- 如果设为
1(yes),代码会尝试读取 NULL 地址的内容。 - 如果是默认的
0,代码会尝试写入一个字节'x'到 NULL 地址。
看一眼 init 函数:
static int __init try_oops_init(void)
{
size_t val = 0x0;
pr_info("Lets Oops!\nNow attempting to %s something %s the NULL address 0x%p\n",
!!try_reading ? "read" : "write",
!!try_reading ? "from" : "to",
NULL);
if (!!try_reading) {
val = *(int *)0x0;
/*
* 这里的注释很关键。如果我们只读不做任何处理,
* 聪明的编译器会直接把这行代码优化掉(删掉),
* 因为它觉得你读了个寂寞。
* 为了强迫编译器生成代码,我们用 pr_info 把 val 打印出来。
* 这样就能保证「触电」成功。
*/
pr_info("val = 0x%lx\n", val);
} else // 尝试写入 NULL
*(int *)val = 'x';
return 0; /* success */
}
注意:这里有个小坑。如果你只是读取 *(int *)0x0 却不使用结果,现代 GCC 优化器会直接把它当成废代码删除。那样你就看不到 Oops 了,会误以为读取 NULL 不会报错。所以我们必须用 pr_info 把变量 val 用起来,强迫生成那条访存指令。
这段代码的逻辑很直白。无论是读还是写,只要碰了 NULL 陷阱页,MMU 就会报错。
此时发生了什么?
这时候,内核模块代码正运行在 insmod 这个进程的上下文中(准确说是进程发起的系统调用上下文)。当那条非法指令执行时:
- 用户态犯错:内核会发 SIGSEGV 杀进程。
- 内核态犯错:内核发现:「等一下,是我自己写的代码出问题了。」这意味着内核本身有 Bug。内核容不得这种二义性,于是它触发了 Oops。
什么是
!!<boolean>语法? 这是个 C 语言的小技巧。!5是0,!0是1。所以!!5最终结果是1。无论你传进去什么非零整数,它都会被规范化成严格的1;如果是0,它还是0。
看看现场的惨状
当我们把模块加载进去并尝试写入 NULL 时,内核日志会刷出一大堆东西。下图 7.2 展示了最初的几行:
(图 7.2:Oops 的部分截图)
你会发现每行前面都有 Oops: 字样。这就是内核在尖叫:「我摔倒了!」
躁动开发者的救命稻草:不重启重载?
这里有个很现实的问题:一旦模块触发了 Oops,你就很难把它卸载了。
试一下 rmmod?大概率没戏。因为 Oops 发生时,insmod 进程直接被干掉了,模块的引用计数没减到 0。你可以用 lsmod 看一下:
$ lsmod |grep oops
oops_tryv1 16384 1
最右边的 1 就像墓碑一样立在那里,阻止你 rmmod。
这时候如果你想改代码重试,难道要重启机器?对于「躁动型」开发者来说,重启太浪费时间了。 这里有一个很蠢但非常有效的土办法:
make clean清理一下。- 把源文件改名(比如
oops_try_v1b.c)。 - 改 Makefile 用新文件名编译。
insmod新模块。
这样你就有了个新名字的模块,内核会把它当成新客,哪怕旧的还在那「尸体」挺着。这在频繁调试时能救命。
Doing a bit more of an Oops —— 我们的 buggy 模块 v2
光是读写 0x0 太无聊了。在 oops_tryv2 里,我们要玩点更花哨的。这个模块提供了三种让内核崩溃的方式:
- Case 1:向 NULL 陷阱页内的一个随机地址写入。
- Case 2:由你指定一个内核空间的无效地址(稀疏区域)并写入。
- Case 3:在内核工作队列里,试图写入一个未初始化的结构体成员(最接近真实 Bug 的场景)。
Case 1 —— 随机乱撞 NULL 页
跟 v1 差不多,只是这次我们用 get_random_bytes() 这个内核 API 生成一个随机数,然后对 PAGE_SIZE(通常是 4096)取模。
// ch7/oops_tryv2/oops_tryv2.c
static int __init try_oops_init(void)
{
unsigned int page0_randptr = 0x0;
[...]
} else { // 没传参数,随机撞 NULL 页
pr_info("Generating Oops by attempting to write to a random invalid kernel address in NULL trap page\n");
get_random_bytes(&page0_randptr, sizeof(unsigned int));
bad_kva = (page0_randptr %= PAGE_SIZE);
}
pr_info("bad_kva = 0x%lx; now writing to it...\n", bad_kva);
*(unsigned long *)bad_kva = 0xdead;
[...]
不管随机数是多少,只要在 0~4095 之间,它就在 NULL 陷阱页里。最后一行写入操作必定触发 Oops。
Case 2 —— 攻击内核 VAS 的「空洞」
这更有意思一点。我们定义了一个模块参数 mp_randaddr,让你自己传一个内核地址进去。
static unsigned long mp_randaddr;
module_param(mp_randaddr, ulong, 0644);
MODULE_PARM_DESC(mp_randaddr, "Random non-zero kernel virtual address; deliberately invalid, to cause an Oops!");
代码逻辑很简单:如果你传了这个参数,我们就往这个地址写 0xdead。
但问题来了:我怎么知道哪个地址是无效的?
如果随便猜一个地址,万一正好碰上内核代码段或者数据段,那就不仅仅是 Oops,可能直接 Panic 了。我们需要一个安全但无效的目标。
这就是上一节费那么大劲介绍 procmap 的原因了。
运行 procmap,只看内核空间:
$ procmap --pid=1 --only-kernel
你会看到类似图 7.3 的输出。注意看那些标记为 <... K sparse region ...> 的区域。这就是稀疏区域,也叫 Hole(空洞)。这些地方没有映射任何物理内存,全是空的。
在 x86_64 上,模块加载区(modules)和 vmalloc 区之间通常就有这种大空洞。
比如在图里,地址范围 0xffffffffc0000000 到 0xffffda377fffffff 之间就是虚空。
随便从中间挑个地址,比如 0xffffffffc000dead。这名字听起来就很吉利。
让我们开始实验:
$ modinfo -p ./oops_tryv2.ko
mp_randaddr:Random non-zero kernel virtual address; deliberately invalid, to cause an Oops! (ulong)
bug_in_workq:Trigger an Oops-generating bug in our workqueue function (bool)
$ sudo insmod ./oops_tryv2.ko mp_randaddr=0xffffffffc000dead
Killed
$
Killed。熟悉的配方,熟悉的味道。
这次内核也是因为写入了一个未映射的地址而触发了 Oops。这背后的机制跟 NULL 陷阱页是一样的:MMU 翻译失败 -> 抛出 Page Fault -> 内核的 Page Fault Handler 发现是内核态在搞非法写入 -> Oops。
Case 3 —— 在工作队列里挖坑
前两个案例都是在 insmod 的进程上下文中发生的。但内核不仅仅是进程上下文,还有很多异步运行的场景,比如中断、Tasklet、Workqueue。
真实的内核 Bug 往往藏在这些地方:你拿到了一个结构体指针,却忘了检查它是不是 NULL,然后在某个异步回调里试图访问它。
这就非常真实了。我们用 v2 模块的第三个参数 bug_in_workq 来演示。
我们定义了一个结构体,并初始化了一个 Workqueue:
struct st_ctx {
int x, y, z;
struct work_struct work;
u8 data;
} *gctx, *oopsie; /* 小心,这两个指针没分配内存! */
注意那个全局指针 oopsie。它默认是 NULL。
当 bug_in_workq=1 时,我们会设置一个工作队列任务:
static int setup_work(void)
{
gctx = kzalloc(sizeof(struct st_ctx), GFP_KERNEL);
[...]
gctx->data = 'C';
/* 初始化 work */
INIT_WORK(&gctx->work, do_the_work);
// 提交到内核默认工作队列
schedule_work(&gctx->work);
[...]
}
真正的工作函数 do_the_work 会被内核的一个 Worker 线程在稍后调用。Bug 就藏在这里:
static void do_the_work(struct work_struct *work)
{
struct st_ctx *priv = container_of(work, struct st_ctx, work);
[...]
if (!!bug_in_workq) {
pr_info("Generating Oops by attempting to write to an invalid kernel memory pointer\n");
oopsie->data = 'x'; // ⚠️ 踩雷!
}
kfree(gctx);
}
看到那句 oopsie->data = 'x'; 了吗?oopsie 是个空指针。我们试图往空指针指向的结构体里写数据。
加载这个模块:
sudo insmod ./oops_tryv2.ko bug_in_workq=yes
注意看,这次控制台没有打印 Killed。因为死掉的不是 insmod 进程,而是内核的一个无辜的 worker 线程。
图 7.5 展示了此时的 Oops 日志。你会发现它跟前面的有点不一样,但本质都是一样:非法内存访问。
A kernel Oops and what it signifies
现在我们已经能熟练地制造内核崩溃了。让我们稍微停下来,思考一下 Oops 到底意味着什么。
- Oops 不是 Segfault:Segfault 是用户态的错误信号,内核发给进程的。Oops 是内核自身的诊断消息,表明内核代码自己出了问题。虽然 Oops 可能会导致某些进程收到 SIGSEGV 甚至死亡,但那只是副作用。
- Oops 不等于 Kernel Panic:Panic 意味着内核彻底放弃治疗,系统停止运行。Oops 只是表示内核发生了一个错误,但在很多情况下,内核还能顽强地活下来继续跑(虽然可能带着伤)。
- 当然,你可以配置内核让它一遇到 Oops 就 Panic。查看
/proc/sys/kernel/panic_on_oops,如果是 1,系统就会立刻挂掉。
- 当然,你可以配置内核让它一遇到 Oops 就 Panic。查看
不管内核是继续跑还是挂掉,Oops 本质上就是内核级的 Bug。它必须被检测、被解释、被修复。
好了,现在我们不仅有了制造 Oops 的能力,还手握几个鲜活的案例。接下来,就是最硬核的部分了:我们要像法医解剖尸体一样,逐行解读那些晦涩难懂的 Oops 日志。
系好安全带。