简介: Linux 内核函数的热替换“撞上”函数调用约定还靠谱吗?
Linux 内核热补丁可以修复正在运行的 linux 内核,是一种维持线上稳定性不可缺少的措施,现在比较常见的比如 kpatch 和 livepatch。内核热补丁可以修复内核中正在运行的函数,用已修复的函数替换掉内核中存在问题的函数从而达到修复目的。
函数替换的思想比较简单,就是在执行旧函数时绕开它的执行逻辑而跳转到新的函数中,有一种比较简单粗暴的方式,就是将原函数的第一条指令修改为“ jump 目标函数”指令,即直接跳转到新的函数以达到替换目的。
那么,问题来了,这么做靠谱吗?直接将原函数的第一条指令修改为 jump 指令,会破坏掉原函数和它的调用者之间的寄存器上下文关系,存在安全隐患!本文会针对该问题进行探索和验证。
因此,当 funA 再次使用到 R 的数据已经是错误的数据了。如果 funA 在调用 funB 前保存寄存器 R 中的数据,funB 返回后再将数据恢复到 R 中,或者 funB 先保存 R 中原有的数据,然后在返回前恢复,就可以解决这类问题。
需要对相应的寄存器进行保存和恢复操作:
调用约定,gcc 它遵守了吗?
设问:当函数实现很简单,只用到了少量寄存器,那没使用到的还需要保存吗?
答案:it depends。根据编译选项决定。
众所周知,GCC 编译器有 -O0、-O1、-O2 和 -Ox 等编译优化选项,优化范围和深度随 x 增大而增大(-O0是不优化,其中隐含的意思是,它会严格遵循 ABI 中的调用约定,对所有使用的寄存器进行保存和恢复)。
Linux 内核选用的都是 -O2 优化。GCC 会选择性的不遵守调用约定,也就是设问里提到的,不需要保存没使用到的寄存器。
当【运行时替换】撞见【调用约定】
GCC 之所以可以做这个优化,是因为 GCC 高屋建瓴,了解程序的执行流。当它知道 callee,caller 的寄存器分配情况,就会大胆且安全地做各种优化。
但是,运行时替换破坏了这个假设,GCC 所掌握的 callee 信息,极有可能是错误的。那么这些优化可能会引发严重问题。这里以一个具体的实例进行详细说明,这是一个用户态的例子( x86_64 平台):
- 程序解释:该程序是对输入的数字进行计算,运行时利用 jump 指令将程序中的函数 b 替换为 newb 函数,即,将 y = x + x 计算过程替换为 y = x + (2x) ^ 3 * x;
- 程序编译:gcc test.c -o test -O2,这里我们采用的是与编译内核相同的优化选项 -O2;
- 程序执行:./test,输入参数:4,输出结果:2056;
- 程序错误:2056 是错误的结果,应该是 2052,而且直接调用 newb 函数编译执行的结果是 2052。
该例子说明,直接使用 jump 指令替换函数在 -O2 的编译优化下,会出现问题,安全性受到了质疑和冲击!!!
上述例子中,我们将函数 b 用 jump 指令替换为 newb 函数,在 -O2 的编译优化下出现了计算错误的结果,因此,我们需要对函数的调用执行过程进行仔细分析,挖掘问题所在。首先,我们先来查看一下该程序的反汇编(指令:objdump -d test),并重点关注 a、b 和 newb 函数:
图2 -O2 编译优化的反汇编结果
(注意:b 函数中没有对 edi 寄存器进行写操作,而且它的代码段被修改为 jump 指令跳转到 newb 函数)
数据出错的原因在于,在函数 newb 中,使用到了 a 函数中使用的 edi 寄存器,edi 寄存器中的值在 newb 函数中被修改为 8,当 newb 函数返回后,edi 的值仍然是 8,a 函数继续使用了该值,因此,计算过程变为:8^3 4 + 8 = 2056,而正确的计算结果应该是 8^3 4 + 4 = 2052。
接下来不进行编译优化(-O0),其输出结果是正确的 2052,反汇编如下所示:
图3 不进行编译优化的反汇编
从反汇编中可以看到,函数 a 在调用 b 函数前,将 edi 寄存器的值存在了栈上,调用之后,将栈上的数据再取出,最后进行相加。这就说明,-O2 优化选项将 edi 寄存器的保存和恢复操作优化掉了,而在调用约定中,edi 寄存器本就该属于 caller 进行保存/恢复的。至于为什么编译器会进行优化,我们此刻的猜想是:
a 函数本来调用的是 b 函数,而且编译器知道 b 函数中没有使用到 edi 寄存器,因此调用者 a 函数没有对该寄存器进行保存和恢复操作。但是编译器不知道的是,在程序运行时,b 函数的代码段被动态修改,利用 jump 指令替换为 newb 函数,而在 newb 函数中对 edi 寄存器进行了数据读写操作,于是出现了错误。
这是一个典型的没有保存 caller-save 寄存器导致数据出错的场景。而编译内核采用的也是 -O2 选项。如果将该场景应用到内核函数热替换是否会出现这类问题呢?于是,我们带着问题继续探索。
我们构造了一个内核函数热替换的实例,将上面的用户态的例子移植到我们构造的场景中,通过内核模块修改原函数的代码段,用 jump 指令直接替换原来的 b 函数。然而加载模块后,结果是正确的 2052,经过反汇编我们发现,内核中 a 函数对 edi 寄存器进行了保存操作:
图4 内核中 a 函数的反汇编
内核和模块编译时采用的是 -O2 优化选项,而此处 a 函数并没有被优化,仍然保存了 edi 寄存器。
此时我们预测:对于内核函数的热替换来说,使用 jump 做函数替换是安全的。
我们猜想是否是内核编译时使用其它的编译选项导致问题不能复现。果不其然,经过探索我们发现内核编译使用的 -pg 选项导致问题不再复现。
通过翻阅 GCC 手册得知,-pg 选项是为了支持 GNU 的 gprop 性能分析工具所引入的,它能在函数中增加一条 call mount 指令,去做一些分析工作。
与本文主题没有关联,不再细说。
为了验证这个结论,我们回到上一节的用户态例子,并且增加了 -pg 编译选项:“gcc test.c -o test -O2 -pg”,此时运行结果果然正确了。查看其反汇编:
图6 增加 -pg 选项后的汇编
可以看到,每个函数都有 call mcount 指令,而且 a 函数中将 edi 寄存器保存到 ebx 中,在 newb 函数中又保存 ebx 寄存器。为什么在增加了 call mount 指令后,会做寄存器的保存操作?我们猜想,会不会是因为,由于 call mount 操作相当于调用了一个未知的函数( mcount 没有定义在同一个文件中),因此,GCC 认为这样未知的操作可能会污染了寄存器的数据,所以它才进行了保存现场的操作。
经过编译:gcc test.c mcount.c -O2 后运行,发现计算结果正确,而且反汇编中 a 函数保存了寄存器:
继续验证猜想,将 mcount 函数放在 test.c 文件中,计算结果错误,而且,反汇编中没有保存寄存器,于是我们得到了这样的猜想结论:
- GCC 在编译某个源文件时,如果文件内的某个函数(比如场景中的函数 a)调用了其它文件中的一个未知函数(比如场景中的 mcount 函数),则 GCC 会在该函数中保存寄存器;
- 开启 -pg 选项,增加了对 mcount 的调用,因此会在函数中增加对寄存器现场的保存操作,对 -O2 选项的函数调用优化起到了屏蔽作用。
神秘的 -fipa-ra 选项:真正的幕后主使
经过我们的探索和资料的查阅,发现了这个 -fipa-ra 选项,可以说它是优化的幕后主使。GCC 手册中给出 -fipa-ra 选项的解释是:
这里主要是说,如果开启这个选项,那么,callee 中如果没有使用到 caller 使用的寄存器,就没有必要保存这些寄存器,前提是,callee 与 caller 在同一个编译单元中而且 callee 函数比 caller 先被编译,这样才可能出现前面的优化。如果开启了 -O2 及以上的编译优化选项,则会使能 -fipa-ra 选项,然而,如果开启了 -p 或者 -pg 这些选项,或者,无法明确 callee 所使用的寄存器,-fipa-ra 选项会被禁用。
这段话,其实已经能 cover 掉我们前面大部分猜想的测试验证:
- -O2 选项自动使能 -fipa-ra 进行优化:在我们的场景中,函数 a 使用的 edi 寄存器,在函数 b 中没有使用到,因此函数 a 被优化,没有保存 edi 寄存器,但是在 newb 函数中,使用到了 edi 寄存器,且数据被修改,将 newb 函数替换函数 b,则计算结果出错;
- 在 -O2 中使用 -pg 选项会禁用 -fipa-ra:编译时使用 -pg 选项,计算结果是正确的,而且函数 a 保存了 edi 寄存器,说明没有对函数 a 进行优化;
- 不在同一编译单元不会被优化:去掉 -pg 选项,在函数 a 中手动调用 mcount 函数,将这个函数放在 test.c(与函数 a 为同一编译单元)与放在另一个文件 mcount.c(不同编译单元)中的计算结果不同:同一编译单元中计算结果是错误的,而且函数 a 没有保存寄存器现场;不在同一编译单元中,计算结果是正确的,函数 a(caller) 保存了寄存器现场,因为编译器无法明确函数 b(callee)所使用的寄存器。
notrace:它是二度冲击吗?
字面上来看,notrace 和 -pg 的含义可以说完全对立,-pg 让 jump 变得安全,是否又会在 notrace 上栽一个跟斗呢?幸运的是,我们接下来将看到,notrace 仅仅是禁止了 instrument function,而没有破坏安全性。
gcc 手册中的 -pg 选项给出这样的解释:
这里主要是说,加上 notrace 属性的函数,不会产生调用 mcount 的行为,那么,是否意味着不再保护寄存器现场,换句话说,notrace 的出现是否会绕过“-pg 选项对 -fipa-ra 优化的屏蔽”?于是我们又增加 notrace 属性进行验证:在 a 函数中增加 notrace 的属性,因为 a 函数是 caller,编译时开启 -pg 选项,然后检查计算结果及反汇编,最后发现,计算结果正确,而且汇编代码中保存了寄存器现场。
我们又对所有的函数追加了 notrace 属性,计算结果正确且寄存器现场被保护。但是这些简单的验证不足以证明,于是我们通过阅读 GCC 源码发现:
图10 gcc 处理每一个函数时都会检查 -fipa-rq 选项,如果为 false,则不对函数进行优化
通过源码阅读,可以确定的是,当使用了 -pg 选项后,会禁用 -fipa-rq 优化选项,GCC 检查每一个函数的时候都会检查该选项,如果为 false,则不会对该函数进行优化。
由于 flag_ipa_ra 是一个全局选项,并不是函数粒度的,notrace 也无能为力。因此,这里可以排除对 notrace 的顾虑。
经过上述的探索分析以及官方资料的查阅,我们可以得出结论:
- 内核函数的热替换,利用 jump 指令直接跳转到新函数的方式是安全的;
通过翻阅手册得知,ARMv8 ABI 中对过程调用时通用寄存器的使用准则如下
可见,ARMv8 ABI 中对函数调用时的寄存器使用有了明确的规定。
我们对于前面 x86-64 下的探索验证过程在 arm64 平台下重新做了测试,相同的代码和相同的测试过程,得出的结论和 x86-64 下的结论是一致的,即,在 arm64 下,直接利用 jump 指令实现函数替换同样是安全的。
其它语言不能保证其安全性
对于 C 语言而言,在不同的架构和系统下都有固定的 ABI 和 calling conventions,但是其它的语言不能保证,比如 rust 语言,rust 自身并没有固定的 ABI,比如社区对 rust 定义 ABI 的讨论,而且 rustc 编译器的优化和 gcc 可能会有不同,因此可能也会出现上述 caller/callee-save 寄存器的问题。
kpatch 利用的是 ftrace 进行函数替换的,它的原理如下所示:
ftrace 的主要作用是用来做 trace 的,会在函数头部或者尾部 hook 一个函数进行一些额外的处理,这些函数在运行过程中可能会污染被 trace 的函数的寄存器上下文,因此 ftrace 定义了一个 trampoline 进行寄存器的保存和恢复操作(图11 中的红框),这样从 hook 函数回来后,寄存器现场仍然是原来的模样。
kpatch 用 ftrace 进行函数替换,hook 的函数是 kpatch 中的函数,该函数的作用是修改 regs 中的 ip 字段的值,也就是将新函数的地址给到了 ip 字段,等 trampoline 恢复寄存器现场后,就直接跳转到新的函数函数去执行了。所以,对于 kpatch 而言,ftrace 的保存和恢复现场操作保护的是 kpatch 中修改 ip 字段函数的过程,而不是它要替换的新函数。
如果修复的是一个热函数,那么 ftrace 的 trampoline 会对性能产生一定的影响。所以,若考虑到性能的场景,那么使用 jump 指令直接替换函数可以很大的减少额外的性能开销。
邓二伟(扶风),2020 年就职于阿里云操作系统内核研发团队,目前从事 linux 内核研发工作。
吴一昊(丁缓),2017 年加入阿里云操作系统团队,主要经历有资源隔离、热升级、调度器 SLI 等。
陈善佩(雏雁),高级技术专家,兴趣方向包括:体系结构、调度器、虚拟化、内存管理。
讨论这么热烈,怎么能少了组织沉淀?Cloud Kernel SIG 盛情邀请你的加入
云内核 (Cloud Kernel) 是一款定制优化版的内核产品,在 Cloud Kernel 中实现了若干针对云基础设施和产品而优化的特性和改进功能,旨在提高云端和云下客户的使用体验。与其他 Linux 内核产品类似,Cloud Kernel 理论上可以运行于几乎所有常见的 Linux 发行版中。
在 2020 年,云内核项目加入 OpenAnolis 社区大家庭,OpenAnolis 是一个开源操作系统社区及系统软件创新平台,致力于通过开放的社区合作,推动软硬件及应用生态繁荣发展,共同构建云计算系统技术底座。
本文为阿里云原创内容,未经允许不得转载。