| --- |
| tags: |
| - security |
| - model-format-vulnerability |
| - surrealml |
| --- |
| |
| # SurrealML `.surml` header parser: uncaught panics on load (DoS) |
|
|
| Target: https://github.com/surrealdb/surrealml |
| Format: `.surml` (SurrealML's custom model container format) |
| Affected component: `modules/core/src/storage/surml_file.rs` (`SurMlFile::from_file` / `from_bytes`) and `modules/core/src/storage/header/*.rs` |
|
|
| ## Summary |
|
|
| A `.surml` file is `[4-byte BE header length][header string][raw model bytes]`. The header is a |
| `//=>`-delimited string. Several of the header's sub-field parsers use unchecked `unwrap()` / |
| unchecked indexing instead of the crate's own `safe_eject!` / `safe_eject_option!` error-propagation |
| macros used everywhere else in the same file. A single malformed field causes an uncaught Rust panic |
| while loading the file, with no way for the caller to catch it. |
|
|
| The only code path that reaches this parser is `SurMlFile::from_file`, which is called from the |
| `extern "C" fn load_model` in `modules/c-wrapper/src/api/storage/load_model.rs` — the FFI entry point |
| used by the Python client (`ctypes`) and the TypeScript client, and by any host embedding this library |
| (e.g. a database loading a user-supplied `.surml` model). That function has no `catch_unwind` wrapper. |
| A Rust panic is not permitted to unwind across an `extern "C"` boundary, so the runtime aborts the |
| **entire host process** instead of returning an error — there is no exception for the Python/TS caller |
| to catch. |
|
|
| ## Confirmed panic sites |
|
|
| All four were reproduced against the real, unmodified `surrealml-core` crate (compiled from the |
| public repo, no source changes) by calling `SurMlFile::from_file` directly. |
|
|
| | PoC file | Location | Trigger | Panic | |
| |---|---|---|---| |
| | `poc_1_input_dims_no_comma.surml` | `modules/core/src/storage/header/input_dims.rs:36` | `input_dims` field has no comma (e.g. `"5"`) | index out of bounds on `dims[1]` | |
| | `poc_2_input_dims_non_numeric.surml` | `modules/core/src/storage/header/input_dims.rs:34` | `input_dims` field is non-numeric (e.g. `"x,y"`) | `unwrap()` on `ParseIntError` | |
| | `poc_3_origin_no_arrow.surml` | `modules/core/src/storage/header/origin.rs:114-115` | `origin` field is non-empty with no `=>` separator | `unwrap()` on `None` | |
| | `poc_4_output_unknown_normaliser.surml` | `modules/core/src/storage/header/output.rs:73` | output field names a normaliser type that isn't one of the 4 known types | `unwrap()` on `Err("Unknown normaliser type: ...")` | |
|
|
| Each PoC file is 62-97 bytes: an empty/placeholder model payload plus a header string where every |
| field is empty except the one field needed to trigger that specific panic. |
|
|
| ## Reproduction |
|
|
| ```rust |
| // Cargo.toml: surrealml-core = { path = "<repo>/modules/core", default-features = false } |
| use surrealml_core::storage::surml_file::SurMlFile; |
| |
| fn main() { |
| let path = std::env::args().nth(1).unwrap(); |
| let result = std::panic::catch_unwind(|| SurMlFile::from_file(&path)); |
| match result { |
| Ok(Ok(_)) => println!("loaded ok"), |
| Ok(Err(e)) => println!("clean error: {e}"), |
| Err(_) => println!("PROCESS PANIC"), |
| } |
| } |
| ``` |
|
|
| Running this against any of the four PoC files prints a panic originating at the exact file:line |
| listed above, e.g.: |
|
|
| ``` |
| thread 'main' panicked at .../storage/header/output.rs:73:57: |
| called `Result::unwrap()` on an `Err` value: SurrealError { message: "Unknown normaliser type: totally_bogus_normaliser", ... } |
| ``` |
|
|
| Equivalently, from Python, `SurMlFile.load("poc_1_input_dims_no_comma.surml", engine=...)` aborts the |
| interpreter process outright (SIGABRT) rather than raising a catchable `RuntimeError`. |
|
|
| ## Impact |
|
|
| - **Denial of Service.** Any service that loads a `.surml` file supplied by another party — a |
| multi-tenant database accepting model uploads, a marketplace of shared models, a client library |
| loading a downloaded file — can be crashed by a file well under 100 bytes. Because the crash is a |
| process abort at the FFI boundary, it cannot be caught or recovered from by the embedding |
| application; the whole process goes down, not just the load call. |
| - This is a single root cause repeated across independent header sub-parsers (input dims, origin, |
| output/normaliser): missing input validation before `unwrap()`/indexing, in code that otherwise |
| consistently uses the crate's own `safe_eject!` / `safe_eject_option!` macros for exactly this |
| purpose elsewhere in the same files. |
|
|
| ## Suggested fix |
|
|
| Replace the bare `unwrap()` calls and unchecked indexing in `input_dims.rs`, `origin.rs`, and |
| `output.rs` with the same `safe_eject!` / `safe_eject_option!` macros already used throughout |
| `modules/core/src/storage/header/`, so malformed fields produce a `SurrealError` instead of a panic. |
| For `input_dims.rs` specifically, validate `dims.len() == 2` before indexing. |
|
|