tinyb0y's picture
Add PoC files and writeup for .surml header parser panic/DoS
4ce4a77 verified
|
Raw
History Blame Contribute Delete
4.82 kB
---
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.