Skip to content

Rejects valid truncating sol_get_return_data reads #249

Description

@caomingpei

Overview

sol_get_return_data copies at most the caller's requested number of bytes and
returns the full available return-data length. Mithril instead returns
SyscallErrInvalidLength whenever a nonzero destination is shorter than the
available return data.

Minimal scenario:

available return data: 24 bytes
requested destination: 16 bytes

Expected: copy bytes [0..16], copy the program ID, return 24
Mithril:  abort the VM invocation with SyscallErrInvalidLength

Observed result:

Agave official syscall test: success, 16-byte prefix copied, returns 24
Mithril 24-byte control:      success, syscall returns 24
Mithril 16-byte read:         VM error SyscallErrInvalidLength

The Go-only reproducer below constructs a verifier-valid V0 sbpf.Program,
executes it with Mithril's official interpreter and runtime syscall registry,
and compares a 16-byte read with a 24-byte control. Short-buffer reads are an
ordinary API use: a caller may retrieve a prefix or query data whose full size
exceeds its local buffer. This is a directly reachable syscall semantic error
that can turn a successful program invocation into failure.

Tested Version

Mithril:    6f31241ba6c48a37316ea23cc947bde395e0722f (v0.3.0)
Agave:      f7db9dee441713162a70086cff23e84788f05386
Host:       Linux x86-64
Go:         1.25.7
sBPF:       V0

Root Cause

Mithril first computes the correct copy length:

length = min(len(returnData), requestedLength)

It translates a destination of exactly length bytes, but then compares that
translated destination length with the full, untruncated len(returnData) in
pkg/sealevel/syscalls_call.go, lines 39-58.
When
0 < requestedLength < len(returnData), the lengths necessarily differ and
Mithril returns SyscallErrInvalidLength; the prefix copy on line 60 is never
reached.

Agave compares and copies return_data[..length] and returns the original full
length
(syscalls/src/lib.rs, lines 1967-1997).
Sig implements the same min/copy/full-length behavior
(shared/vm/syscalls/lib.zig, lines 540-570),
as does Firedancer
(fd_vm_syscall_runtime.c, lines 450-486).

The syscall is unconditionally registered in Mithril
(pkg/sealevel/syscalls.go, lines 118-121),
and the normal upgradeable-program path reaches the official loader, verifier,
registry, and interpreter. No custom registry, invalid pointer, malformed ELF,
or feature mismatch is needed.

Suggested Fix

Compare and copy only the selected prefix, while returning the full available
length. In outline:

length := min(uint64(len(returnData)), requestedLength)
dst, err := translateBytes(..., length, ...)
if err != nil {
    return 0, err
}
copy(dst, returnData[:length])
return uint64(len(returnData)), nil

Preserve the program-ID copy and the existing overlap checks.

Add regressions for zero-length, shorter, equal-length, and longer destination
buffers. For 24 bytes of return data and a 16-byte destination, assert the
16-byte prefix, program ID, and return value 24.

Reproduction

The reproducer is one Go file. It creates both sBPF programs from encoded Go
slots, verifies them through Program.Verify, and executes them through
NewInterpreter with Mithril's official runtime syscall registry. It requires
only Go 1.25.7 and a clean Mithril checkout at the tested revision.

package main

import (
    "bytes"
    "encoding/binary"
    "fmt"
    "strings"

    "github.com/Overclock-Validator/mithril/pkg/cu"
    "github.com/Overclock-Validator/mithril/pkg/features"
    "github.com/Overclock-Validator/mithril/pkg/sbpf"
    "github.com/Overclock-Validator/mithril/pkg/sbpf/sbpfver"
    "github.com/Overclock-Validator/mithril/pkg/sealevel"
    "github.com/gagliardetto/solana-go"
)

const (
    meterLimit      = uint64(1_000_000)
    returnDataSize  = 24
    programIDOffset = 64
)

var returnData = []byte("abcdefghijklmnopqrstuvwx")
var returnProgramID = solana.PublicKey{1, 2, 3}

func slot(op, dst, src uint8, off int16, imm uint32) sbpf.Slot {
    return sbpf.Slot(op) |
        sbpf.Slot(dst)<<8 |
        sbpf.Slot(src)<<12 |
        sbpf.Slot(uint16(off))<<16 |
        sbpf.Slot(imm)<<32
}

func slotsToBytes(slots []sbpf.Slot) []byte {
    raw := make([]byte, len(slots)*sbpf.SlotSize)
    for i, ins := range slots {
        binary.LittleEndian.PutUint64(raw[i*sbpf.SlotSize:], uint64(ins))
    }
    return raw
}

func makeProgram(requestedLength uint32) *sbpf.Program {
    hash := sbpf.SymbolHash("sol_get_return_data")
    slots := []sbpf.Slot{
        slot(sbpf.OpMov64Imm, 2, 0, 0, requestedLength),
        slot(sbpf.OpMov64Reg, 3, 1, 0, 0),
        slot(sbpf.OpAdd64Imm, 3, 0, 0, programIDOffset),
        slot(sbpf.OpCall, 0, 0, 0, hash),
        slot(sbpf.OpExit, 0, 0, 0, 0),
    }
    raw := slotsToBytes(slots)
    return &sbpf.Program{
        RO:          raw,
        TextBytes:   raw,
        Text:        slots,
        TextVA:      sbpf.VaddrProgram,
        Entrypoint:  0,
        Funcs:       map[uint32]int64{},
        SbpfVersion: sbpfver.SbpfVersion{Version: sbpfver.SbpfVersionV0},
    }
}

func run(caseName string, requestedLength uint32) (
    input []byte, r0 uint64, count uint64, runErr error,
) {
    program := makeProgram(requestedLength)
    if err := program.Verify(); err != nil {
        panic(fmt.Sprintf("%s verification failed: %v", caseName, err))
    }

    featureSet := features.NewFeaturesDefault()
    syscalls := sbpf.SyscallRegistry(func(hash uint32) (sbpf.Syscall, bool) {
        return sealevel.Syscalls(featureSet, false, hash)
    })
    txCtx := sealevel.NewTransactionCtx(sealevel.TransactionAccounts{}, 1, 1)
    txCtx.SetReturnData(returnProgramID, returnData)
    meter := cu.NewComputeMeter(meterLimit)
    execCtx := &sealevel.ExecutionCtx{
        TransactionContext: txCtx,
        ComputeMeter:       meter,
    }
    input = make([]byte, 128)
    vm := sbpf.NewInterpreter(program, &sbpf.VMOpts{
        HeapMax:        32 * 1024,
        Syscalls:       syscalls,
        MaxCU:          int(meterLimit),
        ComputeMeter:   &execCtx.ComputeMeter,
        Context:        execCtx,
        Input:          input,
        InputDataVaddr: sbpf.VaddrInput,
    })
    defer vm.Finish()

    r0, count, runErr = vm.Run()
    errorLabel := "<nil>"
    if runErr != nil {
        errorLabel = runErr.Error()
        if strings.Contains(errorLabel, "SyscallErrInvalidLength") {
            errorLabel = "SyscallErrInvalidLength"
        }
    }
    fmt.Printf(
        "case=%s verify=accepted r0=%d count=%d err=%s data=%q program_id=%x\n",
        caseName, r0, count, errorLabel,
        input[:requestedLength], input[programIDOffset:programIDOffset+3],
    )
    return
}

func main() {
    if len(returnData) != returnDataSize {
        panic("locked return-data length changed")
    }

    input, r0, count, err := run("equal_length_control", 24)
    if err != nil || r0 != 24 || count != 105 {
        panic("equal-length control did not succeed")
    }
    if !bytes.Equal(input[:24], returnData) {
        panic("equal-length control copied incorrect return data")
    }
    if !bytes.Equal(input[programIDOffset:programIDOffset+3], returnProgramID[:3]) {
        panic("equal-length control copied incorrect program ID")
    }

    input, r0, count, err = run("truncating_read", 16)
    if err == nil || !strings.Contains(err.Error(), "SyscallErrInvalidLength") {
        panic("truncating read did not return SyscallErrInvalidLength")
    }
    if count != 0 {
        panic(fmt.Sprintf("unexpected candidate instruction count: %d", count))
    }
    if !bytes.Equal(input[:16], make([]byte, 16)) {
        panic("candidate unexpectedly reached the prefix copy")
    }
}

Expected output:

case=equal_length_control verify=accepted r0=24 count=105 err=<nil> data="abcdefghijklmnopqrstuvwx" program_id=010203
case=truncating_read verify=accepted r0=0 count=0 err=SyscallErrInvalidLength data="\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" program_id=000000

count is the cuConsumed value returned by Interpreter.Run, not a raw
instruction count. The successful case includes the syscall's 100-CU charge;
the current error return reports zero together with the syscall error.

The first case proves that the verifier, interpreter, syscall registry,
transaction context, memory translation, and output buffers are configured
correctly. The only program difference in the second case is the requested
length. The reference implementations cited above establish that this shorter
destination must receive the 16-byte prefix and the syscall must return 24.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions