Skip to main content
Lime

Inside the SVM - sBPF JIT Security Pitfalls and Memory Leaks

Dissects sBPF execution paths and JIT details, using real vulnerabilities to understand security boundaries
Article heading

This article is for security research and auditing: we start by locating controllable inputs and verification boundaries in the program lifecycle, then break down the interpreter and JIT execution paths, and finally use two real vulnerabilities to show how those boundaries are hit. The goal is to tell you where to look and why.

Foundations

Let’s start with the core concepts behind Solana security.

  • As a public chain focused on high performance, throughput, and low latency, Solana has become one of the most powerful decentralized blockchains. Its performance engine is sBPF - Solana’s customized extension of standard eBPF.
  • If we compare the execution environment to a computer, the SVM is the operating system and sBPF is the CPU. As a container environment, the SVM provides sandboxing, memory management, syscall dispatch, and resource limits. sBPF executes program bytecode either by interpretation or JIT compilation. Together they form the execution infrastructure for Solana programs.
  • Tracing its origins, the Berkeley Packet Filter (BPF) was born in BSD as a pseudo-assembly language for efficient kernel packet filtering. Solana extends that idea for smart contract execution, resulting in sBPF.
  • All Solana programs are executed by the sBPF engine inside the SVM, with two execution modes: interpreter (security first) and JIT compilation (performance first).

Approaching the Program Lifecycle

The core value of the SVM or sBPF is to provide an execution environment for programs. Analyzing the program lifecycle helps identify controllable input points and potential attack surfaces for vulnerability research. The figure below shows the full flow from development to execution:

Note: transactions do not deploy code. Deployment happens when the program is published. Transactions only invoke deployed programs.

From a security research perspective, the lifecycle highlights several concerns:

  1. Compilation phase security. During compilation from Rust to eBPF bytecode, can compiler optimizations introduce unexpected behavior? Does optimized bytecode match developer intent?
  2. Execution phase security. Are there logic bugs in the interpreter? Can the JIT skip checks or diverge from interpreter semantics?
  3. Deployment phase security. Does on-chain verification miss key checks, leading to undefined behavior at runtime?
  4. Invocation phase security. Can transaction inputs bypass parameter validation or permission checks?

From an attacker’s perspective, key controllable inputs include:

  • Program bytecode, because it is fully controllable and can be crafted with special instruction patterns
  • Transaction parameters, because argument values and call ordering are fully controllable
  • Call context, because transactions can be crafted to influence execution state

Much of Solana is implemented in Rust. That means security research must consider not only Solana’s architecture, but also Rust-specific properties such as memory safety and ownership.

Deep Dive into sBPF

We have identified the main attack surfaces and controllable inputs. Next we dive into two core execution components: the interpreter and the JIT compiler. These are the real execution engines of the SVM, responsible for translating and running eBPF bytecode.

Other links in the diagram, such as compilation optimization and on-chain verification, also matter. However, this article focuses on these two components because they are the direct source of most sBPF vulnerabilities.

Interpreter Execution

Now let’s explore the interpreter’s internal workings and how sBPF executes bytecode one instruction at a time. Understanding this path matters because it defines runtime boundaries and error semantics, and provides a baseline for checking JIT consistency.

The execution path can be simplified as: execute handles parameter serialization and VM initialization, execute_program chooses interpreter or JIT execution, and the caller maps errors and accounts for metering.

First, execute() handles parameter serialization, memory mapping, and VM initialization, then transfers control to 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);
// ...
}

Next, execute_program has two paths. The interpreter path creates an Interpreter and runs step() repeatedly. The JIT path directly invokes compiled machine code. This section focuses on the interpreter path, and JIT details follow later.

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);
}
// ...
}

This is the core logic of the execution phase: the interpreter path creates an Interpreter and repeatedly calls step(), while the JIT path directly invokes compiled machine code.

After execute_program() returns, the caller maps ProgramResult to runtime errors (such as InstructionError) and performs metering on error paths (for example, consuming the remaining budget).

Next, step is the interpreter entry point. The VM maintains a register table with 12 registers. The last two are special registers. The table below uses “all” to indicate the register exists across sBPF versions and feature sets.

NameApplies toTypeSolana ABI
r0allGPRreturn value
r1allGPRargument 0
r2allGPRargument 1
r3allGPRargument 2
r4allGPRargument 3
r5allGPRargument 4 or stack ptr
r6allGPRcallee saved
r7allGPRcallee saved
r8allGPRcallee saved
r9allGPRcallee saved
r10allframe pointersystem register
pcallprogram counterhidden register

Each 8 bytes are treated as one instruction (inst), with the format below. The SVM defines the instruction format: opc is the opcode, dst/src are register indices, off is the offset, and immediate is the immediate value.

+-------+--------+---------+---------+--------+-----------+
| 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

The step executes a single eBPF instruction and checks whether the program should continue, terminate, or throw an error.

Before executing any instruction, step performs runtime and safety checks.

  • Instruction metering, checking whether the cumulative instruction count (self.vm.due_insn_count) has reached the budget (self.vm.previous_instruction_meter)

  • Program counter bounds check, verifying the current instruction address (self.reg[11]) does not exceed the code length

  • Instruction translation, using a large match insn.opc to decode and execute the current instruction, including:

    • Memory operations (LDX/STX) - load (LD_B_REG, LD_DW_REG) and store (ST_B_IMM, ST_DW_REG) operations using the translate_memory_access! macro, which calls load or store on the memory sandbox (self.vm.memory_mapping)
    • Arithmetic/logic (ALU/ALU64) - instructions split into 32-bit (32 suffix) and 64-bit (64 suffix) operations
    • 32-bit operations - sign-extend or zero-extend results depending on sBPF version to update the upper 64 bits correctly
    • Division/modulo (DIV/MOD) - strict runtime checks like divide by zero (throw_error!(DivideByZero; ...)), and overflow checks for signed division (such as i32::MIN / -1) that raise EbpfError::DivideOverflow
  • Function calls (CALL) and exit (EXIT), including:

    • BPF-to-BPF calls (CALL_IMM or CALL_REG) - call push_frame to increase call depth (self.vm.call_depth) and push the current register state onto the call stack. Then check if call depth exceeds config.max_call_depth. It also uses check_pc! to ensure the target PC is inside the text segment, preventing EbpfError::CallOutsideTextSegment.
    • Syscalls (CALL_IMM) - call dispatch_syscall, save metering state, map eBPF registers to Rust variables, call the host builtin (syscall), then update registers (primarily R0) and metering.
    • Exit (EXIT) - if self.vm.call_depth == 0 (main program exit), store R0 into the final result (ProgramResult::Ok(self.reg[0])) and return false to stop. If self.vm.call_depth > 0 (return from a BPF-to-BPF call), restore registers and return address (frame.target_pc), decrement call depth, and continue.
    /// Returns false if the program terminated or threw an error.
    #[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
    }

The verifier runs when loading/creating an Executable and performs static checks to ensure jumps are in bounds, register use is valid (e.g., R10 read-only), and call targets exist in the symbol table. It guarantees the static boundary of “executable” bytecode but does not guarantee runtime semantic equivalence between the interpreter and 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)?;
// ...

After validating each instruction’s parameters, it checks whether the registers are valid. These checks filter out almost all illegal instruction behavior.

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 Compilation

Unlike the interpreter, the JIT compiles eBPF into native machine code and executes it directly. This shortens the execution path but moves security boundaries into codegen, exception handling, and instruction metering details - exactly where vulnerabilities tend to appear. Whether JIT is enabled depends on runtime configuration and feature flags, so security analysis should confirm the actual execution path.

After JIT compilation, the generated machine code and metadata are stored in JitProgram: text_section holds the native code, and pc_section maps BPF instruction indices to native code addresses.

This layout primarily serves fast address mapping and jump resolution.

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],
}

The JIT compiler maps 11 eBPF registers (R0-R10) to specific x86-64 registers for efficient access:

eBPF Registerx86-64 RegisterFunction/note
R0RAXReturn value / scratch
R1-R5RSI, RDX, RCX, R8, R9Function call arguments
R6-R9RBX, R12, R13, R14Callee saved
R10R15Frame pointer (FP)
SpecialRDIPointer-to-VM runtime environment (REGISTER_PTR_TO_VM)
SpecialR10 (Host)Instruction meter (REGISTER_INSTRUCTION_METER)
SpecialR11General scratch register

eBPF instructions cannot access host registers directly. Escape risk mainly comes from emitter or address translation defects.

The compile is the core of JIT. It translates eBPF bytecode into executable x86-64 machine code. At the start, it calls emit_subroutines() to generate fixed reusable code blocks (anchors), such as the unified exception entry, epilogue, and syscall logic.

fn emit_subroutines(&mut self) {
// Routine for instruction tracing
if self.config.enable_register_tracing {
self.set_anchor(ANCHOR_TRACE);
// ...
}

// Epilogue
// [... ...]
}

Then comes instruction-level translation (main loop):

  1. Iterate over each eBPF instruction (insn).
  2. Record the native code offset for the current BPF PC to resolve jump targets later.
  3. Insert periodic instruction budget checks (emit_validate_instruction_count). This is the metering checkpoint, and the interval is controlled by config.instruction_meter_checkpoint_distance.
  4. Generate x86-64 instruction sequences based on the opcode (insn.opc), including:
  • Memory operations (LDX/STX) - call emit_address_translation to integrate the memory sandbox, translating memory access into runtime calls with bounds and permission checks.
  • Arithmetic/logic - generate ADD, SUB, MOV, SHR, MUL, DIV, etc., and handle constant sanitization.
  • Branches (JMP) - generate CMP or TEST and conditional jumps. Before branching, call emit_validate_and_profile_instruction_count to update metering.
  • Function calls (CALL) - emit different logic for internal BPF calls vs external syscalls.
  • Exit (EXIT) - handle return or program termination.
  1. Final processing and sealing, including:
  • Final exception buffer - insert extra instructions so that if the program falls off the end without EXIT, it throws ExecutionOverrun.
  • Jump relocation (resolve_jumps) - after the main loop, compute and patch relative offsets for forward jumps.
  • Program sealing (seal) - set the machine code length, fill remaining space with debug traps (0xcc), and set memory protections (mark text_section as 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));
}
// ...
}
// ...
}

In summary: from a security research perspective, JIT risk points concentrate on instruction metering, exception path insertion, result slot writes, interpreter/JIT inconsistencies, and hand-written encoding details. This self-built emitter trades lower compile overhead and precise instrumentation for the burden of opcode correctness. In the following section, we’ll cover two vulnerabilities in sBPF. Vulnerability 2 is a direct example of that risk.

Common sBPF Vulnerabilities

Below we analyze two real sBPF vulnerabilities from the Solana ecosystem, based on the findings originally published by Secret Club in their article “Earn $200K by fuzzing for a weekend: Part 2”.

Vulnerability 1: Resource Exhaustion

The first vulnerability is a classic heap memory leak. Trigger condition: JIT mode with instruction metering enabled, and the program is near its limit when executing call -1 (unresolved symbol). It first generates an error containing a String, then later exception handling overwrites the slot via raw writes, and the String is never freed. Repeated triggers exhaust memory.

First, the runtime trigger: when CALL_IMM is unresolved, the JIT embeds the function address of report_unresolved_symbol into the machine code and calls it at runtime.

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)?;
}

The as i64 here just embeds the function address into the JIT code. It does not execute at compile time. Actual execution happens at runtime: report_unresolved_symbol constructs Err(ElfError::UnresolvedSymbol(name.to_string(), ...)), allocating a heap String. Therefore unresolved symbols are not rejected at load time, but deferred to runtime reporting.

pub fn report_unresolved_symbol(&self, insn_offset: usize) -> Result<u64, EbpfError<E>> {
// ...
Err(ElfError::UnresolvedSymbol(
name.to_string(), // heap allocation
// ...
)
.into())
}

Note: report_unresolved_symbol only writes Err into the result slot. It does not change control flow. Execution continues into the instruction metering check, so the error can be overwritten by a later exception.

This happens because JIT emits a linear instruction sequence: emit_rust_call is a normal call, and after the Rust function returns, the CPU executes the next instruction. The Err in the result slot does not change RIP. Control flow only changes via explicit jumps - both the cmp/jcc emitted by emit_validate_instruction_count (which jumps to the “budget exceeded” exception) and the subsequent emit_jmp (to the Rust exception handler). Therefore the return value does not stop execution. The JIT error semantic is “write slot + rely on later jumps,” so it is not atomic in time.

The core issue is the result slot. The JIT stores a pointer to ProgramResult<E> in OptRetValPtr. This slot belongs to the host VM runtime structure (not eBPF linear memory). The JIT then sets errors via raw memory writes. These writes do not trigger Rust Drop because there is no Rust-level assignment. From the JIT’s view, the result slot is just a raw address (like a u64), and store_immediate emits a CPU memory overwrite (MOV/STORE), bypassing Rust’s drop glue. The JIT’s own Drop only frees code sections (such as JitProgramSections), and the machine code’s writes to the result slot are entirely outside Rust’s ownership system. The host only reads the error flag/code and does not drop the overwritten old 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;
// ...
}

The runtime sequence is:

  1. report_unresolved_symbol writes Err(UnresolvedSymbol(String)) into the result slot.
  2. Immediately after, emit_validate_instruction_count runs. If the budget is exceeded, it jumps to ExceededMaxInstructions.
  3. The exception handler uses store_immediate to overwrite fields in the result slot, without dropping the old Err.
  4. The original String loses its reference and leaks. Repeated triggers exhaust memory.

The key fix is to move instruction metering before the call: if the budget is already exhausted, report the error directly and do not call report_unresolved_symbol to allocate a String, thereby avoiding the leak path.

+    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), &[

Vulnerability 2: Persistent .rodata Corruption

The second vulnerability is a straightforward x86 instruction encoding error: a hand-written encoder chose the wrong opcode/immediate size, causing an operand size mismatch.

The root cause is a cmp operand size error.

The JIT tries to use X86Instruction::cmp_immediate to emit a cmp, but it incorrectly uses opcode 0x81 (for 16/32/64-bit operands) instead of 0x80 (for 8-bit operands).

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()
}
}

As a result, the JIT emits cmp DWORD PTR [rax+0x19], 0x0 (32-bit compare).

Because of Rust struct padding, the bytes after the is_writable field in MemoryRegion are often non-zero. This cmp is used to check is_writable: it should compare only 1 byte, but instead compares 4 bytes, reading padding bytes and misclassifying a read-only region as writable. Writes that should be blocked are allowed, and .rodata gets corrupted. Here “persistent” means visible to subsequent executions within the same JIT artifact/process lifetime, not permanent on-chain state. Padding bytes are not guaranteed to be non-zero, but the width error reads across fields and distorts the permission check.

The core fix is to choose the opcode based on 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()

Closing Thoughts

Here’s what actually matters:

  1. The boundaries matter most. The verifier decides what bytecode is admissible. The interpreter defines runtime error semantics. If those don’t line up, everything above them is on shaky ground.
  2. JIT is where small mistakes get expensive. Codegen, exception paths, and metering are dense and hand-tuned. Any mismatch with interpreter behavior is worth treating as a bug.
  3. The cases show the pattern. One bug is an exception overwrite that leaks resources. The other is an opcode-width encoding mistake that corrupts .rodata permissions. They’re different bugs, but both come from low-level correctness slipping.

If you want to go deeper, diff interpreter and JIT behavior on identical bytecode, trace result-slot writes and metering jumps, and audit hand-written encoders and memory mappings. Those are the areas most worth prioritizing.

About Us

Zellic specializes in securing emerging technologies. Our security researchers have uncovered vulnerabilities in the most valuable targets, from Fortune 500s to DeFi giants.

Developers, founders, and investors trust our security assessments to ship quickly, confidently, and without critical vulnerabilities. With our background in real-world offensive security research, we find what others miss.

‍Contact us for an audit that’s better than the rest. Real audits, not rubber stamps.