本文深入探讨了Solana区块链中sBPF引擎的安全问题,通过分析程序生命周期,识别了攻击面和可控输入点。重点关注了解释器和JIT编译器的内部机制,并通过两个真实的漏洞案例,展示了资源耗尽和.rodata损坏等安全风险,强调了验证器、解释器和JIT编译器的边界对安全至关重要。
本文面向安全研究和审计:我们首先定位程序生命周期中可控的输入和验证边界,然后分解解释器和 JIT 执行路径,最后使用两个真实漏洞来展示如何触及这些边界。目标是告诉你该看哪里以及为什么。
让我们从 Solana 安全背后的核心概念开始。
SVM 或 sBPF 的核心价值是为程序提供执行环境。分析程序生命周期有助于识别可控的输入点和潜在的攻击面,以进行漏洞研究。下图显示了从开发到执行的完整流程:
OnChainExecution
已验证
程序已在链上
发送交易以调用已部署的程序
JIT 编译或解释
运行时验证
执行指令
开发者编写 Solana 程序
编译为 eBPF 字节码
将程序部署到区块链
开发者构建交互
调用 Solana RPC API
注意:交易不部署代码。部署发生在程序发布时。交易仅调用已部署的程序。
从安全研究的角度来看,生命周期突出了几个问题:
从攻击者的角度来看,关键的可控输入包括:
Solana 的大部分是用 Rust 实现的。这意味着安全研究不仅必须考虑 Solana 的架构,还必须考虑 Rust 特有的属性,例如内存安全和所有权。
我们已经确定了主要的攻击面和可控输入。接下来,我们深入研究两个核心执行组件:解释器 和 JIT 编译器。这些是 SVM 的真正执行引擎,负责翻译和运行 eBPF 字节码。
图中的其他链接,例如编译优化和链上验证,也很重要。但是,本文重点关注这两个组件,因为它们是大多数 sBPF 漏洞的直接来源。
现在,让我们探索解释器的内部工作原理,以及 sBPF 如何一次执行一条字节码指令。理解此路径很重要,因为它定义了运行时边界和错误语义,并为检查 JIT 一致性提供了基线。
执行路径可以简化为:execute 处理参数序列化和 VM 初始化,execute_program 选择解释器或 JIT 执行,调用者映射错误并计算计量。
首先,execute() 处理参数序列化、内存映射和 VM 初始化,然后将控制权转移到 execute_program()。
#[cfg_attr(feature = "svm-internal", qualifiers(pub))]
fn execute<'a, 'b: 'a>(
executable: &'a Executable<InvokeContext<'static>>,
invoke_context: &'a mut InvokeContext<'b>,
) -> Result<(), Box<dyn std::error::Error>> {
let executable = unsafe {
mem::transmute::<&'a Executable<InvokeContext<'static>>, &'a Executable<InvokeContext<'b>>>(
executable,
)
};
// ...
let (parameter_bytes, regions, accounts_metadata) = serialization::serialize_parameters(
&instruction_context,
stricter_abi_and_runtime_constraints,
invoke_context.account_data_direct_mapping,
mask_out_rent_epoch_in_vm_serialization,
)?;
// ...
create_vm!(vm, executable, regions, accounts_metadata, invoke_context);
let (mut vm, stack, heap) = match vm {
Ok(info) => info,
Err(e) => {
ic_logger_msg!(log_collector, "Failed to create SBF VM: {}", e);
return Err(Box::new(InstructionError::ProgramEnvironmentSetupFailure));
}
};
// ...
let (compute_units_consumed, result) = vm.execute_program(executable, !use_jit);
// ...
}
接下来,execute_program 有两条路径。解释器路径创建一个 Interpreter 并重复运行 step()。JIT 路径直接调用已编译的机器代码。本节重点介绍解释器路径,JIT 详细信息将在稍后介绍。
pub fn execute_program(
&mut self,
executable: &Executable<C>,
interpreted: bool,
) -> (u64, ProgramResult) {
let config = executable.get_config();
// ...
if interpreted {
let mut interpreter = Interpreter::new(self, executable, self.registers);
while interpreter.step() {}
} else {
let compiled_program = match executable
.get_compiled_program()
.ok_or_else(|| EbpfError::JitNotCompiled)
{
Ok(compiled_program) => compiled_program,
Err(error) => return (0, ProgramResult::Err(error)),
};
compiled_program.invoke(config, self, self.registers);
}
// ...
}
这是执行阶段的核心逻辑:解释器路径创建一个 Interpreter 并重复调用 step(),而 JIT 路径直接调用已编译的机器代码。
在 execute_program() 返回后,调用者将 ProgramResult 映射到运行时错误(例如 InstructionError)并在错误路径上执行计量(例如,消耗剩余预算)。
接下来,step 是解释器的入口点。VM 维护一个包含 12 个寄存器的寄存器表。最后两个是特殊寄存器。下表使用“all”来指示该寄存器存在于所有 sBPF 版本和功能集中。
| 名字 | 适用于 | 类型 | Solana ABI |
|---|---|---|---|
r0 |
all | GPR | 返回值 |
r1 |
all | GPR | 参数 0 |
r2 |
all | GPR | 参数 1 |
r3 |
all | GPR | 参数 2 |
r4 |
all | GPR | 参数 3 |
r5 |
all | GPR | 参数 4 或堆栈指针 |
r6 |
all | GPR | 被调用者保存 |
r7 |
all | GPR | 被调用者保存 |
r8 |
all | GPR | 被调用者保存 |
r9 |
all | GPR | 被调用者保存 |
r10 |
all | 帧指针 | 系统寄存器 |
pc |
all | 程序计数器 | 隐藏寄存器 |
每个 8 字节被视为一条指令 (inst),格式如下。SVM 定义了指令格式:opc 是操作码,dst/src 是寄存器索引,off 是偏移量,immediate 是立即数。
+-------+--------+---------+---------+--------+-----------+
| class | opcode | dst reg | src reg | offset | immediate |
| 0..3 | 3..8 | 8..12 | 12..16 | 16..32 | 32..64 | Bits
+-------+--------+---------+---------+--------+-----------+
low byte high byte
step 执行单个 eBPF 指令并检查程序是应该继续、终止还是抛出错误。
在执行任何指令之前,step 会执行运行时和安全检查。
指令计量,检查累积指令计数 (self.vm.due_insn_count) 是否已达到预算 (self.vm.previous_instruction_meter)
程序计数器边界检查,验证当前指令地址 (self.reg[11]) 是否超过代码长度
指令翻译,使用一个大的 match insn.opc 来解码和执行当前指令,包括:
LDX/STX) - 使用 translate_memory_access! 宏的加载 (LD_B_REG, LD_DW_REG) 和存储 (ST_B_IMM, ST_DW_REG) 操作,该宏在内存沙箱 (self.vm.memory_mapping) 上调用 load 或 store32 后缀)和 64 位(64 后缀)操作DIV/MOD) - 严格的运行时检查,例如除以零 (throw_error!(DivideByZero; ...)),以及有符号除法的溢出检查(例如 i32::MIN / -1),这些检查会引发 EbpfError::DivideOverflow函数调用 (CALL) 和退出 (EXIT),包括:
CALL_IMM 或 CALL_REG) - 调用 push_frame 以增加调用深度 (self.vm.call_depth) 并将当前寄存器状态推送到调用堆栈上。然后检查调用深度是否超过 config.max_call_depth。它还使用 check_pc! 来确保目标 PC 位于文本段内,从而防止 EbpfError::CallOutsideTextSegment。CALL_IMM) - 调用 dispatch_syscall,保存计量状态,将 eBPF 寄存器映射到 Rust 变量,调用主机内置函数(系统调用),然后更新寄存器(主要是 R0)和计量。EXIT) - 如果 self.vm.call_depth == 0(主程序退出),将 R0 存储到最终结果 (ProgramResult::Ok(self.reg[0])) 中并返回 false 以停止。如果 self.vm.call_depth > 0(从 BPF 到 BPF 的调用返回),则恢复寄存器和返回地址 (frame.target_pc),递减调用深度,并继续。/// 如果程序终止或抛出错误,则返回 false。
#[rustfmt::skip]
pub fn step(&mut self) -> bool {
let config = &self.executable.get_config();
if config.enable_instruction_meter && self.vm.due_insn_count >= self.vm.previous_instruction_meter {
throw_error!(self, EbpfError::ExceededMaxInstructions);
}
self.vm.due_insn_count += 1;
if self.reg[11] as usize * ebpf::INSN_SIZE >= self.program.len() {
throw_error!(self, EbpfError::ExecutionOverrun);
}
let mut next_pc = self.reg[11] + 1;
let mut insn = ebpf::get_insn_unchecked(self.program, self.reg[11] as usize);
let dst = insn.dst as usize;
let src = insn.src as usize;
if config.enable_instruction_tracing {
self.vm.context_object_pointer.trace(self.reg);
}
match insn.opc {
// ...
ebpf::RETURN
| ebpf::EXIT => {
if (insn.opc == ebpf::EXIT && self.executable.get_sbpf_version().static_syscalls())
|| (insn.opc == ebpf::RETURN && !self.executable.get_sbpf_version().static_syscalls()) {
throw_error!(self, EbpfError::UnsupportedInstruction);
}
if self.vm.call_depth == 0 {
if config.enable_instruction_meter && self.vm.due_insn_count > self.vm.previous_instruction_meter {
throw_error!(self, EbpfError::ExceededMaxInstructions);
}
self.vm.program_result = ProgramResult::Ok(self.reg[0]);
return false;
}
// ...
}
_ => throw_error!(self, EbpfError::UnsupportedInstruction),
}
self.reg[11] = next_pc;
true
}
验证器在加载/创建 Executable 时运行,并执行静态检查以确保跳转在范围内、寄存器使用有效(例如,R10 只读)以及调用目标存在于符号表中。它保证了“可执行”字节码的静态边界,但不保证解释器和 JIT 之间的运行时语义等价性。
fn verify<C: ContextObject>(prog: &[u8], _config: &Config, sbpf_version: SBPFVersion, _function_registry: &FunctionRegistry<usize>, syscall_registry: &FunctionRegistry<BuiltinFunction<C>>) -> Result<(), VerifierError> {
check_prog_len(prog)?;
let mut insn_ptr: usize = 0;
if sbpf_version.enable_stricter_verification() && !ebpf::get_insn(prog, insn_ptr).is_function_start_marker() {
return Err(VerifierError::InvalidFunction(0));
}
while (insn_ptr + 1) * ebpf::INSN_SIZE <= prog.len() {
let insn = ebpf::get_insn(prog, insn_ptr);
let mut store = false;
// ...
check_registers(&insn, store, insn_ptr, sbpf_version)?;
// ...
在验证每个指令的参数后,它会检查寄存器是否有效。这些检查会过滤掉几乎所有非法指令行为。
fn check_registers(
insn: &ebpf::Insn,
store: bool,
insn_ptr: usize,
sbpf_version: SBPFVersion,
) -> Result<(), VerifierError> {
if insn.src > 10 {
return Err(VerifierError::InvalidSourceRegister(insn_ptr));
}
match (insn.dst, store) {
(0..=9, _) | (10, true) => Ok(()),
(10, false) if sbpf_version.dynamic_stack_frames() && insn.opc == ebpf::ADD64_IMM => Ok(()),
(10, false) => Err(VerifierError::CannotWriteR10(insn_ptr)),
(_, _) => Err(VerifierError::InvalidDestinationRegister(insn_ptr)),
}
}
与解释器不同,JIT 将 eBPF 编译为本机机器代码并直接执行它。这缩短了执行路径,但将安全边界转移到代码生成、异常处理和指令计量细节中 - 这正是漏洞容易出现的地方。是否启用 JIT 取决于运行时配置和功能标志,因此安全分析应确认实际的执行路径。
在 JIT 编译之后,生成的机器代码和元数据存储在 JitProgram 中:text_section 保存本机代码,pc_section 将 BPF 指令索引映射到本机代码地址。
此布局主要用于快速地址映射和跳转解析。
pub struct JitProgram {
/// OS page size in bytes and the alignment of the sections
page_size: usize,
/// Byte offset in the text_section for each BPF instruction
pc_section: &'static mut [u32],
/// The x86 machinecode
text_section: &'static mut [u8],
}
JIT 编译器将 11 个 eBPF 寄存器 (R0-R10) 映射到特定的 x86-64 寄存器,以实现高效访问:
| eBPF 寄存器 | x86-64 寄存器 | 功能/备注 |
|---|---|---|
| R0 | RAX |
返回值 / 暂存 |
| R1-R5 | RSI, RDX, RCX, R8, R9 |
函数调用参数 |
| R6-R9 | RBX, R12, R13, R14 |
被调用者保存 |
| R10 | R15 |
帧指针 (FP) |
| 特殊 | RDI |
指向 VM 运行时环境的指针 (REGISTER_PTR_TO_VM) |
| 特殊 | R10 (Host) |
指令计数器 (REGISTER_INSTRUCTION_METER) |
| 特殊 | R11 |
通用暂存寄存器 |
eBPF 指令无法直接访问主机寄存器。逃逸风险主要来自发射器或地址转换缺陷。
compile 是 JIT 的核心。它将 eBPF 字节码转换为可执行的 x86-64 机器代码。在开始时,它调用 emit_subroutines() 来生成固定的可重用代码块(锚点),例如统一的异常入口、结尾和系统调用逻辑。
fn emit_subroutines(&mut self) {
// Routine for instruction tracing
if self.config.enable_register_tracing {
self.set_anchor(ANCHOR_TRACE);
// ...
}
// Epilogue
// [... ...]
}
接下来是指令级翻译(主循环):
insn)。emit_validate_instruction_count)。这是计量检查点,间隔由 config.instruction_meter_checkpoint_distance 控制。insn.opc) 生成 x86-64 指令序列,包括:LDX/STX) - 调用 emit_address_translation 以集成内存沙箱,将内存访问转换为具有边界和权限检查的运行时调用。ADD、SUB、MOV、SHR、MUL、DIV 等,并处理常量清理。JMP) - 生成 CMP 或 TEST 和条件跳转。在分支之前,调用 emit_validate_and_profile_instruction_count 以更新计量。CALL) - 为内部 BPF 调用与外部系统调用发出不同的逻辑。EXIT) - 处理返回或程序终止。EXIT 的情况下超出结尾,它会抛出 ExecutionOverrun。resolve_jumps) - 在主循环之后,计算并修补正向跳转的相对偏移量。seal) - 设置机器代码长度,用调试陷阱 (0xcc) 填充剩余空间,并设置内存保护(将 text_section 标记为 Read-Execute)。pub fn compile(mut self) -> Result<JitProgram, EbpfError> {
// [... ...]
self.emit_subroutines();
while self.pc * ebpf::INSN_SIZE < self.program.len() {
// Regular instruction meter checkpoints to prevent long linear runs from exceeding their budget
if self.last_instruction_meter_validation_pc + self.config.instruction_meter_checkpoint_distance <= self.pc {
self.emit_validate_instruction_count(Some(self.pc));
}
// ...
}
// ...
}
总而言之:从安全研究的角度来看,JIT 风险点集中在指令计量、异常路径插入、结果槽写入、解释器/JIT 不一致以及手写编码细节上。这种自行构建的发射器以较低的编译开销和精确的检测为代价,承担了操作码正确性的负担。在下一节中,我们将介绍 sBPF 中的两个漏洞。漏洞 2 是该风险的直接示例。
下面,我们基于 Secret Club 在其文章 “周末模糊测试赚取 20 万美元:第二部分”↗ 中最初发布的研究结果,分析 Solana 生态系统中的两个真正的 sBPF 漏洞。
第一个漏洞是一个经典的 堆内存泄漏。触发条件:启用指令计量的 JIT 模式,并且程序在执行 call -1(未解析的符号)时接近其限制。它首先生成一个包含 String 的错误,然后稍后的异常处理通过原始写入覆盖该槽,并且 String 永远不会被释放。重复触发会耗尽内存。
首先,运行时触发器:当 CALL_IMM 未解析时,JIT 将 report_unresolved_symbol 的 函数地址嵌入到机器代码中,并在运行时调用它。
ebpf::CALL_IMM => {
// ...
// Workaround for unresolved symbols in ELF: Report error at runtime instead of compiletime
emit_rust_call(self, Value::Constant64(Executable::<E, I>::report_unresolved_symbol as *const u8 as i64, false), &[\
Argument { index: 2, value: Value::Constant64(self.pc as i64, false) },\
Argument { index: 1, value: Value::Constant64(&*executable.as_ref() as *const _ as i64, false) },\
Argument { index: 0, value: Value::RegisterIndirect(RBP, slot_on_environment_stack(self, EnvironmentStackSlot::OptRetValPtr), false) },\
], None, true)?;
X86Instruction::load_immediate(OperandSize::S64, R11, self.pc as i64).emit(self)?;
emit_validate_instruction_count(self, false, None)?;
emit_jmp(self, TARGET_PC_RUST_EXCEPTION)?;
}
这里的 as i64 只是将函数地址嵌入到 JIT 代码中。它不在编译时执行。实际执行发生在运行时:report_unresolved_symbol 构建 Err(ElfError::UnresolvedSymbol(name.to_string(), ...)),分配一个堆 String。因此,未解析的符号不会在加载时被拒绝,而是推迟到运行时报告。
pub fn report_unresolved_symbol(&self, insn_offset: usize) -> Result<u64, EbpfError<E>> {
// ...
Err(ElfError::UnresolvedSymbol(
name.to_string(), // 堆分配
// ...
)
.into())
}
注意:report_unresolved_symbol 仅将 Err 写入结果槽。它不会更改控制流。执行继续进行指令计量检查,因此该错误可能会被稍后的异常覆盖。
发生这种情况是因为 JIT 发出一个线性指令序列:emit_rust_call 是一个普通的 call,并且在 Rust 函数返回后,CPU 执行下一条指令。结果槽中的 Err 不会更改 RIP。控制流仅通过显式跳转进行更改 - emit_validate_instruction_count 发出的 cmp/jcc(跳转到“超出预算”异常)和随后的 emit_jmp(跳转到 Rust 异常处理程序)。因此,返回值不会停止执行。JIT 错误语义是“写入槽 + 依赖稍后的跳转”,因此它在时间上不是原子的。
核心问题是 结果槽。JIT 在 OptRetValPtr 中存储一个指向 ProgramResult<E> 的指针。此槽属于主机 VM 运行时结构(而不是 eBPF 线性内存)。然后,JIT 通过 原始内存写入 设置错误。这些写入不会触发 Rust Drop,因为没有 Rust 级别的赋值。从 JIT 的角度来看,结果槽只是一个原始地址(例如 u64),并且 store_immediate 发出一个 CPU 内存覆盖(MOV/STORE),绕过 Rust 的 drop glue。JIT 自己的 Drop 仅释放代码段(例如 JitProgramSections),并且机器代码对结果槽的写入完全在 Rust 所有权系统之外。主机仅读取错误标志/代码,并且不删除被覆盖的旧 Err。
fn emit_set_exception_kind<E: UserDefinedError>(jit: &mut JitCompiler, err: EbpfError<E>) -> Result<(), EbpfError<E>> {
let err = Result::<u64, EbpfError<E>>::Err(err);
let err_kind = unsafe { *(&err as *const _ as *const u64).offset(1) };
X86Instruction::load(OperandSize::S64, RBP, R10, X86IndirectAccess::Offset(slot_on_environment_stack(jit, EnvironmentStackSlot::OptRetValPtr))).emit(jit)?;
X86Instruction::store_immediate(OperandSize::S64, R10, X86IndirectAccess::Offset(8), err_kind as i64).emit(jit)
}
fn emit_profile_instruction_count_of_exception<E: UserDefinedError>(jit: &mut JitCompiler, store_pc_in_exception: bool) -> Result<(), EbpfError<E>> {
// ...
X86Instruction::store_immediate(OperandSize::S64, R10, X86IndirectAccess::Offset(0), 1).emit(jit)?; // is_err = true;
// ...
}
运行时序列是:
report_unresolved_symbol 将 Err(UnresolvedSymbol(String)) 写入结果槽。emit_validate_instruction_count 运行。如果超出预算,它会跳转到 ExceededMaxInstructions。store_immediate 覆盖结果槽中的字段,而不删除旧的 Err。String 丢失其引用并泄漏。重复触发会耗尽内存。CALL_IMM 未解决
emit_rust_call -> report_unresolved_symbol
结果槽 = Err UnresolvedSymbol String
返回到 JIT 线性执行
emit_validate_instruction_count cmp/jcc
跳转到 ExceededMaxInstructions
store_immediate 覆盖结果槽
String 泄漏 (没有 Drop)
关键的修复是 将指令计量移到调用之前:如果预算已经耗尽,直接报告错误,不调用 report_unresolved_symbol 来分配 String,从而避免泄漏路径。
+ emit_validate_instruction_count(self, true, Some(self.pc))?;
// ...
emit_rust_call(self, Value::Constant64(Executable::<E, I>::report_unresolved_symbol as *const u8 as i64, false), &[\
.rodata 损坏第二个漏洞是一个简单的 x86 指令编码错误:手写编码器选择了错误的操作码/立即数值大小,导致操作数大小不匹配。
根本原因是 cmp 操作数大小错误。
JIT 尝试使用 X86Instruction::cmp_immediate 发出一个 cmp,但它错误地使用了操作码 0x81(用于 16/32/64 位操作数)而不是 0x80(用于 8 位操作数)。
X86Instruction::cmp_immediate(OperandSize::S8, RAX, 0, Some(X86IndirectAccess::Offset(25))).emit(self)?;
pub fn cmp_immediate(
size: OperandSize,
destination: u8,
immediate: i64,
indirect: Option<X86IndirectAccess>,
) -> Self {
Self {
size,
opcode: 0x81,
first_operand: RDI,
second_operand: destination,
immediate_size: OperandSize::S32,
immediate,
indirect,
..Self::default()
}
}
因此,JIT 发出 cmp DWORD PTR [rax+0x19], 0x0(32 位比较)。
由于 Rust 结构体填充,MemoryRegion 中 is_writable 字段之后的字节通常为非零。此 cmp 用于检查 is_writable:它应该仅比较 1 个字节,但改为比较 4 个字节,读取填充字节并将只读区域错误地分类为可写。应该阻止的写入被允许,并且 .rodata 损坏。这里的“持久性”是指在同一 JIT 工件/进程生命周期内的后续执行可见,而不是永久性的链上状态。不能保证填充字节为非零,但宽度错误会跨字段读取并扭曲权限检查。
核心修复是基于 size 选择操作码:
pub fn cmp_immediate(
size: OperandSize,
destination: u8,
immediate: i64,
indirect: Option<X86IndirectAccess>,
+ debug_assert_ne!(size, OperandSize::S0);
Self {
size,
- opcode: 0x81,
+ opcode: if size == OperandSize::S8 { 0x80 } else { 0x81 },
first_operand: RDI,
second_operand: destination,
- immediate_size: OperandSize::S32,
+ immediate_size: if size != OperandSize::S64 {
+ size
+ } else {
+ OperandSize::S32
+ },
immediate,
indirect,
..Self::default()
以下是真正重要的:
.rodata 权限损坏。它们是不同的错误,但都来自底层正确性的下降。如果你想更深入,请在相同的字节码上区分解释器和 JIT 行为,跟踪结果槽写入和计量跳转,并审核手写编码器和内存映射。这些是最值得优先考虑的领域。
Zellic 专门从事新兴技术的安全保护。我们的安全研究人员已经发现最有价值的目标中的漏洞,从财富 500 强企业到 DeFi 巨头。
开发者、创始人和投资者信任我们的安全评估,以便快速、自信且无关键漏洞地交付产品。凭借我们在现实世界进攻性安全研究方面的背景,我们发现了其他人遗漏的东西。
联系我们↗ 进行比其他评估更好的审计。真正的审计,而不是橡皮图章审计。
- 原文链接: zellic.io/blog/solana-sb...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!