Solana eBPF JIT编译器移植到ARM64架构的技术实践

本文详细介绍了将Solana区块链的RBPF JIT编译器从x86架构移植到ARM64架构的技术实现过程,包括寄存器映射、调用约定、指令翻译等核心改造,以及Windows平台支持和安全加固措施。

将Solana eBPF JIT编译器移植到ARM64架构

背景

在Solana区块链上运行的智能合约从Rust(或C语言)编译为eBPF(扩展版伯克利包过滤器)。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验证器不执行任何控制流图分析或尝试跟踪每个寄存器保存的值范围。

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
// 特殊寄存器:
//     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端口时,我确保它在一个功能门后面,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支持在一个功能标志后面,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 设计