Livepatch

本文档概述了关于内核热补丁的基本信息。

1. 动机

在很多情况下,用户不愿意重启系统。可能是因为他们的系统正在执行复杂的科学计算,或者在高峰使用期间负载过重。除了保持系统正常运行之外,用户还希望拥有一个稳定且安全的系统。热补丁通过允许重定向函数调用来满足用户的这两个需求;从而在无需重启系统的情况下修复关键函数。

2. Kprobes、Ftrace、Livepatching

Linux 内核中有多种机制与代码执行重定向直接相关;即:内核探针、函数跟踪和热补丁。

  • 内核探针是最通用的。可以通过将断点指令替换为任何指令来重定向代码。

  • 函数跟踪器从预定义的位置调用代码,该位置靠近函数入口点。此位置由编译器使用“-pg”gcc 选项生成。

  • 热补丁通常需要在函数参数或堆栈以任何方式修改之前,在函数入口的最开始处重定向代码。

所有这三种方法都需要在运行时修改现有代码。因此,它们需要相互了解,并且不会互相干扰。大多数这些问题通过使用动态 ftrace 框架作为基础来解决。当探测函数入口时,Kprobe 被注册为 ftrace 处理程序,请参阅 CONFIG_KPROBES_ON_FTRACE。此外,还会借助自定义的 ftrace 处理程序调用来自热补丁的替代函数。但是存在一些限制,请参阅下文。

3. 一致性模型

函数的存在是有原因的。它们接受一些输入参数,获取或释放锁,以定义的方式读取、处理甚至写入一些数据,并具有返回值。换句话说,每个函数都有定义的语义。

许多修复程序不会更改修改函数的语义。例如,它们添加空指针或边界检查,通过添加缺少的内存屏障来修复竞争,或者在关键部分周围添加一些锁。大多数这些更改都是自包含的,并且该函数以相同的方式呈现给系统的其余部分。在这种情况下,可以独立地逐个更新函数。

但是,还有更复杂的修复程序。例如,一个补丁可能同时更改多个函数中锁的顺序。或者,一个补丁可能会交换某些临时结构的含义并更新所有相关函数。在这种情况下,受影响的单元(线程、整个内核)需要同时开始使用所有新版本的函数。此外,只有在安全的情况下才必须进行切换,例如,当受影响的锁被释放或此时没有数据存储在修改的结构中时。

关于如何以安全方式应用函数的理论相当复杂。目的是定义所谓的一致性模型。它尝试定义可以使用新实现的条件,以便系统保持一致。

Livepatch 有一个一致性模型,它是 kGraft 和 kpatch 的混合体:它使用 kGraft 的每个任务一致性和系统调用屏障切换,并结合了 kpatch 的堆栈跟踪切换。还有一些回退选项使其非常灵活。

当认为任务可以安全切换时,补丁会按任务应用。启用补丁后,livepatch 进入转换状态,其中任务会收敛到已修补状态。通常,此转换状态可以在几秒钟内完成。禁用补丁时也会发生相同的序列,只是任务从已修补状态收敛到未修补状态。

中断处理程序会继承中断它的任务的已修补状态。对于派生的任务也是如此:子任务会继承父任务的已修补状态。

Livepatch 使用几种互补的方法来确定何时可以安全地修补任务。

  1. 第一种也是最有效的方法是检查休眠任务的堆栈。如果给定任务的堆栈上没有受影响的函数,则修补该任务。在大多数情况下,这将会在第一次尝试时修补大多数或所有任务。否则,它会定期尝试。只有当架构具有可靠的堆栈(HAVE_RELIABLE_STACKTRACE)时,此选项才可用。

  2. 如果需要,第二种方法是内核退出切换。当任务从系统调用、用户空间 IRQ 或信号返回到用户空间时,会切换该任务。它在以下情况下很有用:

    1. 修补在受影响的函数上休眠的 I/O 绑定的用户任务。在这种情况下,您必须发送 SIGSTOP 和 SIGCONT 来强制它退出内核并进行修补。

    2. 修补 CPU 绑定的用户任务。如果任务高度 CPU 绑定,那么它将在下次被 IRQ 中断时进行修补。

  3. 对于空闲的“swapper”任务,由于它们永远不会退出内核,因此它们在空闲循环中有一个 klp_update_patch_state() 调用,这允许它们在 CPU 进入空闲状态之前进行修补。

    (请注意,对于 kthread 还没有这样的方法。)

没有 HAVE_RELIABLE_STACKTRACE 的架构仅依赖于第二种方法。很可能某些任务可能仍在运行旧版本的函数,直到该函数返回。在这种情况下,您必须向任务发送信号。这尤其适用于 kthread。它们可能不会被唤醒,需要强制唤醒。有关更多信息,请参阅下文。

除非我们可以找到另一种修补 kthread 的方法,否则内核热补丁不认为没有 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 为新架构添加一致性模型支持

要为新架构添加一致性模型支持,有以下几种选项:

  1. 添加 CONFIG_HAVE_RELIABLE_STACKTRACE。这意味着需要移植 objtool,对于非 DWARF 的回溯器,还需要确保堆栈跟踪代码能够检测到堆栈上的中断。

  2. 或者,确保每个 kthread 都在安全位置调用了 klp_update_patch_state()。kthread 通常在一个无限循环中重复执行某些操作。切换 kthread 的补丁状态的安全位置应该位于循环中的指定点,此时没有获取任何锁,并且所有数据结构都处于定义良好的状态。

    当使用工作队列或 kthread worker API 时,这个位置很明确。这些 kthread 在一个通用循环中处理独立的动作。

    对于具有自定义循环的 kthread,情况要复杂得多。在这种情况下,必须根据具体情况仔细选择安全位置。

    在这种情况下,没有 HAVE_RELIABLE_STACKTRACE 的架构仍然可以使用一致性模型的非堆栈检查部分

    1. 在用户任务跨越内核/用户空间边界时进行补丁;以及

    2. 在指定的补丁点对 kthread 和空闲任务进行补丁。

    此选项不如选项 1,因为它需要向用户任务发送信号并唤醒 kthread 来修补它们。但是,对于那些尚未具有可靠堆栈跟踪的体系结构来说,它仍然可能是一个很好的备用选项。

4. Livepatch 模块

Livepatch 使用内核模块进行分发,请参阅 samples/livepatch/livepatch-sample.c。

该模块包括我们想要替换的函数的新实现。此外,它还定义了一些描述原始实现和新实现之间关系的结构。然后还有一些代码,使内核在加载 livepatch 模块时开始使用新代码。此外,还有一些代码在删除 livepatch 模块之前进行清理。所有这些将在接下来的章节中详细解释。

4.1. 新函数

新版本的函数通常只是从原始源文件中复制而来。一个好的做法是在名称中添加前缀,以便可以将它们与原始名称区分开来,例如在回溯中。此外,它们可以声明为静态的,因为它们不是直接调用的,也不需要全局可见性。

补丁仅包含真正修改过的函数。但是它们可能想要访问原始源文件中的函数或数据,而这些函数或数据可能仅在本地可访问。这可以通过生成的 livepatch 模块中的特殊重定位部分来解决,有关更多详细信息,请参阅 Livepatch 模块 ELF 格式

4.2. 元数据

该补丁由多个结构描述,这些结构将信息分为三个级别

  • struct klp_func 是为每个修补的函数定义的。它描述了特定函数的原始实现和新实现之间的关系。

    该结构包括原始函数的名称(作为字符串)。函数地址是在运行时通过 kallsyms 找到的。

    然后,它包括新函数的地址。它是通过直接分配函数指针来定义的。请注意,新函数通常在同一源文件中定义。

    作为可选参数,可以使用 kallsyms 数据库中的符号位置来消除同名函数的歧义。这并非数据库中的绝对位置,而是仅针对特定对象(vmlinux 或内核模块)找到的顺序。请注意,kallsyms 允许根据对象名称搜索符号。

  • struct klp_object 定义同一对象中已修补函数的数组(struct klp_func)。其中对象可以是 vmlinux (NULL) 或模块名称。

    该结构有助于将每个对象的函数分组和处理。请注意,已修补的模块可能会在补丁本身之后加载,并且只有在它们可用时才能修补相关函数。

  • struct klp_patch 定义已修补对象的数组(struct klp_object)。

    此结构一致且最终同步地处理所有已修补的函数。仅当找到所有已修补的符号时才应用整个补丁。唯一的例外是尚未加载的对象(内核模块)中的符号。

    有关如何在每个任务的基础上应用补丁的更多详细信息,请参阅“一致性模型”部分。

5. Livepatch 生命周期

Livepatch 可以通过五个基本操作来描述:加载、启用、替换、禁用、删除。

其中替换和禁用操作是互斥的。它们对于给定的补丁具有相同的结果,但对于系统则不然。

5.1. 加载

唯一合理的方法是在加载 livepatch 内核模块时启用补丁。为此,必须在 module_init() 回调中调用 klp_enable_patch()。主要有两个原因

首先,只有模块才能轻松访问相关的 struct klp_patch

其次,当无法启用补丁时,可以使用错误代码拒绝加载模块。

5.2. 启用

通过从 module_init() 回调调用 klp_enable_patch() 来启用 livepatch。在此阶段,系统将开始使用已修补函数的新实现。

首先,根据其名称找到已修补函数的地址。应用 “新函数” 部分中提到的特殊重定位。在 /sys/kernel/livepatch/<name> 下创建相关条目。如果任何上述操作失败,则拒绝该补丁。

其次,livepatch 进入过渡状态,其中任务正在收敛到已修补状态。如果第一次修补原始函数,则会创建一个特定于函数的 struct klp_ops,并注册一个通用 ftrace 处理程序[1]。此阶段由 /sys/kernel/livepatch/<name>/transition 中的值“1”指示。有关此过程的更多信息,请参阅“一致性模型”部分。

最后,一旦所有任务都已修补,'transition' 值将更改为 '0'。

5.3. 替换

所有已启用的补丁都可能被设置了 .replace 标志的累积补丁替换。

一旦启用新补丁并且 'transition' 完成,则将与已替换补丁关联的所有函数(struct klp_func)从相应的 struct klp_ops 中删除。此外,当相关函数未被新补丁修改并且 func_stack 列表变为空时,将取消注册 ftrace 处理程序并释放 struct klp_ops。

有关更多详细信息,请参阅 原子替换 & 累积补丁

5.4. 禁用

可以通过将“0”写入 /sys/kernel/livepatch/<name>/enabled 来禁用已启用的补丁。

首先,livepatch 进入过渡状态,其中任务正在收敛到未修补状态。系统开始使用先前启用的补丁中的代码,甚至是原始代码。此阶段由 /sys/kernel/livepatch/<name>/transition 中的值“1”指示。有关此过程的更多信息,请参阅“一致性模型”部分。

其次,一旦所有任务都已取消修补,'transition' 值将更改为 '0'。与要禁用的补丁关联的所有函数(struct klp_func)都将从相应的 struct klp_ops 中删除。当 func_stack 列表变为空时,将取消注册 ftrace 处理程序并释放 struct klp_ops。

第三,销毁 sysfs 接口。

5.5. 删除

仅当模块提供的函数没有用户时,模块删除才是安全的。这就是强制功能永久禁用删除的原因。只有当系统在没有被强制的情况下成功过渡到新的补丁状态(已修补/未修补)时,才能保证没有任务在旧代码中休眠或运行。

6. Sysfs

有关已注册补丁的信息可以在 /sys/kernel/livepatch 下找到。可以通过在此处写入来启用和禁用补丁。

/sys/kernel/livepatch/<补丁>/force 属性允许管理员影响补丁操作。

有关更多详细信息,请参阅 Documentation/ABI/testing/sysfs-kernel-livepatch。

7. 限制

当前的 Livepatch 实现有几个限制

  • 只能修补可以被跟踪的函数。

    Livepatch 基于动态 ftrace。特别是,实现 ftrace 或 livepatch ftrace 处理程序的函数不能被修补。否则,代码将陷入无限循环。通过将有问题的函数标记为“notrace”来防止潜在的错误。

  • 只有当动态 ftrace 位于函数的开头时,Livepatch 才能可靠地工作。

    需要在堆栈或函数参数以任何方式被修改之前重定向函数。例如,livepatch 需要在 x86_64 上使用 -fentry gcc 编译器选项。

    PPC 端口是一个例外。它使用相对寻址和 TOC。每个函数都必须在调用 ftrace 处理程序之前处理 TOC 并保存 LR。此操作必须在返回时恢复。幸运的是,通用的 ftrace 代码也存在同样的问题,并且所有这些都在 ftrace 级别处理。

  • 使用 ftrace 框架的 Kretprobes 与已修补的函数冲突。

    kretprobes 和 livepatch 都使用修改返回地址的 ftrace 处理程序。先到者胜。当处理程序已被另一个使用时,将拒绝探测或补丁。

  • 当代码重定向到新的实现时,原始函数中的 Kprobes 将被忽略。

    目前正在进行添加有关此情况的警告的工作。