5.6 使用 UBSAN 内核检查器捕获未定义行为
上一节我们谈到,KASAN 是内存错误世界的「重炮手」,但它显然不是万能的。有些极其狡猾的 Bug——比如我们在前面看到的那些——能完美避开 KASAN 的雷达。那么,我们还有什么武器可以用?
如果你写过 C 语言,你一定有过这种时刻:代码写错了,逻辑上有漏洞,但它居然——居然能跑。有时候还跑得挺顺,直到某天深夜两点,你在生产环境加了一行无关痛痒的代码,整个系统突然炸了。
这就是 C 语言最让人背脊发凉的地方:未定义行为。
什么是未定义行为?
C 语言标准里有一块无人区。编译器通常只负责处理「正确」的情况,当你的代码越界了——比如数组溢出、有符号整数溢出、除零——编译器往往会选择「无视」。它假设这种事不会发生,并基于这个假设生成高度优化的机器码。这确实能提升性能,但代价是把安全埋进了沙堆。
更糟糕的是,这些 Bug 有极高的隐蔽性。你可能觉得一段代码在优化开关关闭时能跑,开了优化就崩;或者像我们在「Stale frames」一节看到的那样,访问过时的栈帧数据居然能成功读取,甚至读出来的数据看起来还是对的。这一切都不可预测,所以我们称之为未定义行为。
引入 UBSAN
为了对付这些幽灵,内核社区引入了 Undefined Behavior Sanitizer (UBSAN)。
和 KASAN 一样,UBSAN 也是利用编译时插桩技术来工作的。当你完全开启 UBSAN 时,内核代码会带上 -fsanitize=undefined 选项进行编译。这意味着,编译器会在关键位置插入检查代码——一旦触发违规条件,系统就会立即报告。
UBSAN 主要覆盖以下两大类问题:
-
算术相关的 UB:
- 整数溢出/下溢
- 除以零
- 位移操作越界
-
内存相关的 UB:
- 静态数组越界访问
- 空指针解引用
- 错误的内存对齐访问
- 对象大小不匹配
你可能会发现,这里有一部分功能和 KASAN 是重叠的。确实如此,UBSAN 会让内核代码体积变大,运行速度变慢(通常慢 2 到 3 倍),但它能捕获很多 KASAN 看不到的东西,特别是在开发阶段和单元测试中,它是不可或缺的。实际上,只要你还能忍受内核体积稍大一点、CPU 负担稍重一点(除了极小的嵌入式系统,现代服务器完全没压力),在生产环境开启 UBSAN 也是可行的。
配置内核开启 UBSAN
让我们打开 make menuconfig,按以下路径找到 UBSAN 的菜单位置:
Kernel hacking
-> Generic Kernel Debugging Instruments
-> Undefined behaviour sanity checker
界面大致如图 5.10 所示(此处省略图片,你可以想象那个熟悉的蓝色配置窗口)。
为了让你能跟着我一起操作,你需要确保开启以下核心配置项:
CONFIG_UBSAN:主开关。CONFIG_UBSAN_BOUNDS:这个非常重要,它负责对静态数组索引进行边界检查。CONFIG_UBSAN_MISC:捕获其他杂项的 UB。CONFIG_UBSAN_SANITIZE_ALL:开启全内核扫描。CONFIG_TEST_UBSAN=m:把测试代码编译成模块(在lib/test_ubsan.c),方便我们验证。
你可以去 lib/Kconfig.ubsan 看看这些选项的具体含义,但我建议你先别纠结,把上面的开了再说。
UBSAN 对编译过程的影响
当你把 CONFIG_UBSAN=y 编进内核后,执行编译命令时带上 V=1 参数,你就能看到编译器到底干了什么。你会看到 GCC 的命令行里多了一长串 -fsanitize=... 的选项。
比如像这样:
make V=1
[...]
gcc -Wp,-MMD,[...] -fsanitize=bounds \
-fsanitize=shift -fsanitize=integer-divide-by-zero \
-fsanitize=unreachable -fsanitize=signed-integer-overflow \
-fsanitize=object-size -fsanitize=bool -fsanitize=enum [...]
这就是插桩发生的时刻。编译器正在给你的代码织入一张安全网。
用 UBSAN 狩猎 UB
UBSAN 最擅长的一招,就是检测静态数组越界访问。
让我们来看看测试用例 #4.4。我们在代码里定义了几个全局数组:
static char global_arr1[10], global_arr2[10], global_arr3[10];
为什么要定义三个,而不是一个?
这里有个冷门的坑。在我们写这本书的时候(至少在 GCC 9.3 版本),编译器在设置全局数据的「红区」时有个 Bug。
模块里的第一个全局变量的左侧红区可能设置不正确。这会导致一个很诡异的现象:如果你对这个变量进行「左越界」(下溢)访问,检测工具可能会漏掉!为了绕过这个 Bug,我们定义了三个数组,然后在测试代码里特意把指针传给第二个(
global_arr2)。这样 KASAN 和 UBSAN 就能正常工作了。(顺便说一下,全局变量在模块里的顺序取决于链接器,这事儿不由你说了算。)
值得一提的是,这个问题在 Clang 11+ 上并不存在。
这个 Bug 是我亲自踩到的。后来我把这个问题连同内核 test_kasan 模块没覆盖这个用例的问题一起上报了。KCSAN 的维护者 Marco Elver 很快跟进并补上了测试用例(2021 年 11 月 17 日)。同时,本书的技术审阅者 Chi-Thanh Hoang 也发现这本质上是因为 GCC 缺了左侧红区,并把这一信息添进了内核 Bugzilla。希望 GCC 社区能尽快修复这个顽疾。
好了,回到代码。我们来看看其中一个测试用例——全局内存的右越界访问。我们的测试代码会对 global_arr2 进行读写操作,当然,是故意写错的:
int global_mem_oob_right(int mode, char *p)
{
volatile char w, x, y, z;
volatile char local_arr[20];
char *volatile ptr = p + ARRSZ + 3; // OOB right
[...]
} else if (mode == WRITE) {
*(volatile char *)ptr = 'x'; // 无效:右越界写入
p[ARRSZ - 3] = 'w'; // 有效:在范围内
p[ARRSZ + 3] = 'x'; // 无效:右越界写入
local_arr[ARRAY_SIZE(local_arr) - 5] = 'y'; // 有效
local_arr[ARRAY_SIZE(local_arr) + 5] = 'z'; // 无效:右越界写入
} [...]
它的调用方式是通过 debugfs 接口:
[...] else if (!strncmp(udata, "4.4", 4))
global_mem_oob_left(WRITE, global_arr2);
当这段代码运行并触发非法访问时,UBSAN 会在内核日志里甩出这样一份错误报告:
array-index-out-of-bounds in <C-source-pathname.c>:<line#>
index <index> is out of range for type '<var-type> [<size>]'
看图 5.11(省略截图),右边的窗口就是内核日志。忽略上面那一大堆 KASAN 的输出,我们重点看下面 UBSAN 的部分。它精准地指出了第 194 行的问题——尝试向局部数组的合法范围之外写入数据!
顺便说一句,随着代码的修改,你看到的行号可能会有所不同,这是正常的。
紧接着,测试用例 #4.3 又作死地尝试对局部栈变量进行读取下溢。结果?UBSAN 再次漂亮地接住了!
看图 5.12 的输出,UBSAN 再次把源文件名和行号甩在了你脸上。
UBSAN 的盲区
到这里你应该看出来了:UBSAN 非常擅长处理静态数组索引的错误——无论你是左越界还是右越界。
但是,它有一个明显的盲区:纯指针运算。如果你完全通过指针算术来搞破坏,UBSAN 可能会视而不见。而这正是 KASAN 的强项,KASAN 对所有基于指针的非法访问是一视同仁的。
此外,就像 KASAN 一样,UBSAN 也无法捕获所有的内存缺陷。
为了证明这一点,我们再次运行那个捣乱的内核模块 ch5/kmembugs_test。结果非常扎心:即使开启了 UBSAN,那三个经典问题——未初始化内存读取 (UMR)、释放后重用 (UAR) 以及 内存泄漏——依然没被抓住!
图 5.13 的截图记录了这一「惨案」。日志里干干净净,仿佛什么都没发生过。
这说明什么?说明工具是互补的。你需要 KASAN,也需要 UBSAN,还需要下一章我们要讲的其他工具。
另外别忘了,UBSAN 捕捉算术 UB 也是一把好手——整数溢出、下溢、除零,这些都是安全漏洞的重灾区。既然本章的主题是内存缺陷,我们就不在这里深入展开算术问题了。如果你好奇,可以去内核源码的 lib/test_ubsan.c 里逛逛,我强烈建议你跑一跑。
实验结果总结
好了,是时候整理一下战果了。表 5.4 总结了我们在开启 UBSAN 的情况下运行各种测试用例的结果。
(表 5.4 内容概览:展示了不同测试用例在 UBSAN 下的捕获情况,包括行号引用、编译器版本 GCC 9.3.0 以及内核配置 5.10.60-prod01 的说明。)
表格里的几个细节值得说道说道:
- [1] 测试用例编号:请参考
ch5/kmembugs_test/kmembugs_test.c源码,以及同目录下的debugfs_kmembugs.c、load_testmod和run_tests脚本。 - [2] 编译器环境:x86_64 上的 Ubuntu Linux,GCC 9.3.0。(关于 Clang 13 的部分我们在后面专门有一节讲)。
- [3] 测试内核:自定义的生产内核 5.10.60-prod01,开启了
CONFIG_UBSAN=y和CONFIG_UBSAN_SANITIZE_ALL=y。 - 测试用例 4.1 到 4.4 作用于全局静态内存(编译时分配)和栈局部内存都有效。这就是为什么它们的编号重复出现了。
表格细节详解(必读)
这里是对表中那些 [U1]、[U2] 标记的详细解释,里面藏着不少坑和边界情况:
-
[U1] UBSAN 捕获并报告了全局静态内存上的越界访问: 输出格式如下:
array-index-out-of-bounds in <path>:<line>index <idx> is out of range for type 'char [10]' -
[U2] 对象大小不匹配: 在某些情况下,UBSAN 还会顺带报告一个
object-size-mismatch错误:object-size-mismatch in <path>:<line>store to address <addr> with insufficient space for an object of type 'char [10]'在上述情况下,UBSAN 会详细列出违规细节,包括进程上下文和内核栈回溯。
这里有个有趣的转折:如果你把 KASAN 关掉(我特意为此重编了一个
CONFIG_KASAN=n的内核),只开 UBSAN,情况会有点不一样。这种配置下,你可能会直接收到一个段错误,虽然内核日志依然会清晰地指向 Bug 的源头(通过检查指令指针寄存器 RIP 的指向)。
注意: 别忘了查看下一章的表 6.4,那是一个集大成的对比表,把所有工具的检测结果放在一起,非常值得一看。
现在,你同时装备了 KASAN 和 UBSAN,手里比以前硬气多了。但我建议你先停下来消化一下这些信息,去读读后面「Catching memory defects in the kernel – comparisons and notes (Part 1)」这一节的详细笔记,并在你自己的机器上试着跑跑这些用例。
不过,还有个悬念没解开:我们前面提到,有些越界缺陷只有用 Clang 11 或更高版本编译才能被捕获。这是一个关键点。
所以,接下来让我们进入 Clang 的世界,看看怎么用这个现代化的编译器来构建我们的内核和模块。