可靠的栈回溯

本文档概述了有关可靠栈回溯的基本信息。

1. 引言

内核 livepatch 一致性模型依赖于准确识别哪些函数可能具有存活状态(live state),因此可能不安全进行修补。识别哪些函数处于存活状态的方法之一是使用栈回溯。

现有的栈回溯代码可能无法总是准确反映所有具有存活状态的函数,并且对调试有帮助的尽力而为(best-effort)方法对于 livepatch 来说是不健全的。Livepatch 依赖于体系结构来提供一个可靠的栈回溯,以确保它从不在回溯中遗漏任何存活的函数。

2. 要求

体系结构必须实现可靠栈回溯函数之一。使用 CONFIG_ARCH_STACKWALK 的体系结构必须实现 ‘arch_stack_walk_reliable’,而其他体系结构必须实现 ‘save_stack_trace_tsk_reliable’。

原则上,可靠的栈回溯函数必须确保以下情况之一

  • 回溯包含任务可能返回到的所有函数,且返回码为零,表示该回溯是可靠的。

  • 返回码非零,表示回溯不可靠。

注意

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

其次,可靠的栈回溯函数必须对栈或其他展开状态损坏或不可靠的情况具有鲁棒性。该函数应尝试检测此类情况并返回非零错误码,且不应陷入死循环或以不安全的方式访问内存。具体情况将在下文中详细描述。

3. 编译时分析

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

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

4. 注意事项

展开过程因体系结构、各自的过程调用标准和内核配置而异。本节描述了体系结构应考虑的常见细节。

4.1 识别成功终止

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

  • 栈或帧指针损坏。

  • 缺少对罕见场景的展开支持,或者展开器中存在 bug。

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

为确保这不会导致函数被从回溯中省略(即使未被其他检查捕获),强烈建议体系结构验证栈回溯是否在预期的位置结束,例如

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

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

  • 在内核入口点预期的特定栈上(例如,如果体系结构有独立的任务栈和 IRQ 栈)。

4.2 识别可展开的代码

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

  • 异常向量和入口汇编。

  • 过程链接表(PLT)条目和饰面函数(veneer functions)。

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

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

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

为了确保此类情况不会导致从回溯中省略函数,强烈建议体系结构明确识别已知可安全展开的代码,并拒绝从所有其他代码中进行展开。

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

体系结构可以通过多种方式识别被认为无法可靠展开的内核代码,例如

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

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

4.3 跨中断和异常进行栈展开

在函数调用边界处,栈和其他展开状态应处于适合可靠展开的一致状态,但在函数执行过程中可能并非如此。例如,在函数序言或收尾期间,帧指针可能是暂时无效的;或者在函数体执行期间,返回地址可能保存在某个任意的通用寄存器中。对于某些体系结构,这可能会由于动态插桩(instrumentation)而在运行时发生改变。

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

无法识别何时可以可靠地展开此类情况(或者在任何情况下都不可靠)的体系结构必须拒绝跨异常边界进行展开。请注意,跨某些异常(例如 IRQ)进行展开可能是可靠的,但跨其他异常(例如 NMI)进行展开则可能是不可靠的。

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

4.4 重写返回地址

某些蹦床会临时修改函数的返回地址,以便通过返回蹦床拦截该函数的返回,例如

  • ftrace 蹦床可以修改返回地址,以便函数图(function graph)跟踪可以拦截返回。

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

发生这种情况时,原始返回地址将不在其通常的位置。对于不受 live patching 约束的蹦床,如果展开器可以可靠地确定原始返回地址且蹦床未改变任何展开状态,则展开器可以报告原始返回地址来替代蹦床,并将其报告为可靠的。否则,展开器必须将这些情况报告为不可靠。

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

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 中。在此期间,展开器可以使用 ftrace_graph_ret_addr() 找到返回地址。

当被跟踪的函数返回到 return_to_handler 时,栈上不再有返回地址,不过原始返回地址仍存储在任务的 cur_ret_stack 中。在 ftrace_return_to_handler() 内部,原始返回地址从 cur_ret_stack 中移除,并在通过 rax 返回之前被编译器临时移动到任意位置。return_to_handler 蹦床将其移入 rdi,然后跳转到该地址。

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

建议体系结构展开尚未返回到 return_to_handler 的情况,但不要求体系结构从 return_to_handler 的中间进行展开,并可以将其报告为不可靠。不要求体系结构从修改返回地址的其他蹦床中进行展开。

4.5 混淆返回地址

某些蹦床不通过重写返回地址来拦截返回,但会临时破坏(clobber)返回地址或其他展开状态。

例如,optprobes 的 x86_64 实现使用 JMP 指令对被探测的函数进行打补丁,该指令指向相关的 optprobe 蹦床。当触发探测时,CPU 将跳转到 optprobe 蹦床,并且被探测函数的地址既不保存在任何寄存器中,也不保存在栈上。

类似地,DYNAMIC_FTRACE_WITH_REGS 的 arm64 实现使用以下内容对被跟踪的函数进行打补丁

MOV X9, X30
BL <trampoline>

MOV 将链接寄存器(X30)保存到 X9 中,以便在 BL 破坏链接寄存器并跳转到蹦床之前保留返回地址。在蹦床的起点,被跟踪函数的地址位于 X9 中,而不是像通常情况那样位于链接寄存器中。

体系结构必须确保展开器要么能可靠地展开此类情况,要么将展开报告为不可靠。