技术详解 | Solana的SVM无限循环指令消耗漏洞分析

admin 2024年5月24日23:19:50评论2 views字数 13160阅读43分52秒阅读模式

技术详解 | Solana的SVM无限循环指令消耗漏洞分析

SVM(Solana Virtual Machine)是Solana区块链生态系统的核心组件之一,负责执行智能合约和去中心化应用程序。其核心原理是利用即时编译技术实现高性能的智能合约执行。由于Solana的高吞吐量和低延迟特性,SVM在Solana中扮演着至关重要的角色,为开发者提供了一个高效的去中心化应用开发环境,并且对Solana的安全性起着重要作用。

最近Solana开发者为了提高SVM的安全性能,为SVM引入JIT二级防御,该功能针对立即数(immediate)进行了随机数优化,旨在防止攻击者操控立即数影响JIT机器指令生成内容(JIT Spraying)。然而,这一引入虽然增加了新的功能,但是修改了CU(计算单元)的计算。CU的计算没有正确修改,这一变更导致了严重的错误,影响了SVM中对指令资源消耗的重要计量

技术详解 | Solana的SVM无限循环指令消耗漏洞分析JIT二级防御

https://github.com/solana-labs/rbpf/pull/557

技术详解 | Solana的SVM无限循环指令消耗漏洞分析随机数优化

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中的合约执行流程如下:

1.将ELF智能合约解析得到rbpf指令字节码。
2.初始化运行环境,包括加载堆栈、初始化虚拟机等。
3.在解释模式下执行execute_program(),根据rbpf指令执行计算。

4.或者在JIT模式下执行,即时编译rbpf字节码为x86机器码后执行execute_program()

技术详解 | Solana的SVM无限循环指令消耗漏洞分析

2. SVM中rbpf指令的CU计算和安全检查

计算单元(CU)是Solana区块链智能合约计算资源消耗的最小计量单位,即每个Solana-rbpf指令的计算单元,用于估算智能合约执行指令的成本。

在Solana1.9.2中引入了一项名为“事务范围计算上限”的功能,类似于ETH的GasLimit。默认情况下,每个事务具有200,000CU的预算,并且封装的指令从该事务预算中提取。每笔交易请求(和使用)的最大CU为140万个CU。

技术详解 | Solana的SVM无限循环指令消耗漏洞分析“事务范围计算上限”功能
https://solanacookbook.com/guides/feature-parity-testing.html#scenario

在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的计算方法和安全检查

A 解释模式(Interpreter)
解释模式下的CU计算如下图所示:

技术详解 | Solana的SVM无限循环指令消耗漏洞分析

在解释模式中,初始化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)    #[inline]    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_IMMebpf::JAebpf::CALL_IMMebpf::EXIT。在JIT模式下,这些指令的CU检查和CU消耗计算是同时进行的,调用了emit_validate_and_profile_instruction_count()方法。当翻译完成所有的rbpf指令后,还会调用一次emit_validate_and_profile_instruction_count()来进行最后一次检查和计算。

以下是相关代码片段:

#[inline]    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检查的对比

技术详解 | Solana的SVM无限循环指令消耗漏洞分析*引入的commit

https://github.com/solana-labs/rbpf/pull/557/files

在引入这个commit*之前,我们先来分析一下JIT模式下CU的计算和消耗检查代码。在emit_validate_instruction_count()函数中,用于检查CU消耗情况时,直接使用了cmp指令来比较REGISTER_INSTRUCTION_METERpc+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)随机值优化。

技术详解 | Solana的SVM无限循环指令消耗漏洞分析

在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_METERself.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,0x1017:  0f 86 ac f2 ff ff          jbe   0xfffffffffffff2b9d:  48 81 c3 ff fe ff ff       add   rbx,0xfffffffffffffeff14: 49 c7 c3 00 00 00 00       mov   r11,0x01b: e9 dd f8 ff ff             jmp   0xfffffffffffff8fd

然而,引入commit后,emit_profile_instruction_count()的CU计算变为:

self.emit_sanitized_alu(OperandSize::S32, 0x810, REGISTER_INSTRUCTION_METER, target_pc as i64 - self.pc as i64 - 1);

继续以ja -257为例,commit后的翻译x86机器码如下。通过键(key)随机化了257,并将随机化的值加到REGISTER_SCRATCH(这里是r11)中。然后进行比较操作,REGISTER_INSTRUCTION_METERself.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,0xffffffffce7ae6cc7:  49 81 c3 35 1a 85 31       add   r11,0x31851a35e:  4c 39 db                   cmp   rbx,r1111: 0f 86 a1 f2 ff ff          jbe   0xfffffffffffff2b817: 41 c7 c3 e2 5a 25 fb       mov   r11d,0xfb255ae21e: 41 81 c3 1d a4 da 04       add   r11d,0x4daa41d25: 44 81 db 00 00 00 00       rex.R sbb ebx,0x02c: 49 c7 c3 00 00 00 00       mov   r11,0x033: 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::S32opcode传入是x081,destinationREGISTER_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()函数中,根据传入的参数sizeopcode对指令进行了解析,然后根据这些信息生成了x86指令。最终,通过jit.emit()将其翻译成了x86指令。在这个过程中,最重要的四个结构体是X86RexX86ModRmX86SibX86IndirectAccess

  • X86Rex用于扩展寄存器操作数的大小和寻址范围;
  • X86ModRm定义了x86架构中的ModR/M字节的结构,用于解析指令中的寄存器操作数和内存操作数;

  • X86Sib定义了x86架构中的SIB(Scale Index Base)字节的结构,用于解析指令中的内存操作数的索引和基址;

  • X86IndirectAccess枚举定义了x86架构中的间接访问方式,用于表示内存操作数的复杂寻址方式。
commit后错误的指令参数如下:
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=1REX.X=0REX.B=0REX.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机器码的过程中,传递参数发生了错误!

技术详解 | Solana的SVM无限循环指令消耗漏洞分析

这里CU计算翻译成了sbb ebx, 0后会导致jump往后跳转不能有效地减去self.pc + 1的值,所以CU计算会失效。

5. 漏洞导致的后果及修复补丁

CU计算失效直接导致的后果就是:存在漏洞的智能合约将会在SVM中无限循环和消耗下去,攻击合约的poc只需要构造向后跳转ja-257的poc代码,SVM加载包含poc的智能合约即可导致SVM无限循环下去,并且没法加载其他合约。SVM加载此poc的流程如下:

技术详解 | Solana的SVM无限循环指令消耗漏洞分析

所幸的是此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,0xffffffffebf59acb7:  49 81 c2 36 66 0a 14     add   r10,0x140a6636e:  4c 39 d3                 cmp   rbx,r1011: 0f 86 a4 f2 ff ff        jbe   0xfffffffffffff2bb17: 49 c7 c2 23 e5 07 cd     mov   r10,0xffffffffcd07e5231e: 49 81 c2 dc 19 f8 32     add   r10,0x32f819dc25: 4c 01 d3                 add   rbx,r1028: 49 c7 c3 00 00 00 00     mov   r11,0x02f: e9 cb f8 ff ff           jmp   0xfffffffffffff8ff

6. 漏洞时间线

漏洞发现

Commit(2024年4月18日

https://github.com/solana-labs/rbpf/pull/557/files

漏洞修复

Commit(2024年5月19日
https://github.com/solana-labs/rbpf/pull/567/files

:漏洞未被引入任何发行版本。

技术详解 | Solana的SVM无限循环指令消耗漏洞分析

原文始发于微信公众号(CertiK):技术详解 | Solana的SVM无限循环指令消耗漏洞分析

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2024年5月24日23:19:50
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   技术详解 | Solana的SVM无限循环指令消耗漏洞分析https://cn-sec.com/archives/2773615.html

发表评论

匿名网友 填写信息