Skip to content

Bound GGUF metadata string/array values against the file mapping - #4212

Open
x14ngch3n wants to merge 2 commits into
ml-explore:mainfrom
x14ngch3n:fix/gguf-metadata-oob
Open

Bound GGUF metadata string/array values against the file mapping#4212
x14ngch3n wants to merge 2 commits into
ml-explore:mainfrom
x14ngch3n:fix/gguf-metadata-oob

Conversation

@x14ngch3n

Copy link
Copy Markdown

Summary

Bounds the length of STRING and ARRAY metadata KV values against the mmap'd file mapping in load_gguf, mirroring the existing check_tensor_in_file() guard that protects the tensor data path. A crafted GGUF file could otherwise force an out-of-bounds read past the file mapping.

Problem

gguf_get_key() returns a pointer (val) into the mmap'd file but performs no bounds checking, and the metadata value lengths are read straight from the file. In set_mx_value_from_gguf, these attacker-controlled lengths were fed directly to a copy/string constructor with no check against the mapping size:

STRING KV:

value = std::string(val->string.string, static_cast<int>(val->string.len));

val->string.len is a uint64_t from the file, narrowed to int, then std::string(ptr, n) memmoves n bytes out of the mmap.

ARRAY KV:

auto size = static_cast<int>(val->array.len);
...
value = array(reinterpret_cast<uint32_t*>(data), {size}, uint32);

array(T*, Shape, Dtype) allocates size*itemsize and std::copys size elements from the mmap — a pure source over-read.

The tensor path was already bounded by check_tensor_in_file() (added in #4179 / fixes #4136), but the metadata path had no equivalent guard.

Impact

Out-of-bounds read past the mmap'd GGUF file when loading an untrusted .gguf. Confirmed with AddressSanitizer (4 MB read on a ~50-byte file, both sinks). In a non-ASAN process the read crosses into an unmapped page → SEGV (denial of service). No write primitive (the allocation is derived from the same len, so it is a source over-read, not an over-write).

Reachable from the default-config public Python API on every platform mlx ships:

import mlx.core as mx
mx.load("evil.gguf", format="gguf")

MLX_BUILD_GGUF is ON by default.

Fix

Adds check_metadata_value_in_file(ctx, val, value_bytes), which verifies the value region lies within [0, ctx->size). Each metadata branch now bounds its value length against the mapping before any copy/string construction:

  • STRING: rejects len that doesn't fit in an int or exceeds ctx->size, then bounds sizeof(gguf_string) + len.
  • ARRAY: computes elt_size from the declared element type and checks arr_len * elt_size <= remaining (overflow-safe division form) before the element loop.
  • STRING array elements: each inner str_val->len is bounded individually.

The static_cast<int> narrowing is also guarded so a length above INT_MAX is rejected explicitly rather than wrapping to a negative int.

Testing

Built with clang -O1 -g -fsanitize=address -fno-omit-frame-pointer (LLVM clang, since libmlx is built with it). Three crafted GGUFs (over-long STRING, over-long UINT32 ARRAY, and STRING with len > INT_MAX):

Before (on main), STRING sink:

==95370==ERROR: AddressSanitizer: unknown-crash on address 0x000103ab0000
READ of size 4194304 at 0x000103ab0000 thread T0
    #6 mlx::core::set_mx_value_from_gguf(...) gguf.cpp:128
    #7 mlx::core::load_metadata(gguf_ctx*) gguf.cpp:209
    #8 mlx::core::load_gguf(...) gguf.cpp:279

After (this branch): all three crafted files are rejected cleanly:

exception: [load_gguf] String metadata value length exceeds file size.

no ASAN violation, process exits 0/1 via exception.

No regression: a legitimate roundtrip (save_gguf of a small float tensor + a STRING and an INT32 ARRAY metadata value, then load_gguf) still loads correctly with the new bounds in place.

Prior art

Distinct from #4136/#4179 (tensor data offset/bsize — tensor only), #3436 (gguflib -UNDEBUG asserts — mlx bypasses gguf_do_with_value), and CVE-2025-62609 (different sink). None of these bound the metadata KV value length.

The tensor load path validates offset and byte size against the mmap'd
file (check_tensor_in_file, ml-explore#4179). The metadata path did not:
set_mx_value_from_gguf read val->string.len / val->array.len straight
from the file and passed them to std::string / array construction, so a
crafted STRING or ARRAY metadata value could claim a length far larger
than the file and force a read past the mapping (out-of-bounds read,
SEGV / potential memory disclosure).

gguf_get_key() performs no bounds checking of its own, and mlx does not
use gguflib's bounded gguf_do_with_value walk, so nothing else caught
this. Distinct from ml-explore#4136/ml-explore#4179 (tensor data offset), ml-explore#3436 (gguflib
asserts), and CVE-2025-62609.

Add check_metadata_value_in_file() mirroring check_tensor_in_file, and
bound each STRING and ARRAY metadata value (including each element of a
string array) against the mapping before any copy. Lengths that would
narrow badly to int are rejected before the static_cast.

Reproduced under AddressSanitizer on main (4 MB over-read on a ~50-byte
file at gguf.cpp:128 STRING and :155 ARRAY); after this change the same
PoCs throw cleanly and a normal save/load round trip still succeeds.

Co-Authored-By: Claude <noreply@anthropic.com>

@zcbenz zcbenz left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you do simple check in load_metadata like what #4179 did?

Move check_metadata_value_in_file() out of set_mx_value_from_gguf and call
it once per key in load_metadata(), mirroring check_tensor_in_file() on the
tensor path (ml-explore#4179). set_mx_value_from_gguf is back to reading value
lengths straight from the file; all STRING/ARRAY bounds checking (fixed
scalars, length-prefixed strings, fixed-size arrays, and each element of a
string array) now lives in a single validator invoked before the value is
consumed. Lengths that would not fit in the int the downstream array() /
std::string constructors take are rejected there too.

Adds "test gguf metadata value validation" covering valid empty/small
strings plus OOB string, far-past-end string, fixed-size array, and string
array element cases (ASAN, -O1).

Co-Authored-By: Claude <noreply@anthropic.com>
@x14ngch3n

Copy link
Copy Markdown
Author

Thanks @zcbenz. Done in the latest push: the bounds check now lives in a single check_metadata_value_in_file(ctx, type, val) call inside load_metadata() (one call per key, before set_mx_value_from_gguf consumes it), mirroring check_tensor_in_file() on the tensor path. set_mx_value_from_gguf is back to reading lengths straight from the file — the STRING/ARRAY validation (fixed scalars, length-prefixed strings, fixed-size arrays, and each element of a string array) is centralized in the validator. Added "test gguf metadata value validation" next to the tensor-offset test. All four gguf cases pass under ASAN (-O1).

Comment thread mlx/io/gguf.cpp
// gguf_string = { uint64_t len; char string[] }.
if (type == GGUF_VALUE_TYPE_STRING) {
if (sizeof(uint64_t) > avail(base) ||
val->string.len > static_cast<uint64_t>(std::numeric_limits<int>::max()) ||

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

feels meaningless to test int_max?

Comment thread mlx/io/gguf.cpp
break;
case GGUF_VALUE_TYPE_UINT64:
case GGUF_VALUE_TYPE_INT64:
case GGUF_VALUE_TYPE_FLOAT64:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you deduplicate the code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OOB read in the GGUF loader: tensor data offset and size are not bounded against the file mapping

2 participants