SVM(Solana Virtual Machine)是Solana区块链生态系统的核心组件之一,负责执行智能合约和去中心化应用程序。其核心原理是利用即时编译技术实现高性能的智能合约执行。由于Solana的高吞吐量和低延迟特性,SVM在Solana中扮演着至关重要的角色,为开发者提供了一个高效的去中心化应用开发环境,并且对Solana的安全性起着重要作用。
最近Solana开发者为了提高SVM的安全性能,为SVM引入JIT二级防御,该功能针对立即数(immediate)进行了随机数优化,旨在防止攻击者操控立即数影响JIT机器指令生成内容(JIT Spraying)。然而,这一引入虽然增加了新的功能,但是修改了CU(计算单元)的计算。CU的计算没有正确修改,这一变更导致了严重的错误,影响了SVM中对指令资源消耗的重要计量。
https://github.com/solana-labs/rbpf/pull/557
https://github.com/solana-labs/rbpf/blob/main/src/jit.rs#L792
本文将详细分析该错误引发的漏洞及其对系统的影响。同时,SVM的稳定性和安全性对Solana区块链的整体安全性具有至关重要的作用,这也使得Solana SVM的优化和改进至关重要。
1. SVM运行模式介绍
SVM是Solana区块链平台的关键组成部分,用于提供高效、安全的执行环境,用于运行智能合约和分布式应用程序。SVM的设计采用了rbpf字节码解释器(interpreter)和即时编译器(JIT),通过全局状态和智能合约接口实现与区块链网络的交互。
Solana的Rust智能合约首先被编译成ELF格式的文件,合约代码被翻译成rbpf指令字节码,并通过加载到SVM中来运行。SVM中有两种运行模式,即解释模式(interpreter)和即时编译模式(JIT)。在解释模式中,根据rbpf指令序列执行算术运算;而在JIT模式中,SVM将对应的rbpf指令翻译成x86机器码,通过直接运行底层x86机器码来提高运行速度和效率。
相对于解释模式,Solana通常采用即时编译模式来运行智能合约,因为本地x86机器码的运行效率大大提高了Solana运行智能合约的效率。
Solana在SVM中的合约执行流程如下:
4.或者在JIT模式下执行,即时编译rbpf字节码为x86机器码后执行execute_program()。
2. SVM中rbpf指令的CU计算和安全检查
计算单元(CU)是Solana区块链智能合约计算资源消耗的最小计量单位,即每个Solana-rbpf指令的计算单元,用于估算智能合约执行指令的成本。
在Solana1.9.2中引入了一项名为“事务范围计算上限”的功能,类似于ETH的GasLimit。默认情况下,每个事务具有200,000CU的预算,并且封装的指令从该事务预算中提取。每笔交易请求(和使用)的最大CU为140万个CU。
在SVM中,单个CU对应一条rbpf指令的运行。因此,在最大CU限制下,Solana智能合约能够运行140万条rbpf指令。SVM通过TestContextObject上下文对象来传递最大的CU限制,其中remaining记录了最大的CU数量。
pub struct TestContextObject {
/// Contains the register state at every instruction in order of execution
pub trace_log: Vec<TraceLogEntry>,
/// Maximal amount of instructions which still can be executed
pub remaining: u64,
}
并且通过get_remaining()可以随时获取执行后剩余的CU计数。SVM通过创建EbpfVm来初始化运行环境。EbpfVm结构体中包含了SVM运行情况记录成员,在解释器模式中,CU记录由due_insn_count计数;而在JIT模式中,则是通过REGISTER_INSTRUCTION_METER(RBX寄存器)来计数CU。EbpfVm还有一个成员previous_instruction_meter,SVM在执行execute_program()的时候,previous_instruction_meter会初始为最大限制的CU,每执行一次通过consume()更新remaining的值,也就是执行完后剩余的CU。
在SVM中,智能合约包含的rbpf指令是如何计算CU并确保运行安全的呢?接下来将会详细讲解解释模式和JIT模式中CU的计算方法和安全检查:
在解释模式中,初始化EbpfVm环境后,execute_program()将会根据rbpf指令数循环调用step()执行每一条rbpf指令,CU则是由due_insn_count计数加一来统计的:
self.vm.due_insn_count += 1;
CU计数的检查是通过比较self.vm.due_insn_count和合约执行前的剩余指令计数self.vm.previous_instruction_meter完成的。初始时,self.vm.previous_instruction_meter的值为最大的CU数量。如果当前的CU数量超过了剩余的CU数量,则会抛出错误EbpfError::ExceededMaxInstructions。
下面是相应的代码片段:
if config.enable_instruction_meter && self.vm.due_insn_count >= self.vm.previous_instruction_meter {
self.reg[11] += 1;
throw_error!(self, EbpfError::ExceededMaxInstructions);
}
在SVM运行过程中,同样会对指令执行的范围进行安全性判断。这些判断是基于当前指令的位置(next_pc)和跳转目标位置(target_pc)来进行的。其中,target_pc对应了指令跳转的具体位置,而next_pc则是存储着当前指令执行完后的下一个指令位置。在解释模式中,虚拟寄存器r11也扮演了类似的角色,保存了next_pc的值。
self.reg[11] = next_pc;
一条rbpf指令的字节码长度为8,对应的成员是ebpf::INSN_SIZE。当解析到合约ELF格式后,SVM会将字节码保存在JITProgram结构体中。通过调用(program_vm_addr,program)=executable.get_text_bytes(),可以获得起始运行地址和rbpf字节码,其中program_vm_addr是起始运行地址,设置为MM_PROGRAM_START+offset。
pub const MM_PROGRAM_START: u64 = 0x100000000;
当前PC运行的地址可以通过target_address=target_pc*ebpf::INSN_SIZE计算得到。
在解释模式中,为了防止运行时取指令越界,需要获取下一条执行的指令数next_pc+1,然后对比整个program字节码的长度,判断下一条指令是否已经超出整个rbpf程序指令运行的最大值。
let mut next_pc = self.reg[11] + 1;
if next_pc as usize * ebpf::INSN_SIZE > self.program.len() {
throw_error!(self, EbpfError::ExecutionOverrun);
}
此外,执行call指令和exit指令都会检查当前指令跳转的偏移量是否已经越界,比较的值是当前指令的偏移量offset是否在整个program字节码中越界。
check_pc!(self, next_pc, (target_pc - self.program_vm_addr) / ebpf::INSN_SIZE as u64);
macro_rules! check_pc {
($self:expr, $next_pc:ident, $target_pc:expr) => {
if ($target_pc as usize)
.checked_mul(ebpf::INSN_SIZE)
.and_then(|offset| $self.program.get(offset..offset + ebpf::INSN_SIZE))
.is_some()
{
$next_pc = $target_pc;
} else {
throw_error!($self, EbpfError::CallOutsideTextSegment);
}
};
}
除了以上的错误检查与安全防范,解释模式还存在许多其他检查。然而,这里的重点仍然是围绕着CU和rbpf指令来进行详细分析。
B 即时编译模式(JIT)
在JIT模式和解释模式中,最大的不同在于JIT模式需要将rbpf指令翻译成x86机器码,并在本地机器上执行,而解释模式则只需根据rbpf指令来进行相关运算。因此,想要获取JIT模式下的实时运行状态,就需要深入了解本地机器的运行模式。
在JIT模式中,使用JitProgram结构体来存储rbpf指令和对应的翻译后的x86指令。pc_section用于存储rbpf指令,而text_section则用于存储翻译后的x86指令。page_size则对应不同架构下的页面大小,通常为4096字节,用来映射到本地机器内存的页面大小。
pub struct JitProgram {
/// OS page size in bytes and the alignment of the sections
page_size: usize,
/// A `*const u8` pointer into the text_section for each BPF instruction
pc_section: &'static mut [usize],
/// The x86 machinecode
text_section: &'static mut [u8],
}
在JIT模式中,调用compile()来翻译rbpf指令。与解释模式不同的是,JIT模式将异常处理代码放在最前面,并通过调用emit_subroutines()来设置异常和错误处理。在运行时,当遇到错误时,会通过跳转到relative_to_anchor()函数来抛出异常。
// instruction_length = 5 (Unconditional jump / call)
// instruction_length = 6 (Conditional jump)
fn relative_to_anchor(&self, anchor: usize, instruction_length: usize) -> i32 {
let instruction_end = unsafe { self.result.text_section.as_ptr().add(self.offset_in_text_section).add(instruction_length) };
let destination = self.anchors[anchor];
debug_assert!(!destination.is_null());
(unsafe { destination.offset_from(instruction_end) } as i32) // Relative jump
}
以上是relative_to_anchor()函数的实现。在JIT模式中,该函数用于计算跳转目标相对于指令结尾的偏移量,从而实现异常处理的跳转。
在JIT模式中,CU的检查通过调用emit_validate_instruction_count()来完成,CU的计算则是通过调用emit_profile_instruction_count()实现的。然而,在JIT模式下,并不是每一条指令都会去判断CU的消耗,而是在一定的运行间隔后进行检查。
这个运行间隔由instruction_meter_checkpoint_distance决定,该值被设置为10000,即每运行10000条指令后判断一次CU的消耗情况。
以下是实现代码:
// 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(true, Some(self.pc));
}
以上代码片段表示,当当前指令位置self.pc超过上次CU验证的位置self.last_instruction_meter_validation_pc加上运行间隔instruction_meter_checkpoint_distance时,会调用emit_validate_instruction_count()进行CU的验证。
只有少数指令每次运行都会计算和检查CU消耗。这些指令包括ebpf::LD_DW_IMM、ebpf::JA、ebpf::CALL_IMM和ebpf::EXIT。在JIT模式下,这些指令的CU检查和CU消耗计算是同时进行的,调用了emit_validate_and_profile_instruction_count()方法。当翻译完成所有的rbpf指令后,还会调用一次emit_validate_and_profile_instruction_count()来进行最后一次检查和计算。
以下是相关代码片段:
fn emit_validate_and_profile_instruction_count(&mut self, exclusive: bool, target_pc: Option<usize>) {
if self.config.enable_instruction_meter {
self.emit_validate_instruction_count(exclusive, Some(self.pc));
self.emit_profile_instruction_count(target_pc);
}
}
在这段代码中,如果启用了指令计数器(enable_instruction_meter),则会调用emit_validate_instruction_count()进行CU的检查,并调用emit_profile_instruction_count()进行CU的计算。
在JIT模式中,CU计算函数emit_profile_instruction_count()的作用是根据当前pc和目标运行target_pc计算CU,然后将结果存储到寄存器REGISTER_INSTRUCTION_METER中。而emit_validate_instruction_count()函数用于检查CU是否超出了限制,如果超出限制,则会抛出异常ANCHOR_THROW_EXCEEDED_MAX_INSTRUCTIONS,下面代码则是异常处理:
self.emit_ins(X86Instruction::conditional_jump_immediate(if exclusive { 0x82 } else { 0x86 }, self.relative_to_anchor(ANCHOR_THROW_EXCEEDED_MAX_INSTRUCTIONS, 6)));
3. SVM的CU消耗计算错误原因
在引入了commit后,CU的计算出现了错误。在进行分析之前,让我们先回顾一下引入commit之前和之后的CU检查与计算方式:
A commit前后CU检查的对比
https://github.com/solana-labs/rbpf/pull/557/files
在引入这个commit*之前,我们先来分析一下JIT模式下CU的计算和消耗检查代码。在emit_validate_instruction_count()函数中,用于检查CU消耗情况时,直接使用了cmp指令来比较REGISTER_INSTRUCTION_METER和pc+1,这里的判断与解释模式的逻辑基本一致。REGISTER_INSTRUCTION_METER存储了剩余可用的CU数量,通过比较pc+1,可以确定下一条指令是否超出了限制:
self.emit_ins(X86Instruction::cmp_immediate(OperandSize::S64, REGISTER_INSTRUCTION_METER, pc as i64 + 1, None));
引入commit后,修改后增加了emit_sanitized_alu()的调用,同样传入的参数也是pc+1:
self.emit_sanitized_alu(OperandSize::S64, 0x39, RDI, REGISTER_INSTRUCTION_METER, pc as i64 + 1);
emit_sanitized_alu()的功能实际上是对pc+1进行了随机数优化,这个随机数是通过调用emit_sanitized_load_immediate函数来实现的。这样做的好处是防止攻击者覆盖立即数,从而导致数据被劫持,这里确保CU计数不会被劫持:
#[inline]
fn emit_sanitized_alu(&mut self, size: OperandSize, opcode: u8, opcode_extension: u8, destination: u8, immediate: i64) {
if self.should_sanitize_constant(immediate) {
self.emit_sanitized_load_immediate(size, REGISTER_SCRATCH, immediate);
self.emit_ins(X86Instruction::alu(size, opcode, REGISTER_SCRATCH, destination, 0, None));
.......`
` .......
}
}
should_sanitize_constant()函数的功能主要是判断立即数是否在优化的范围内,在超出范围的情况下都会进行优化。优化完成后,将会把pc+1的值存入寄存器REGISTER_SCRATCH中,到这里CU检查在commit前后都是没问题的。
B commit前后CU计算的对比
在分析提交前后CU计算时,我们先回顾一下引入commit前的emit_profile_instruction_count()函数。该函数的翻译逻辑是instruction_meter=target_pc-(self.pc+1),用于获取剩余的CU。但是,需要考虑到两种情况,即向前跳转和向后跳转。当向后跳转时,(self.pc+1)为负值,此时instruction_meter实际上是减去了(self.pc+1)-target_pc;而向前跳转时,实际上是增加了target_pc-(self.pc+1)。同样,self.pc也会随着变化而更新为target_pc。这样做的好处是,CU不会随着跳转的改变而出现计算错误。
self.emit_ins(X86Instruction::alu(OperandSize::S64, 0x81, 0, REGISTER_INSTRUCTION_METER, target_pc as i64 - self.pc as i64 - 1, None)); // instruction_meter += target_pc - (self.pc + 1);
接下来,我们将通过引入跳转指令的实例来分析commit提交前后的CU计算,并且也会对比rbpf指令翻译成x86机器码,这里以ja指令为例。
例如,通过调用以下的rbpf指令,通过调用ja跳转到-257的位置。这里选择-257是因为self.should_sanitize_constant(immediate)函数会判断为真,从而触发键(key)随机值优化。
在JIT中,ja指令翻译成x86指令代码如下所示。它首先通过调用emit_validate_and_profile_instruction_count()来检查CU的消耗情况并记录最终的CU计数,然后将target_pc保存到寄存器REGISTER_SCRATCH中,最后执行jump指令到偏移位置进行跳转:
// BPF_JMP class
ebpf::JA => {
self.emit_validate_and_profile_instruction_count(false, Some(target_pc));
self.emit_ins(X86Instruction::load_immediate(OperandSize::S64, REGISTER_SCRATCH, target_pc as i64));
let jump_offset = self.relative_to_target_pc(target_pc, 5);
self.emit_ins(X86Instruction::jump_immediate(jump_offset));
},
以下是翻译后的x86机器码,其中rbx是寄存器REGISTER_INSTRUCTION_METER,self.pc此时是256。因为这里是ja -257,所以是向后跳转,其实就是减去了(self.pc+1)-target_pc。这里目标的target_pc是入口处,因此target_pc为0。REGISTER_INSTRUCTION_METER减去257的CU消耗,所以在引入commit前,CU计算是没有问题的。
0: 48 81 fb 01 01 00 00 cmp rbx,0x101
7: 0f 86 ac f2 ff ff jbe 0xfffffffffffff2b9
d: 48 81 c3 ff fe ff ff add rbx,0xfffffffffffffeff
14: 49 c7 c3 00 00 00 00 mov r11,0x0
1b: e9 dd f8 ff ff jmp 0xfffffffffffff8fd
然而,引入commit后,emit_profile_instruction_count()的CU计算变为:
self.emit_sanitized_alu(OperandSize::S32, 0x81, 0, REGISTER_INSTRUCTION_METER, target_pc as i64 - self.pc as i64 - 1);
继续以ja -257为例,commit后的翻译x86机器码如下。通过键(key)随机化了257,并将随机化的值加到REGISTER_SCRATCH(这里是r11)中。然后进行比较操作,REGISTER_INSTRUCTION_METER和self.pc+1,在CU计算时也进行了键的随机化。最终得到r11为-257,然后执行rex.Rsbbebx,0x0,此处的立即数为0,并且没有用到存储(self.pc+1)的寄存器REGISTER_SCRATCH(r11),最后REGISTER_INSTRUCTION_METER减0,导致CU计数没有变化,所以这里出现了明显的错误!
0: 49 c7 c3 cc e6 7a ce mov r11,0xffffffffce7ae6cc
7: 49 81 c3 35 1a 85 31 add r11,0x31851a35
e: 4c 39 db cmp rbx,r11
11: 0f 86 a1 f2 ff ff jbe 0xfffffffffffff2b8
17: 41 c7 c3 e2 5a 25 fb mov r11d,0xfb255ae2
1e: 41 81 c3 1d a4 da 04 add r11d,0x4daa41d
25: 44 81 db 00 00 00 00 rex.R sbb ebx,0x0
2c: 49 c7 c3 00 00 00 00 mov r11,0x0
33: e9 c6 f8 ff ff jmp 0xfffffffffffff8fe
4. JIT模式翻译x86指令时错误参数导致的CU计算漏洞
在计算CU时发生的错误主要源于调用emit_profile_instruction_count()函数:
self.emit_sanitized_alu(OperandSize::S32, 0x81, 0, REGISTER_INSTRUCTION_METER, target_pc as i64 - self.pc as i64 - 1);
在翻译计算消耗的CU的功能时候调用了self.emit_sanitized_alu(),之后调用如下,这里的size是OperandSize::S32,opcode传入是x081,destination是REGISTER_INSTRUCTION_METER,接下来分析下是如何翻译成x86机器码的,参数是怎么传递的:
self.emit_ins(X86Instruction::alu(size, opcode, REGISTER_SCRATCH, destination, 0, None));
而alu()这里的传参如下,对应了size、opcode、source、destination、immediate、indirect,immediate_size这里对应0x81 => OperandSize::S32:
/// Arithmetic or logic
#[inline]
pub const fn alu(
size: OperandSize,opcode: u8,source: u8,destination: u8,immediate: i64,indirect: Option<X86IndirectAccess>,
) -> Self {
exclude_operand_sizes!(size, OperandSize::S0 | OperandSize::S8 | OperandSize::S16);
Self {
size,opcode,first_operand: source,second_operand: destination,
immediate_size: match opcode {
0xc1 => OperandSize::S8,
0x81 => OperandSize::S32,
0xf7 if source == 0 => OperandSize::S32,
_ => OperandSize::S0,
},
immediate,indirect,
..X86Instruction::DEFAULT
}
}
在alu()函数中,根据传入的参数size和opcode对指令进行了解析,然后根据这些信息生成了x86指令。最终,通过jit.emit()将其翻译成了x86指令。在这个过程中,最重要的四个结构体是X86Rex、X86ModRm、X86Sib和X86IndirectAccess。
X86Rex用于扩展寄存器操作数的大小和寻址范围; X86ModRm定义了x86架构中的ModR/M字节的结构,用于解析指令中的寄存器操作数和内存操作数;
X86Sib定义了x86架构中的SIB(Scale Index Base)字节的结构,用于解析指令中的内存操作数的索引和基址;
X86IndirectAccess枚举定义了x86架构中的间接访问方式,用于表示内存操作数的复杂寻址方式。
self.emit_ins(X86Instruction::alu(OperandSize::S32, 0x81, REGISTER_SCRATCH, REGISTER_INSTRUCTION_METER, 0, None));
翻译成x86机器码是:
44 81 db 00 00 00 00
机器码0x44对应的REX前缀为REX.R=1,REX.X=0,REX.B=0,REX.W=0;0x81的opcode可以对应指令sbb/add等;而db通过X86ModRm对应的是相应寄存器ebx。
这里最终翻译成了sbb ebx,0x0,而sbb在x86指令使用对应opcode方式如下:
0x81下的sbb,对应的只能是sbb rex imm;CU想要计算正确,则必须要用到r11里面的值,所以sbb应该使用sbb rex rex,对应opcode应该是0x19或者0x1b等。所以commit引入后,计算CU功能的代码在翻译x86机器码的过程中,传递参数发生了错误!
这里CU计算翻译成了sbb ebx, 0后会导致jump往后跳转不能有效地减去self.pc + 1的值,所以CU计算会失效。
5. 漏洞导致的后果及修复补丁
CU计算失效直接导致的后果就是:存在漏洞的智能合约将会在SVM中无限循环和消耗下去,攻击合约的poc只需要构造向后跳转ja-257的poc代码,SVM加载包含poc的智能合约即可导致SVM无限循环下去,并且没法加载其他合约。SVM加载此poc的流程如下:
所幸的是此commit还没有引入到发布版本中:在我们发现漏洞、准备通报Solana时,开发者及时意识并修复了这个错误,修复后的emit_profile_instruction_count函数commit如下:
self.emit_sanitized_alu(OperandSize::S64, 0x01, 0, REGISTER_INSTRUCTION_METER, target_pc as i64 - self.pc as i64 - 1);
最终修复后的ja -257机器码翻译如下,sbb ebx 0修复成add rbx r10,将r11换成了r10,最后把REGISTER_INSTRUCTION_METER (RBX)加上了 -257,正确计算了CU:
0: 49 c7 c2 cb 9a f5 eb mov r10,0xffffffffebf59acb
7: 49 81 c2 36 66 0a 14 add r10,0x140a6636
e: 4c 39 d3 cmp rbx,r10
11: 0f 86 a4 f2 ff ff jbe 0xfffffffffffff2bb
17: 49 c7 c2 23 e5 07 cd mov r10,0xffffffffcd07e523
1e: 49 81 c2 dc 19 f8 32 add r10,0x32f819dc
25: 4c 01 d3 add rbx,r10
28: 49 c7 c3 00 00 00 00 mov r11,0x0
2f: e9 cb f8 ff ff jmp 0xfffffffffffff8ff
6. 漏洞时间线
漏洞发现:
https://github.com/solana-labs/rbpf/pull/557/files
漏洞修复:
注:漏洞未被引入任何发行版本。