Livepatch

本文档概述了有关内核 Livepatching 的基本信息。

1. 动机

在许多情况下,用户不愿意重新启动系统。 这可能是因为他们的系统正在执行复杂的科学计算,或者在高峰使用期间承受着巨大的负载。除了保持系统正常运行之外,用户还希望拥有一个稳定且安全的系统。Livepatching 通过允许重定向函数调用来为用户提供这两者;因此,无需重新启动系统即可修复关键函数。

2. Kprobes, Ftrace, Livepatching

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

  • 内核探针是最通用的。可以通过放置一个断点指令而不是任何指令来重定向代码。

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

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

所有三种方法都需要在运行时修改现有代码。因此,它们需要相互了解,并且不能互相踩踏。 大部分问题都通过使用动态ftrace框架作为基础来解决。当探测函数入口时,Kprobe被注册为ftrace处理程序,请参见CONFIG_KPROBES_ON_FTRACE。Live Patch的替代函数也通过自定义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 的方法,否则内核 livepatching 不认为没有 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 可能会有所帮助)。 移除(rmmod)补丁程序模块在 force 功能使用时被永久禁用。 不能保证没有任务在此类模块中休眠。 如果在循环中禁用和启用补丁程序模块,则意味着无限制的引用计数。

此外,使用 force 还可能影响未来 live patch 的应用,并对系统造成更大的损害。 管理员应首先考虑简单地取消过渡(参见上文)。 如果使用了 force,则应计划重新启动,并且不再应用 live patch。

3.1 向新架构添加一致性模型支持

要向新架构添加一致性模型支持,有几个选项

  1. 添加 CONFIG_HAVE_RELIABLE_STACKTRACE。 这意味着移植 objtool,并且对于非 DWARF 解卷器,还要确保堆栈跟踪代码有一种方法来检测堆栈上的中断。

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

    当使用 workqueue 或 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生命周期

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

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

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. 移除

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

6. Sysfs

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

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

有关更多详细信息,请参见 ABI 文件测试/sysfs-kernel-livepatch

7. 局限性

当前的 Livepatch 实现有几个限制

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

    Livepatch 基于动态 ftrace。 特别是,实现 ftrace 或 livepatch ftrace 处理程序的函数无法修补。 否则,代码最终会进入无限循环。 通过用“notrace”标记有问题的功能,可以防止潜在的错误。

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

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

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

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

    kretprobe 和 livepatch 都使用修改返回地址的 ftrace 处理程序。 第一个用户获胜。 当处理程序已被另一个用户使用时,将拒绝探针或补丁程序。

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

    目前正在努力添加有关此情况的警告。