Bound GGUF metadata string/array values against the file mapping - #4212
Open
x14ngch3n wants to merge 2 commits into
Open
Bound GGUF metadata string/array values against the file mapping#4212x14ngch3n wants to merge 2 commits into
x14ngch3n wants to merge 2 commits into
Conversation
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>
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>
Author
|
Thanks @zcbenz. Done in the latest push: the bounds check now lives in a single |
zcbenz
reviewed
Aug 13, 2026
| // 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()) || |
Member
There was a problem hiding this comment.
feels meaningless to test int_max?
zcbenz
reviewed
Aug 13, 2026
| break; | ||
| case GGUF_VALUE_TYPE_UINT64: | ||
| case GGUF_VALUE_TYPE_INT64: | ||
| case GGUF_VALUE_TYPE_FLOAT64: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Bounds the length of
STRINGandARRAYmetadata KV values against the mmap'd file mapping inload_gguf, mirroring the existingcheck_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. Inset_mx_value_from_gguf, these attacker-controlled lengths were fed directly to a copy/string constructor with no check against the mapping size:STRINGKV:val->string.lenis auint64_tfrom the file, narrowed toint, thenstd::string(ptr, n)memmovesnbytes out of the mmap.ARRAYKV:array(T*, Shape, Dtype)allocatessize*itemsizeandstd::copyssizeelements 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 samelen, so it is a source over-read, not an over-write).Reachable from the default-config public Python API on every platform mlx ships:
MLX_BUILD_GGUFisONby 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:lenthat doesn't fit in anintor exceedsctx->size, then boundssizeof(gguf_string) + len.elt_sizefrom the declared element type and checksarr_len * elt_size <= remaining(overflow-safe division form) before the element loop.str_val->lenis bounded individually.The
static_cast<int>narrowing is also guarded so a length aboveINT_MAXis rejected explicitly rather than wrapping to a negativeint.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 withlen>INT_MAX):Before (on
main),STRINGsink:After (this branch): all three crafted files are rejected cleanly:
no ASAN violation, process exits 0/1 via exception.
No regression: a legitimate roundtrip (
save_ggufof a small float tensor + a STRING and an INT32 ARRAY metadata value, thenload_gguf) still loads correctly with the new bounds in place.Prior art
Distinct from #4136/#4179 (tensor data offset/bsize — tensor only), #3436 (gguflib
-UNDEBUGasserts — mlx bypassesgguf_do_with_value), and CVE-2025-62609 (different sink). None of these bound the metadata KV value length.