Solana eBPF JIT编译器向ARM64架构的移植技术详解

本文详细介绍了将Solana的RBPF JIT编译器移植到ARM64架构的技术实现,包括寄存器映射、调用约定、指令翻译、内存地址转换等核心机制,以及Windows平台适配和安全加固措施。

移植Solana eBPF JIT编译器到ARM64

背景

在Solana区块链上运行的智能合约从Rust(或C语言)编译为eBPF(Berkeley Packet Filter的扩展版本)。eBPF虚拟机架构相对简单,包含最小化的32位和64位整数操作(包括乘除法)、内存和控制流指令。BPF程序拥有独立的地址空间,在RBPF中由代码、栈、堆和输入数据段组成,位于固定地址。

RBPF支持的BPF版本设计用于与LLVM BPF后端编译的程序协同工作。官方Linux eBPF文档显示RBPF与eBPF存在一些差异,最显著的是RBPF必须支持间接调用(callx)指令。

此外,RBPF的"验证器"比eBPF简单得多。在Linux内核中,eBPF验证器在JIT编译和执行前验证BPF程序的某些安全属性。在RBPF中,Solana程序在JIT编译前通过更简单的验证器,检查试图除以常数零、跳转到明显无效地址、读写无效寄存器等错误。值得注意的是,RBPF验证器不执行任何控制流图(CFG)分析或尝试跟踪每个寄存器保存的值范围。RBPF验证器检查的错误完整列表可在此处找到。

RBPF内部机制

源代码到二进制翻译阶段

RBPF在最终调用生成代码前,逐条指令验证并将整个程序翻译为目标架构。这涉及eBPF指令解码器和目标架构的部分指令编码器(在2022年夏季之前仅支持x86)。RBPF还提供能够执行eBPF Solana程序的解释器,但出于性能原因,JIT翻译是默认选项。

内存和地址转换

BPF程序在自身内存空间中执行,该地址空间与主机地址空间之间存在映射。为每个要执行的程序设置内存区域(使用mmap和mprotect);BPF代码、栈、堆和输入数据各有其区域,位于BPF地址空间中的固定地址。这些映射在主机地址空间中的位置不固定。

为处理eBPF加载和存储指令,必须首先将地址转换到主机地址空间。RBPF包含translate_memory_address汇编例程,负责查找包含被访问地址的区域并将BPF地址转换为主机地址。每次执行BPF加载或存储指令时都会调用此转换逻辑,如本文后面示例指令翻译所示。

寄存器分配

BPF有11个寄存器(10个通用寄存器和帧指针),每个映射到主机架构中的不同寄存器。在x86_64(有16个寄存器)上,剩余四个寄存器用于特定目的(RSP无法重新分配,因为将维护原始主机调用栈),如下所述:

1
2
3
4
5
6
7
// 特殊寄存器:
//     ARGUMENT_REGISTERS[0]  RDI  BPF程序计数器限制(由指令计量器使用)
// CALLER_SAVED_REGISTERS[8]  R11  临时寄存器
// CALLER_SAVED_REGISTERS[7]  R10  指向JitProgramArgument的常量指针(也是异常处理的临时寄存器)
// CALLEE_SAVED_REGISTERS[0]  RBP  指向初始RSP - 8的常量指针

来源:solana-labs中jit.rs的第224行

指令翻译

在RBPF中翻译指令是一个相当直接的过程:

  • eBPF虚拟机中的寄存器映射到主机架构中的唯一寄存器。
  • 每个操作码翻译为主机架构中的一个或多个指令(通过这个大匹配语句)。

下面显示两个示例翻译:

示例指令翻译

RBPF包含子例程,这些子例程发出一次以处理共享逻辑(如地址转换,通过翻译上面的加载指令执行)。有时这些子例程包括回调Rust代码以处理更复杂的操作(例如,跟踪、“系统调用”)或更新某些外部可见状态(例如,指令计量器)。还有序言(例如,设置栈、处理异常等)和尾声(例如,处理执行到达程序中最后一条指令且未退出的情况,通常通过调用退出函数完成)。

控制流

每个BPF指令都是跳转或调用的有效目标地址。eBPF指令为8字节,有一个例外:加载双字(LDDW)为16字节。这意味着,除了这个例外,BPF代码地址空间中的每个8字节边界都是有效的跳转目标。

相对跳转始终可以在运行时前解析;它们可以在翻译时解析(对于向后跳转)或在所有指令发出后"修复"(对于向前跳转)。然而,间接调用必须在运行时解析。因此,RBPF保持从指令索引到主机地址的映射,以便在发生间接调用时可以查找已翻译目标指令的位置。

指令计量器

Solana程序设计为在特定"计算预算"下运行,这本质上是程序退出前可以执行的eBPF指令数量。为了强制执行此限制(对可能不终止的程序),JIT编译器发出额外逻辑来跟踪已执行的指令数。指令计量器在此注释中有最佳描述,但可以总结如下:

  • 每个分支的源被插桩,以计算自上次更新以来在线性序列中执行的指令,并记录分支目标(要执行的下一个线性指令序列的开始)。
  • 如果条件分支实际上未采取,则撤销对指令计量器的更新。
  • 在长线性指令序列的某些阈值处插入额外的指令计量器检查。

指令计量器过去是多个错误的来源(例如,查看拉取请求203和拉取请求263)。

调用和"系统调用"

对于同一程序内的常规eBPF调用,RBPF保持与主机分离的栈(当前使用固定大小栈帧),跟踪当前调用深度,并在调用深度超过预算时退出并报错。Solana程序尤其还需要调用其他合约并与某些区块链状态交互。RBPF有一种称为"系统调用"的机制,eBPF程序可以通过该机制调用在Rust中实现的Solana特定辅助函数。

异常

如果遇到许多不可恢复的运行时条件(如除以零或无效内存访问),JIT编译器可能提前退出。由于验证器不尝试跟踪寄存器内容,大多数异常在运行时而不是验证时捕获。异常处理程序设计为将当前异常信息记录到EbpfError枚举中,然后继续退出子例程(返回到Rust代码)。

安全加固

RBPF包含一些属于"机器代码多样化"类别的功能,用于在一定程度上强化JIT编译器以防利用。其中两个功能(去年引入)是常量清理和指令地址随机化。

常量清理改变了立即数在生成代码中加载到寄存器的方式。不是发出典型的x86 MOVABS指令(包含未修改的立即数字节),而是将立即数偏移一个随机生成的密钥。在运行时,此密钥在后续指令中从内存获取并添加,以便目标寄存器包含最初所需的立即数。

指令地址随机化在整个生成代码中的随机位置添加无操作指令。这两种加固措施旨在使代码重用攻击更加困难。

将RBPF移植到ARM64

调用约定和寄存器分配

JIT编译器需要能够调用Rust代码,这将遵循主机的调用约定。幸运的是,大多数平台遵循ARM软件标准的调用约定。苹果和微软都发布了自己的ABI文档,但它们大多遵循标准ARM64文档。我在运行macOS的M1和通过QEMU模拟的ARM64虚拟机上测试了我的实现。

注意,ARM64的额外寄存器意味着即使将每个eBPF寄存器映射到主机寄存器后,仍有大量未使用的主机寄存器。我在翻译更复杂指令时使用其中一些额外寄存器来保存额外的"临时"值。额外的临时值通常有帮助,因为只有加载和存储指令可以在ARM64中访问内存,这通常导致更长的翻译和更多临时值。

逐指令翻译

我为每个eBPF指令编写了到ARM64的翻译,紧密模仿它们的x86翻译。以下是eBPF ADD指令两个变体的现有x86代码和新翻译的ARM64代码示例。

现有x86代码

翻译的ARM64代码

注意,ARM64的固定指令大小为4字节意味着无法在单条指令中编码每个32位立即数,并且ARM64 ALU指令只能编码非常有限的立即数值范围。因此,一些简单的eBPF指令需要多条ARM64指令(例如,emit_load_immediate64可能发出多条指令将立即数移动到临时寄存器),即使它们只需要单条x86指令。

一些意外

ARM64 ABI要求在任何SP相对访问时栈对齐16字节;这种对齐应由硬件强制执行。QEMU默认不强制执行此对齐,但Apple M1会。

子例程(负责异常处理、地址转换、解析间接调用等)各自对其输入和输出有略微不同的约定,这些约定没有很好文档化。在ARM64中正确重写这些子例程是此过程中最耗时的部分。我最终记录了许多关于这些子例程的假设。这些子例程还负责一些相当复杂的逻辑,包括地址转换和指令计量器记账。

当我发布ARM64端口时,我确保它 behind a feature-gate,jit-aarch64-not-safe-for-production。这是一个实习项目,旨在允许开发人员使用JIT编译器,在经过彻底同行评审之前尚未准备好用于生产。

我的RBPF ARM64端口目前可通过Trail of Bits分支或此拉取请求获得。

Winapi

Windows虚拟内存API使用VirtualAlloc和VirtualProtect代替mmap和mprotect。对于我们的目的,这些几乎是直接替换——我只需选择与mmap和mprotect中使用的最密切对应的权限和分配选项。

调用约定

Windows x64调用约定指定不同的寄存器为调用者保存和被调用者保存;它还有额外的"影子空间"要求,调用者负责在调用前在栈上留出32字节空间(在任何栈驻留参数被推送后)。

与ARM64一样,Windows支持 behind a feature flag,jit-windows-not-safe-for-production。

一个小且不可利用的错误

我的RBPF ARM64端口确实发现了一个小且不可利用的未初始化内存错误,该错误甚至存在于现有的x86 JIT编译器中。VTCAKAVSMoACE在LLVM内存清理器(MSAN)下运行我的ARM64分支时指出一些警告。我调查了这些警告,发现罪魁祸首是此函数:

1
2
3
4
5
6
fn emit_set_exception_kind<E: UserDefinedError>(jit: &mut JitCompiler, err: EbpfError<E>) {
    let err = Result::<u64, EbpfError<E>>::Err(err);
    let err_kind = unsafe { *(&err as *const _ as *const u64).offset(1) };
    ...
    emit_ins(jit, X86Instruction::store_immediate(OperandSize::S64, R10, X86IndirectAccess::Offset(8), err_kind as i64));
}

此函数将EbpfError值作为第二个参数,将其移动到Result中,然后使用不安全代码从Result中抓取字节8到16。这些字节对应于确定EbpfError是哪种变体(错误类型)的整数判别式。Rust编译器不保证枚举的大小或布局,除非向枚举添加repr属性(如#[repr(u64)])。

Rust编译器已决定EbpfError枚举判别式仅为u8,因此传递给emit_set_exception_kind的枚举实际上有7字节未初始化栈内存被写入JIT代码区域。写入可执行区域的未初始化(可能攻击者控制)字节本身不是错误,但它们部分破坏了上述代码重用加固的目的。

我打开了一个添加#[repr(u64)]的拉取请求。由于JIT编译器对枚举布局做了额外假设(即对于Rust标准库中的Result),我还添加了测试,应检测编译器是否曾更改某些类型上枚举判别式的位置或大小。

结论

鉴于RBPF JIT编译器对Solana区块链的重要性,我们认为让最广泛的开发人员在他们用于开发的任何机器上使用它非常重要。现在,使用M1和Windows机器的开发人员也可以在测试期间使用JIT编译器。虽然工作仍需同行评审,但可以在GitHub上的两个拉取请求中找到。请随意尝试!

感谢Anders Helsing在我探索RBPF内部机制并学习ARM64和Windows x64 ABI的细节时提供的出色指导。

这项工作展示了Trail of Bits如何植根于解决Solana的安全挑战,建立在我们已用于构建已公开发布工具的深厚Solana专业知识之上。我们不仅旨在使Solana尽可能安全,还希望使工程师与Solana使用的工具同样安全。我们这些努力的最终目标是提高未来将构建的所有Solana项目的安全水平。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计