将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不能被重新用途,因为将维护原始主机调用堆栈),如下所述:
|
|
指令翻译
在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分支时指出了一些警告。我调查了这些警告,并发现罪魁祸首是这个函数:
|
|
此函数将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项目的安全水平。