Livepatch
这份文件概述了有关内核热补丁的基本信息。
1. 动机
有许多情况下,用户不愿意重新启动系统。这可能是因为他们的系统正在进行复杂的科学计算,或者在高峰期使用时负载很重。除了保持系统运行,用户还希望拥有一个稳定和安全的系统。热补丁通过允许对函数调用进行重定向,从而在不重新启动系统的情况下修复关键函数,为用户提供了这两者。
2. Kprobes、Ftrace、热补丁
Linux内核中有多种机制与代码执行重定向直接相关,即内核探针、函数跟踪和热补丁:
-
内核探针是最通用的。可以通过在任何指令处放置断点指令来重定向代码。
-
函数跟踪从预定义位置调用代码,该位置靠近函数入口点。这个位置是由编译器使用“-pg”gcc选项生成的。
-
热补丁通常需要在函数入口的最开始重定向代码,此时函数参数或堆栈尚未以任何方式被修改。
这三种方法都需要在运行时修改现有代码。因此它们需要相互了解,不得相互干扰。大部分这些问题都是通过使用动态ftrace框架作为基础来解决的。当函数入口被探测时,一个Kprobe会注册为一个ftrace处理程序,参见CONFIG_KPROBES_ON_FTRACE。同时,一个来自热补丁的替代函数会通过自定义ftrace处理程序被调用。但是也存在一些限制,见下文。
3. 一致性模型
函数是有原因的。它们以一定方式获取或释放锁,读取、处理甚至写入一些数据,有返回值。换句话说,每个函数都有一个定义好的语义。
许多修复不会改变修改函数的语义。例如,它们添加了空指针或边界检查,通过添加缺失的内存屏障来修复竞争,或在关键部分周围添加了一些锁定。大部分这些改变都是自包含的,函数对系统的呈现方式保持不变。在这种情况下,函数可能会独立地一个接一个地进行更新。
但也存在更复杂的修复。例如,一个补丁可能会同时改变多个函数中锁的顺序。或者一个补丁可能会交换一些临时结构的含义,并更新所有相关的函数。在这种情况下,受影响的单元(线程、整个内核)需要同时开始使用所有新版本的函数。此外,切换必须只在安全的情况下进行,例如在受影响的锁被释放或修改结构中没有数据存储时。
如何以安全的方式应用函数的理论相当复杂。目标是定义所谓的一致性模型。它试图定义新实现何时可以使用,以使系统保持一致。
热补丁具有一个一致性模型,它是kGraft和kpatch的混合体:它使用kGraft的每个任务一致性和系统调用屏障切换,结合了kpatch的堆栈跟踪切换。还有一些备用选项,使其非常灵活。
补丁是基于每个任务应用的,当任务被认为可以安全切换时。当启用补丁时,热补丁进入过渡状态,任务正在趋向于已打补丁的状态。通常,这个过渡状态可以在几秒钟内完成。当禁用补丁时,相同的序列发生,只是任务从已打补丁的状态收敛到未打补丁的状态。
中断处理程序继承了它中断的任务的已打补丁状态。对于派生的任务也是如此:子任务继承了父任务的已打补丁状态。
热补丁使用了几种互补的方法来确定何时可以安全地打补丁任务:
-
第一种最有效的方法是检查正在休眠任务的堆栈。如果给定任务的堆栈上没有受影响的函数,那么该任务将被打补丁。在大多数情况下,这将在第一次尝试中打补丁大部分或所有的任务。否则,它将定期尝试。如果体系结构具有可靠的堆栈(HAVE_RELIABLE_STACKTRACE),则此选项才可用。
-
如果需要,第二种方法是内核退出切换。当任务从系统调用返回到用户空间、从用户空间中断返回或从信号返回时,任务将被切换。它在以下情况下很有用:
-
打补丁I/O密集型用户任务,它们正在受影响的函数上休眠。在这种情况下,您必须发送SIGSTOP和SIGCONT以强制其退出内核并打补丁。
-
打补丁CPU密集型用户任务。如果任务高度CPU密集,则下次被IRQ中断时它将被打补丁。
-
-
对于空闲的“swapper”任务,因为它们永远不会退出内核,它们在空闲状态之前有一个klp_update_patch_state()调用,这使它们在CPU进入空闲状态之前可以被打补丁。
(注意,目前还没有针对kthreads的这种方法。)
没有HAVE_RELIABLE_STACKTRACE的体系结构完全依赖于第二种方法。很可能一些任务仍在使用旧版本的函数运行,直到该函数返回。在这种情况下,您必须向任务发送信号。这尤其适用于kthreads。它们可能不会被唤醒,需要被强制唤醒。有关更多信息,请参见下文。
除非我们能想出另一种打补丁kthreads的方法,否则没有HAVE_RELIABLE_STACKTRACE的体系结构不被认为是内核热补丁的完全支持。
/sys/kernel/livepatch/<patch>/transition
文件显示了补丁是否处于过渡状态。在给定时间内只能有一个补丁处于过渡状态。如果任何任务卡在初始补丁状态,补丁可以无限期地保持在过渡状态。
过渡可以通过在过渡进行中写入/sys/kernel/livepatch/<patch>/enabled
文件的相反值来被撤销并有效地取消。然后所有任务将尝试重新收敛到原始的补丁状态。
还有一个/proc/<pid>/patch_state
文件,可用于确定哪些任务正在阻止补丁操作的完成。如果补丁处于过渡状态,此文件显示0表示任务未打补丁,1表示任务已打补丁。否则,如果没有补丁处于过渡状态,它显示-1。任何阻止过渡的任务都可以通过SIGSTOP和SIGCONT信号来强制它们改变打补丁状态。尽管这可能对系统有害。向所有剩余的阻止任务发送一个虚假信号是一个更好的选择。实际上并没有发送正确的信号(信号挂起结构中没有数据)。任务被中断或唤醒,并被强制改变打补丁状态。虚假信号每15秒自动发送一次。
管理员还可以通过/sys/kernel/livepatch/<patch>/force
属性影响过渡。在那里写入1会清除所有任务的TIF_PATCH_PENDING标志,从而强制任务进入打补丁状态。重要提示!force属性是用于在过渡因为阻止任务而长时间卡住的情况下使用的。管理员应收集所有必要的数据(即这些阻止任务的堆栈跟踪)并请求补丁分发者批准强制过渡。未经授权的使用可能会对系统造成伤害。这取决于补丁的性质,哪些函数被(未)打补丁,以及阻止任务正在休眠的哪些函数(/proc/<pid>/stack
可能在这里有所帮助)。当使用force功能时,rmmod补丁模块的移除将被永久禁用。如果一个补丁模块被在循环中禁用和启用,就无法保证没有任务在其中休眠。这意味着无限的引用计数。
此外,force的使用可能还会影响将来应用的热补丁,并对系统造成更多伤害。管理员应首先考虑简单地取消过渡(见上文)。如果使用了force,应该计划重新启动,并且不再应用更多的热补丁。
3.1 向新体系结构添加一致性模型支持
要向新体系结构添加一致性模型支持,有几个选项:
-
添加CONFIG_HAVE_RELIABLE_STACKTRACE。这意味着移植objtool,并且对于非DWARF展开器,还要确保堆栈跟踪代码能够检测到堆栈上的中断。
-
或者,确保每个kthread在安全的位置调用klp_update_patch_state()。Kthreads通常在一个重复执行某些动作的无限循环中。切换kthread的补丁状态的安全位置将在循环的指定点,那里没有锁被获取,并且所有数据结构处于良好定义的状态。
当使用工作队列或kthread worker API时,位置是明确的。这些kthreads在通用循环中处理独立的动作。
对于具有自定义循环的kthreads来说,安全位置必须在逐个案例的基础上小心选择。
在这种情况下,没有HAVE_RELIABLE_STACKTRACE的体系结构仍然可以使用一致性模型的非堆栈检查部分:
-
当用户任务穿越内核/用户空间边界时打补丁;和
-
在它们指定的打补丁点上打补丁kthreads和空闲任务。
这个选项不如选项1好,因为它需要向用户任务发送信号并唤醒kthreads来打补丁。但对于那些尚未具有可靠堆栈跟踪的体系结构来说,这仍然可以是一个很好的备用选项。
-
4. Livepatch 模块
Livepatch 使用内核模块进行分发,参见 samples/livepatch/livepatch-sample.c。
该模块包括我们想要替换的函数的新实现。此外,它定义了一些描述原始实现和新实现之间关系的结构。然后,当加载 livepatch 模块时,有代码使内核开始使用新代码。同时,还有代码在移除 livepatch 模块之前进行清理。所有这些将在接下来的章节中详细解释。
4.1. 新函数
通常,函数的新版本只是从原始源代码中复制过来。一个好的做法是为它们添加前缀,以便它们可以与原始函数区分开来,例如在回溯中。此外,它们可以声明为静态,因为它们不会直接调用,也不需要全局可见性。
补丁仅包含实际修改的函数。但它们可能需要访问原始源文件中仅在本地可访问的函数或数据。这可以通过生成的 livepatch 模块中的特殊重定位部分来解决,详细信息请参见 Livepatch 模块 ELF 格式。
4.2. 元数据
补丁由几个结构描述,将信息分为三个级别:
-
对于每个修补的函数,定义了 klp_func 结构。它描述了特定函数的原始实现和新实现之间的关系。
该结构包括原始函数的名称(作为字符串)。函数地址是在运行时通过 kallsyms 找到的。
然后它包括新函数的地址。它是通过直接分配函数指针来定义的。请注意,新函数通常在同一源文件中定义。
作为可选参数,可以使用 kallsyms 数据库中的符号位置来消除相同名称的函数。这不是数据库中的绝对位置,而是它仅针对特定对象(vmlinux 或内核模块)被发现的顺序。请注意,kallsyms 允许根据对象名称搜索符号。
-
klp_object 结构在同一对象中定义了一组修补的函数(klp_func 结构)。其中对象可以是 vmlinux(NULL)或模块名称。
该结构有助于将每个对象的函数分组和处理在一起。请注意,修补的模块可能在修补本身之后加载,并且相关函数可能仅在可用时才被修补。
-
klp_patch 结构定义了一组修补的对象(klp_object 结构)。
该结构一致地处理所有修补的函数,并最终同步处理。只有当找到所有修补的符号时,整个补丁才会被应用。唯一的例外是尚未加载的对象(内核模块)的符号。
有关补丁如何在每个任务基础上应用的更多详细信息,请参见“一致性模型”部分。
5. Livepatch 生命周期
Livepatch 可以通过五个基本操作来描述:加载、启用、替换、禁用、移除。
其中替换和禁用操作是互斥的。它们对于给定的补丁具有相同的结果,但对于系统而言则不然。
5.1. 加载
唯一合理的方法是在加载 livepatch 内核模块时启用补丁。为此,在 module_init() 回调中必须调用 klp_enable_patch()。主要有两个原因:
首先,只有模块可以轻松访问相关的 klp_patch 结构。
其次,当补丁无法启用时,错误代码可能会被用来拒绝加载模块。
5.2. 启用
通过在 module_init() 回调中调用 klp_enable_patch() 来启用 livepatch。系统将在此阶段开始使用修补函数的新实现。
首先,根据它们的名称找到修补函数的地址。在“新函数”部分提到的特殊重定位将被应用。相关条目将在 /sys/kernel/livepatch/<name>
下创建。当任何上述操作失败时,补丁将被拒绝。
其次,livepatch 进入过渡状态,任务正在收敛到修补状态。如果原始函数首次被修补,将创建一个特定于函数的 klp_ops 结构,并注册一个通用的 ftrace 处理程序。此阶段由 /sys/kernel/livepatch/<name>
/transition 中的值 '1' 表示。有关此过程的更多信息,请参见“一致性模型”部分。
最后,一旦所有任务都已被修补,'transition' 值将变为 '0'。
-
[1] 请注意,函数可能会被多次修补。对于给定函数,ftrace 处理程序只注册一次。进一步的修补只是向 struct klp_ops 的 func_stack 列表添加一个条目。通过 ftrace 处理程序选择正确的实现,请参见“一致性模型”部分。
因此,强烈建议使用累积 livepatch,因为它有助于保持所有更改的一致性。在这种情况下,函数可能在过渡期间仅被修补两次。
5.3. 替换
所有已启用的补丁可能会被具有 .replace 标志的累积补丁替换。
一旦启用新补丁并且“过渡”完成,那么与被替换的补丁相关的所有函数(klp_func 结构)将从相应的 struct klp_ops 中移除。当相关函数未被新补丁修改且 func_stack 列表为空时,也将取消注册 ftrace 处理程序并释放 struct klp_ops。
有关更多详细信息,请参见原子替换和累积补丁。
5.4. 禁用
通过向 /sys/kernel/livepatch/<name>/enabled
写入 '0',可以禁用已启用的补丁。
首先,livepatch 进入过渡状态,任务正在收敛到未修补状态。系统开始使用先前启用的补丁的代码,甚至使用原始代码。此阶段由 /sys/kernel/livepatch/<name>/transition
中的值 '1' 表示。有关此过程的更多信息,请参见“一致性模型”部分。
其次,一旦所有任务都已被取消修补,'transition' 值将变为 '0'。与即将禁用的补丁相关的所有函数(klp_func 结构)将从相应的 struct klp_ops 中移除。当 func_stack 列表为空时,也将取消注册 ftrace 处理程序并释放 struct klp_ops。
第三,销毁 sysfs 接口。
5.5. 移除
仅当没有模块提供的函数的用户时,模块移除才是安全的。这就是为什么强制功能永久禁用移除的原因。只有当系统成功过渡到新的补丁状态(已修补/未修补)而没有被强制时,才能保证没有任务在旧代码中睡眠或运行。
6. Sysfs
有关已注册补丁的信息可以在 /sys/kernel/livepatch 下找到。可以通过在此处写入来启用和禁用补丁。
/sys/kernel/livepatch/<patch>/force
属性允许管理员影响修补操作。
有关更多详细信息,请参见 Documentation/ABI/testing/sysfs-kernel-livepatch。
7. 限制
当前的 Livepatch 实现有几个限制:
-
只有可以被跟踪的函数才能被修补。
Livepatch 基于动态 ftrace。特别是,实现 ftrace 或 livepatch ftrace 处理程序的函数无法被修补。否则,代码将陷入无限循环。通过将有问题的函数标记为 "notrace" 来防止潜在的错误。
-
仅当动态 ftrace 位于函数的最开始位置时,Livepatch 才能可靠工作。
在修改堆栈或函数参数之前,函数需要被重定向。例如,在 x86_64 上,livepatch 需要使用 -fentry gcc 编译器选项。
一个例外是 PPC 端口。它使用相对寻址和 TOC。每个函数在调用 ftrace 处理程序之前必须处理 TOC 并保存 LR。此操作在返回时必须被还原。幸运的是,通用 ftrace 代码也有同样的问题,所有这些都在 ftrace 级别上处理。
-
使用 ftrace 框架的 Kretprobes 与被修补的函数存在冲突。
Kretprobes 和 livepatches 都使用修改返回地址的 ftrace 处理程序。第一个使用者胜出。当处理程序已被另一个使用时,探测或补丁将被拒绝。
-
当代码被重定向到新实现时,原始函数中的 Kprobes 将被忽略。
目前正在进行工作以添加关于此情况的警告。