PoC for Huntr Report โ Missing Bounds Check in SurMlFile::from_file() Allows Uncontrolled Memory Allocation from a Malicious .surml File
Target: SurrealML (Surreal) โ github.com/surrealdb/surrealml
CWE: CWE-400 (Uncontrolled Resource Consumption)
Affected: surrealml (Python) / surrealml-core (Rust) up to and including the currently installed surrealml-core 0.1.2 (latest at time of testing), via SurMlFile::from_file().
Threat model
A .surml file is a model artifact that can be shared through model repositories, issue attachments, or other channels a user does not fully control, then loaded locally via the official Python API. This is a local-processing threat, not a remote network attack: the SurrealDB HTTP /ml/import endpoint was checked directly (server/src/ntw/ml.rs in surrealdb/surrealdb) and buffers the full upload before calling SurMlFile::from_bytes(), which is not the affected function -- the HTTP import endpoint is not implicated by this report.
The affected, official public API
from surrealml import SurMlFile
from surrealml.engine import Engine
SurMlFile.load(path="model.surml", engine=Engine.ONNX)
This is the documented way to load a .surml file from local storage in Python. Tracing the call chain confirms it reaches the vulnerable function directly:
SurMlFile.load(path, engine) # surml_file.py
-> RustAdapter.load(path) # rust_adapter.py
-> loader.lib.load_model(path...) # FFI call, passes the raw path
-> SurMlFile::from_file(file_path) # Rust, surml_file.rs -- the affected function
Root cause
The .surml format begins with a 4-byte big-endian u32 giving the length of the header that follows. SurMlFile::from_bytes() (used when the file is already fully loaded into memory as a Vec<u8>) validates this length against the actual buffer size before using it:
// from_bytes() -- validated
let integer_value = u32::from_be_bytes(buffer);
// check to see if there is enough bytes to read
if bytes.len() < (4 + integer_value as usize) {
return Err(SurrealError::new(
"Not enough bytes to read for header, maybe the file format is not correct".to_string(),
SurrealErrorStatus::BadRequest,
));
}
header_bytes.extend_from_slice(&bytes[4..(4 + integer_value as usize)]);
SurMlFile::from_file() (used when loading directly from a file path -- the path taken by the public Python API) reads the same 4-byte length prefix, but allocates a buffer of that size before any validation against the file's actual size:
// from_file() -- unvalidated
let mut buffer = [0u8; 4];
file.read_exact(&mut buffer)?;
let integer_value = u32::from_be_bytes(buffer);
// Read the next integer_value bytes for the header
let mut header_buffer = vec![0u8; integer_value as usize]; // <-- allocated immediately, no size cap
file.read_exact(&mut header_buffer)?; // fails safely here if the file is too short,
// but the allocation has already happened
Why this must be rejected before allocation rather than treated as "the file said so": from_bytes(), in the same project, already treats an oversized claimed length as a BadRequest input-validation failure rather than a legitimate size to honor. from_file() reads the identical field from the identical file format and does not apply the same judgment -- it is a missing check, not an intentional design difference between the two loading paths.
Historical evidence: the project already fixed this exact check once, only in the sibling function
PR #41 ("updating the error handling", surrealml-core 0.1.0 -> 0.1.1) added the bounds check shown above. The full diff to surml_file.rs in that commit:
@@ -80,6 +80,16 @@ impl SurMlFile {
let integer_value = u32::from_be_bytes(buffer);
+ // check to see if there is enough bytes to read
+ if bytes.len() < (4 + integer_value as usize) {
+ return Err(
+ SurrealError::new(
+ "Not enough bytes to read for header, maybe the file format is not correct".to_string(),
+ SurrealErrorStatus::BadRequest
+ )
+ );
+ }
+
header_bytes.extend_from_slice(&bytes[4..(4 + integer_value as usize)]);
@@ -87,7 +97,7 @@ impl SurMlFile {
model_bytes.extend_from_slice(&bytes[(4 + integer_value as usize)..]);
// construct the header and C model from the bytes
- let header = Header::from_bytes(header_bytes).unwrap();
+ let header = Header::from_bytes(header_bytes)?;
This diff touches only from_bytes(). from_file() is not mentioned anywhere in the commit. The equivalent check was never applied there, and it is still absent in the current main branch and in surrealml-core 0.1.2 (verified by reading current source directly and by the test below).
Note: an older public issue, surrealdb/surrealml#20 ("Server shut down if use invalid file on /ml/import", filed against surrealml-core 0.0.3), reported a related-looking out-of-bounds panic. Its exact panic location predates this commit and cannot be mapped with certainty to either function from the issue text alone. This report does not claim to be the same root cause as issue #20 -- it documents, with a direct source/diff comparison, that from_file() specifically lacks the validation from_bytes() has, independent of whichever function issue #20 originally hit.
Reproduction and measurement
pip install surrealml psutil
python test_allocation.py
Output from an actual run:
Wrote 'surml_bomb.surml': 8 bytes on disk, claims a 300000000 byte (286.1 MB) header.
RSS before: 19.1 MB
RuntimeError - modules\core\src\storage\surml_file.rs:125 => failed to fill whole buffer
RSS after: 509.1 MB (delta 489.9 MB)
Summary: an 8-byte file caused approximately 490 MB of additional RSS before failing.
The process survives at this scale: the allocation succeeds, the subsequent read_exact fails because the file has nowhere near the claimed number of bytes, and that failure is returned as a catchable RuntimeError. This report deliberately did not test larger claimed lengths. u32 allows claiming up to ~4 GB; Rust's default behavior on an allocation that cannot be satisfied is to call handle_alloc_error, which aborts the process rather than returning a catchable error. Whether a given claimed length reaches that point depends entirely on the memory available on the host running the code -- this was not tested here to avoid destabilizing the test environment, and no crash is claimed as directly observed. The measured, moderate-scale result above is sufficient to demonstrate the missing validation and its real memory cost.
Windows note (packaging, unrelated to the vulnerability)
On Windows, the published wheel ships c_wrapper.dll, but surrealml/loader.py looks for libc_wrapper.dll (a Unix-style lib prefix applied even for the .dll suffix). Reproducing this PoC on Windows requires copying/renaming the shipped DLL to match:
Copy-Item "<site-packages>\surrealml\c_wrapper.dll" "<site-packages>\surrealml\libc_wrapper.dll"
This is a packaging defect, not part of this security report.
Environment
surrealml==0.0.4 (Python package)
surrealml-core 0.1.2 (bundled native library)
Python 3.13.3
Windows 11 Home 10.0.26200 (Build 26200)
Files in this repository
| File | Purpose |
|---|---|
generate_poc.py |
Builds the 8-byte malicious .surml file. |
test_allocation.py |
The primary PoC: builds the file, loads it via the public API, measures RSS, confirms process survival at the tested scale. |
raw_output.txt |
Unedited console output from running test_allocation.py. |
Suggested fixes
- Add the same bounds check already present in
from_bytes()tofrom_file()before allocatingheader_buffer. - Cap the accepted header length to a reasonable maximum regardless of the claimed value.
- Validate the claimed length against the actual remaining file size (available via
file.metadata()?.len()) before allocating. - Add a regression test loading a small file with an oversized claimed header length via
from_file()specifically (the existingtest_empty_buffertest only coversfrom_bytes()).