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

本文详细记录了在Trail of Bits实习期间将Solana的RBPF JIT编译器从x86移植到ARM64架构的技术过程,包括寄存器映射、调用约定更新、指令转换实现以及安全缓解机制等核心内容。

将Solana eBPF JIT编译器移植到ARM64

背景

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

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

此外,RBPF的"验证器"比eBPF简单得多。在Linux内核中,eBPF验证器会在JIT编译和执行前验证BPF程序的安全性。而在RBPF中,Solana程序在JIT编译前仅通过一个更简单的验证器,主要检查除以常数零、跳转到无效地址、读写无效寄存器等错误,但不执行CFG分析或寄存器值范围跟踪。

RBPF内部机制

源码到二进制转换阶段

RBPF在最终调用生成代码前,会逐条验证并翻译整个程序到目标架构。这个过程涉及eBPF指令解码器和目标架构的部分指令编码器(2022年夏季前仅支持x86)。虽然RBPF提供解释器执行eBPF Solana程序,但出于性能考虑默认使用JIT编译。

内存与地址转换

BPF程序在独立内存空间中执行,其地址空间与主机地址空间存在映射关系。每个程序的内存区域通过mmap和mprotect设置,代码段、堆栈、堆和输入数据在BPF地址空间中有固定地址区域,但这些映射在主机地址空间中的位置不固定。

处理eBPF加载和存储指令时,地址必须首先转换到主机地址空间。RBPF包含translate_memory_address汇编例程,负责查找访问地址所在的区域并进行地址转换。该转换逻辑在每次执行BPF加载/存储指令时都会调用。

寄存器分配

BPF拥有11个寄存器(10个通用寄存器和帧指针),每个都映射到主机架构的唯一寄存器。在x86_64架构中,16个寄存器里有4个用于特定用途:RDI作为BPF程序计数器限制(由指令计量器使用),R11作为临时寄存器,R10作为JitProgramArgument的常量指针(异常处理时也作临时寄存器),RBP作为初始RSP-8的常量指针。

指令转换

RBPF中的指令转换过程相对直接:

  • eBPF虚拟机寄存器映射到主机架构的唯一寄存器
  • 每个操作码通过大型match语句转换为一条或多条主机架构指令

示例转换显示,RBPF包含一次性生成的共享逻辑子程序(如地址转换)。这些子程序有时会回调Rust代码处理复杂操作(如跟踪、“系统调用”)或更新外部可见状态(如指令计量器)。还存在序言(设置堆栈、处理异常等)和尾声(处理执行到最后指令但未退出的情况)。

控制流

每个BPF指令都是跳转或调用的有效目标地址。eBPF指令通常为8字节(除16字节的LDDW外),这意味着每个8字节边界都是有效跳转目标。

相对跳转可在运行时前解析:回跳在转换时解析,前跳在所有指令生成后"修复"。间接调用则必须在运行时解析,因此RBPF维护指令索引到主机地址的映射,以便在间接调用发生时查找已翻译目标指令的位置。

指令计量器

Solana程序设计为在特定"计算预算"下运行,即程序退出前可执行的eBPF指令数量。为强制执行此限制,JIT编译器生成额外逻辑来跟踪已执行指令数。具体实现包括:

  • 在每个分支源点插装,统计自上次更新后的线性指令序列
  • 条件分支未采取时撤销指令计量器更新
  • 在长线性序列的特定阈值插入额外检查

该计量器曾是多个漏洞的根源(如PR #203和#263)。

调用与"系统调用"

对于同一程序内的常规eBPF调用,RBPF维护独立于主机的堆栈(使用固定大小堆栈帧),跟踪当前调用深度,超出预算时错误退出。Solana程序还需要调用其他合约和与区块链状态交互,RBPF通过"系统调用"机制使eBPF程序能调用Rust实现的Solana特定辅助函数。

异常处理

JIT编译器在遇到不可恢复运行时条件(如除零或无效内存访问)时会提前退出。由于验证器不跟踪寄存器内容,大多数异常在运行时而非验证时捕获。异常处理程序将当前异常信息记录到EbpfError枚举后退出子程序(返回Rust代码)。

安全缓解措施

RBPF包含"机器代码多样化"功能以强化JIT编译器:

  • 常量清理:通过随机生成密钥偏移立即数加载方式,替代直接包含未修改字节的MOVABS指令
  • 指令地址随机化:在生成代码中随机位置添加空操作指令 这些措施旨在增加代码复用攻击的难度。

移植RBPF到ARM64

调用约定与寄存器分配

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

ARM64的额外寄存器意味着在映射每个eBPF寄存器后仍有大量未使用主机寄存器。这些额外寄存器在复杂指令转换过程中用作临时值存储。由于ARM64中只有加载/存储指令可访问内存,通常需要更多临时值导致转换代码更长。

逐指令转换

基于x86转换模型为每个eBPF指令编写ARM64转换。需要注意的是,ARM64固定4字节指令大小意味着无法在单指令中编码每个32位立即数,且ALU指令只能编码有限范围的立即值。因此某些简单eBPF指令需要多条ARM64指令(如emit_load_immediate64可能发射多条指令将立即数移入临时寄存器),而x86仅需单条指令。

意外发现

  • ARM64 ABI要求SP相对访问时堆栈16字节对齐(硬件强制)。QEMU默认不强制执行,但Apple M1会强制执行
  • 负责异常处理、地址转换、解析间接调用等的子程序各有不同的输入输出约定,且文档不完善。正确重写这些ARM64子程序是最耗时的部分
  • 发布ARM64端口时确保其位于特性门jit-aarch64-not-safe-for-production后,这是允许开发者使用JIT编译器的实习项目,需经同行评审后才能用于生产

Winapi实现

Windows虚拟内存API使用VirtualAlloc和VirtualProtect替代mmap和mprotect。这些几乎是直接替代——只需选择与mmap/mprotect最对应的权限和分配选项。

调用约定

Windows x64调用约定指定不同的调用者/被调用者保存寄存器,还有额外的"影子空间"要求:调用者需在调用前在堆栈上保留32字节空间(在推入任何堆栈驻留参数后)。与ARM64支持类似,Windows支持也位于特性标志jit-windows-not-safe-for-production后。

不可利用的小漏洞

ARM64移植发现了一个存在于现有x86 JIT编译器中的未初始化内存漏洞。LLVM内存清理器(MSAN)在ARM64分支下运行时的警告指向以下函数:

1
2
3
4
5
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) };
    ...
}

该函数使用不安全代码从Result中获取字节8-16(对应决定EbpfError变体的整型判别式)。Rust编译器对枚举大小和布局不提供保证(除非添加repr属性)。编译器决定EbpfError枚举判别式仅为u8,导致传入函数的枚举实际上有7字节未初始化堆栈内存被写入JIT代码区域。

虽未初始化的(可能攻击者控制的)字节写入可执行区域本身不是漏洞,但部分削弱了上述代码复用缓解措施的目的。已提交添加#[repr(u64)]的PR,并添加测试检测编译器是否改变特定类型枚举判别式的位置或大小。

结论

鉴于RBPF JIT编译器对Solana区块链的重要性,让最广泛的开发者能在其开发机器上使用它至关重要。现在使用M1和Windows机器的开发者也能在测试中使用JIT编译器。虽然仍需同行评审,但相关工作已在GitHub的两个PR中提供。欢迎尝试!

感谢Anders Helsing在探索RBPF内部机制和学习ARM64/Windows x64 ABI细节时提供的卓越指导。这项工作展示了Trail of Bits如何扎根解决Solana安全挑战,基于我们已公开工具所积累的深厚Solana专业知识。我们不仅致力于使Solana尽可能安全,还希望使工程师使用的工具同样安全。这些努力的最终目标是提高未来所有Solana项目的安全水平。

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