可靠的堆栈跟踪

本文档概述了有关可靠堆栈跟踪的基本信息。

1. 简介

内核 livepatch 一致性模型依赖于准确识别哪些函数可能具有活动状态,因此可能不适合修补。 识别哪些函数是活动的其中一种方法是使用堆栈跟踪。

现有的堆栈跟踪代码可能并不总是能准确地描述所有具有活动状态的函数,并且对于调试有帮助的最佳实践方法对于 livepatching 而言是不健全的。 Livepatching 依赖于架构来提供可靠的堆栈跟踪,以确保它永远不会从跟踪中遗漏任何活动函数。

2. 要求

架构必须实现其中一个可靠的堆栈跟踪函数。 使用 CONFIG_ARCH_STACKWALK 的架构必须实现 'arch_stack_walk_reliable',其他架构必须实现 'save_stack_trace_tsk_reliable'。

原则上,可靠的堆栈跟踪函数必须确保以下任一项

  • 跟踪包括任务可能返回的所有函数,并且返回代码为零以指示跟踪是可靠的。

  • 返回代码为非零,以指示跟踪不可靠。

注意

在某些情况下,从跟踪中省略特定函数是合法的,但必须报告所有其他函数。 这些情况将在下面详细描述。

其次,可靠的堆栈跟踪函数必须能够处理堆栈或其他展开状态损坏或不可靠的情况。 该函数应尝试检测此类情况并返回非零错误代码,并且不应陷入无限循环或以不安全的方式访问内存。 具体案例将在下面详细描述。

3. 编译时分析

为了确保可以在所有情况下正确展开内核代码,架构可能需要验证代码是否以 unwinder 期望的方式进行编译。 例如,unwinder 可能期望函数以有限的方式操作堆栈指针,或者所有函数都使用特定的 prologue 和 epilogue 序列。 具有此类要求的架构应使用 objtool 验证内核编译。

在某些情况下,unwinder 可能需要元数据才能正确展开。 如有必要,应使用 objtool 在构建时生成此元数据。

4. 注意事项

展开过程因架构、其各自的程序调用标准和内核配置而异。 本节介绍架构应考虑的常见详细信息。

4.1 识别成功终止

展开可能会因多种原因而提前终止,包括

  • 堆栈或帧指针损坏。

  • 缺少对不常见场景的展开支持,或者 unwinder 中存在错误。

  • 动态生成的代码(例如 eBPF)或外部代码(例如 EFI 运行时服务)不遵循 unwinder 期望的约定。

为了确保这不会导致函数从跟踪中省略,即使未被其他检查捕获,强烈建议架构验证堆栈跟踪是否在预期位置结束,例如

  • 在作为内核入口点的特定函数内。

  • 在内核入口点预期的堆栈上的特定位置。

  • 在内核入口点预期的特定堆栈上(例如,如果该架构具有单独的任务和 IRQ 堆栈)。

4.2 识别可展开代码

展开通常依赖于代码遵循特定的约定(例如,操作帧指针),但是可能存在不遵循这些约定并且可能需要在 unwinder 中进行特殊处理的代码,例如

  • 异常向量和入口汇编。

  • 程序链接表 (PLT) 条目和 veneer 函数。

  • Trampoline 汇编(例如 ftrace、kprobes)。

  • 动态生成的代码(例如 eBPF、optprobe trampolines)。

  • 外部代码(例如 EFI 运行时服务)。

为了确保此类情况不会导致函数从跟踪中省略,强烈建议架构积极识别已知可以可靠展开的代码,并拒绝从所有其他代码展开。

可以使用 '__kernel_text_address()' 将包括模块和 eBPF 在内的内核代码与外部代码区分开来。 检查此项也有助于检测堆栈损坏。

架构可以通过多种方式识别被认为不可靠展开的内核代码,例如

  • 将此类代码放入特殊的链接器节中,并拒绝从这些节中的任何代码展开。

  • 使用边界信息识别代码的特定部分。

4.3 跨中断和异常展开

在函数调用边界处,堆栈和其他展开状态预计处于适合可靠展开的一致状态,但这可能不是函数执行过程中的情况。 例如,在函数 prologue 或 epilogue 期间,帧指针可能暂时无效,或者在函数体期间,返回地址可能保存在任意通用寄存器中。 对于某些架构,这可能会由于动态工具而发生更改。

如果在堆栈或其他展开状态处于不一致状态时发生中断或其他异常,则可能无法可靠地展开,并且可能无法识别此类展开是否可靠。 请参见下面的示例。

无法识别何时可以可靠地展开此类情况(或永远不可靠)的架构必须拒绝跨越异常边界的展开。 请注意,跨越某些异常(例如 IRQ)进行展开可能是可靠的,但跨越其他异常(例如 NMI)进行展开可能是不可靠的。

可以识别何时可以可靠地展开此类情况(或没有此类情况)的架构应尝试跨越异常边界进行展开,因为这样做可以防止不必要地停止 livepatch 一致性检查,并允许 livepatch 转换更快地完成。

4.4 重写返回地址

一些 trampoline 会暂时修改函数的返回地址,以便在函数使用返回 trampoline 返回时进行拦截,例如

  • ftrace trampoline 可以修改返回地址,以便函数图跟踪可以拦截返回。

  • kprobes(或 optprobes)trampoline 可以修改返回地址,以便 kretprobes 可以拦截返回。

发生这种情况时,原始返回地址将不会位于其通常的位置。 对于不受实时修补影响的 trampoline,如果 unwinder 可以可靠地确定原始返回地址并且 trampoline 不会更改任何展开状态,则 unwinder 可以报告原始返回地址来代替 trampoline,并将其报告为可靠的。 否则,unwinder 必须将这些情况报告为不可靠的。

在识别原始返回地址时需要特别小心,因为此信息在入口 trampoline 或返回 trampoline 的持续时间内不在一致的位置。 例如,考虑 x86_64 'return_to_handler' 返回 trampoline

SYM_CODE_START(return_to_handler)
        UNWIND_HINT_UNDEFINED
        subq  $24, %rsp

        /* Save the return values */
        movq %rax, (%rsp)
        movq %rdx, 8(%rsp)
        movq %rbp, %rdi

        call ftrace_return_to_handler

        movq %rax, %rdi
        movq 8(%rsp), %rdx
        movq (%rsp), %rax
        addq $24, %rsp
        JMP_NOSPEC rdi
SYM_CODE_END(return_to_handler)

当跟踪的函数运行时,堆栈上的返回地址指向 return_to_handler 的开头,原始返回地址存储在任务的 cur_ret_stack 中。 在此期间,unwinder 可以使用 ftrace_graph_ret_addr() 找到返回地址。

当跟踪的函数返回到 return_to_handler 时,堆栈上不再有返回地址,但原始返回地址仍然存储在任务的 cur_ret_stack 中。 在 ftrace_return_to_handler() 中,原始返回地址将从 cur_ret_stack 中删除,并由编译器暂时任意移动,然后以 rax 返回。 return_to_handler trampoline 在跳转到该地址之前将其移动到 rdi 中。

架构可能并不总是能够展开此类序列,例如当 ftrace_return_to_handler() 已从 cur_ret_stack 中删除地址时,并且无法可靠地确定返回地址的位置。

建议架构展开尚未返回到 return_to_handler 的情况,但架构不需要从 return_to_handler 的中间展开,并且可以将其报告为不可靠的。 架构不需要从其他修改返回地址的 trampoline 展开。

4.5 模糊返回地址

一些 trampoline 不会重写返回地址来拦截返回,但会暂时覆盖返回地址或其他展开状态。

例如,x86_64 optprobes 的实现使用 JMP 指令修补被探测的函数,该指令以关联的 optprobe trampoline 为目标。 当命中探针时,CPU 将分支到 optprobe trampoline,并且被探测函数的地址不会保存在任何寄存器或堆栈上。

同样,arm64 DYNAMIC_FTRACE_WITH_REGS 的实现使用以下内容修补跟踪的函数

MOV X9, X30
BL <trampoline>

MOV 将链接寄存器 (X30) 保存到 X9 中,以在 BL 覆盖链接寄存器并分支到 trampoline 之前保留返回地址。 在 trampoline 的开头,跟踪的函数的地址位于 X9 中,而不是通常情况下的链接寄存器中。

架构必须确保 unwinder 要么可靠地展开此类情况,要么将展开报告为不可靠的。