SVM内部:sBPF JIT安全缺陷和内存泄漏

  • zellic
  • 发布于 2天前
  • 阅读 36

本文深入探讨了Solana区块链中sBPF引擎的安全问题,通过分析程序生命周期,识别了攻击面和可控输入点。重点关注了解释器和JIT编译器的内部机制,并通过两个真实的漏洞案例,展示了资源耗尽和.rodata损坏等安全风险,强调了验证器、解释器和JIT编译器的边界对安全至关重要。

本文面向安全研究和审计:我们首先定位程序生命周期中可控的输入和验证边界,然后分解解释器和 JIT 执行路径,最后使用两个真实漏洞来展示如何触及这些边界。目标是告诉你该看哪里以及为什么。

基础

让我们从 Solana 安全背后的核心概念开始。

  • 作为专注于高性能、高吞吐量和低延迟的公链,Solana 已成为最强大的去中心化区块链之一。其性能引擎是 sBPF - Solana 对标准 eBPF 的定制扩展。
  • 如果我们将执行环境比作一台计算机,那么 SVM 是操作系统,而 sBPF 是 CPU。作为容器环境,SVM 提供沙箱、内存管理、系统调用分发和资源限制。sBPF 通过解释或 JIT 编译来执行程序字节码。它们共同构成了 Solana 程序的执行基础设施。
  • 追溯其起源,Berkeley Packet Filter (BPF) 诞生于 BSD,是一种伪汇编语言,用于高效的内核数据包过滤。Solana 扩展了这个想法,用于智能合约执行,从而产生了 sBPF。
  • 所有 Solana 程序都由 SVM 内部的 sBPF 引擎执行,具有两种执行模式:解释器(安全优先)和 JIT 编译(性能优先)。

程序生命周期方法

SVM 或 sBPF 的核心价值是为程序提供执行环境。分析程序生命周期有助于识别可控的输入点和潜在的攻击面,以进行漏洞研究。下图显示了从开发到执行的完整流程:

OnChainExecution

已验证

程序已在链上

发送交易以调用已部署的程序

JIT 编译或解释

运行时验证

执行指令

开发者编写 Solana 程序

编译为 eBPF 字节码

将程序部署到区块链

开发者构建交互

调用 Solana RPC API

注意:交易不部署代码。部署发生在程序发布时。交易仅调用已部署的程序。

从安全研究的角度来看,生命周期突出了几个问题:

  1. 编译阶段的安全性。 在从 Rust 编译到 eBPF 字节码期间,编译器优化是否会引入意外行为?优化的字节码是否符合开发者的意图?
  2. 执行阶段的安全性。 解释器中是否存在逻辑错误?JIT 是否可以跳过检查或偏离解释器的语义?
  3. 部署阶段的安全性。 链上验证是否遗漏了关键检查,导致运行时出现未定义的行为?
  4. 调用阶段的安全性。 交易输入是否可以绕过参数验证或权限检查?

从攻击者的角度来看,关键的可控输入包括:

  • 程序字节码,因为它是完全可控的,并且可以使用特殊的指令模式来制作
  • 交易参数,因为参数值和调用顺序是完全可控的
  • 调用上下文,因为可以精心制作交易来影响执行状态

Solana 的大部分是用 Rust 实现的。这意味着安全研究不仅必须考虑 Solana 的架构,还必须考虑 Rust 特有的属性,例如内存安全和所有权。

深入 sBPF

我们已经确定了主要的攻击面和可控输入。接下来,我们深入研究两个核心执行组件:解释器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) 上调用 loadstore
    • 算术/逻辑 (ALU/ALU64) - 指令分为 32 位(32 后缀)和 64 位(64 后缀)操作
    • 32 位操作 - 根据 sBPF 版本有符号扩展或零扩展结果,以正确更新高 64 位
    • 除法/模 (DIV/MOD) - 严格的运行时检查,例如除以零 (throw_error!(DivideByZero; ...)),以及有符号除法的溢出检查(例如 i32::MIN / -1),这些检查会引发 EbpfError::DivideOverflow
  • 函数调用 (CALL) 和退出 (EXIT),包括:

    • BPF 到 BPF 的调用 (CALL_IMMCALL_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 编译

与解释器不同,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
    // [... ...]
}

接下来是指令级翻译(主循环):

  1. 迭代每个 eBPF 指令 (insn)。
  2. 记录当前 BPF PC 的本机代码偏移量,以便稍后解析跳转目标。
  3. 插入周期性的指令预算检查 (emit_validate_instruction_count)。这是计量检查点,间隔由 config.instruction_meter_checkpoint_distance 控制。
  4. 根据操作码 (insn.opc) 生成 x86-64 指令序列,包括:
  • 内存操作 (LDX/STX) - 调用 emit_address_translation 以集成内存沙箱,将内存访问转换为具有边界和权限检查的运行时调用。
  • 算术/逻辑 - 生成 ADDSUBMOVSHRMULDIV 等,并处理常量清理。
  • 分支 (JMP) - 生成 CMPTEST 和条件跳转。在分支之前,调用 emit_validate_and_profile_instruction_count 以更新计量。
  • 函数调用 (CALL) - 为内部 BPF 调用与外部系统调用发出不同的逻辑。
  • 退出 (EXIT) - 处理返回或程序终止。
  1. 最终处理和密封,包括:
  • 最终异常缓冲区 - 插入额外的指令,以便如果程序在没有 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 是该风险的直接示例。

常见的 sBPF 漏洞

下面,我们基于 Secret Club 在其文章 “周末模糊测试赚取 20 万美元:第二部分”↗ 中最初发布的研究结果,分析 Solana 生态系统中的两个真正的 sBPF 漏洞。

漏洞 1:资源耗尽

第一个漏洞是一个经典的 堆内存泄漏。触发条件:启用指令计量的 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;
    // ...
}

运行时序列是:

  1. report_unresolved_symbolErr(UnresolvedSymbol(String)) 写入结果槽。
  2. 紧随其后,emit_validate_instruction_count 运行。如果超出预算,它会跳转到 ExceededMaxInstructions
  3. 异常处理程序使用 store_immediate 覆盖结果槽中的字段,而不删除旧的 Err
  4. 原始 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), &[\

漏洞 2:持久性 .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 结构体填充,MemoryRegionis_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()

结束语

以下是真正重要的:

  1. 边界最为重要。 验证器决定了可接受的字节码。解释器定义了运行时错误语义。如果这些不一致,那么它们之上的所有内容都是不稳定的。
  2. JIT 是小错误变得昂贵的地方。 代码生成、异常路径和计量是密集且手动调整的。与解释器行为的任何不匹配都值得视为错误。
  3. 案例显示了模式。 一个错误是异常覆盖导致资源泄漏。另一个是操作码宽度编码错误,导致 .rodata 权限损坏。它们是不同的错误,但都来自底层正确性的下降。

如果你想更深入,请在相同的字节码上区分解释器和 JIT 行为,跟踪结果槽写入和计量跳转,并审核手写编码器和内存映射。这些是最值得优先考虑的领域。

关于我们

Zellic 专门从事新兴技术的安全保护。我们的安全研究人员已经发现最有价值的目标中的漏洞,从财富 500 强企业到 DeFi 巨头。

开发者、创始人和投资者信任我们的安全评估,以便快速、自信且无关键漏洞地交付产品。凭借我们在现实世界进攻性安全研究方面的背景,我们发现了其他人遗漏的东西。

‍联系我们↗ 进行比其他评估更好的审计。真正的审计,而不是橡皮图章审计。

  • 原文链接: zellic.io/blog/solana-sb...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
zellic
zellic
Security reviews and research that keep winners winning. https://www.zellic.io/