You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Reported privately as GHSA-2284-h945-8w3c; @zcbenz asked that it be filed publicly instead, so here it is with full detail.
mx.load() on a crafted GGUF file writes past the end of a heap allocation. Confirmed under AddressSanitizer at 21d897d (v0.32.0-189) and as SIGSEGV against mlx==0.32.0 from PyPI. Three crash sites, one per quantized type.
Root cause
Two independent defects that only matter in combination.
1. std::accumulate deduces int — mlx/io/gguf_quants.cpp:125-139
auto w_nbytes = uint32.size() *
std::accumulate(weights_shape.begin(),
weights_shape.end(),
1, // <-- int literal
std::multiplies<size_t>());
...
auto sb_nbytes = float16.size() *
std::accumulate(shape.begin(), shape.end(), 1, std::multiplies<size_t>());
std::accumulate's return type is deduced from init, not from the binary op. The literal 1 is int, so T = int and each intermediate product is assigned back into an int. std::multiplies<size_t> computes a 64-bit product and then it gets truncated on assignment. Both w_nbytes and sb_nbytes are (true product) mod 2^32, scaled by element size.
2. The Shape element count is never checked against the validated byte size
check_tensor_in_file() (mlx/io/gguf.cpp:229-247) validates tensor.offset and tensor.bsize against the file mapping. That check is correct, and it is the only one.
get_shape() (mlx/io/gguf.cpp:49-57) separately narrows each 64-bit file dimension into 32-bit ShapeElem with no range or sign check:
for (int i = tensor.ndim - 1; i >= 0; i--) {
shape.push_back(tensor.dim[i]); // uint64 -> int32
}
So the element count exists in two widths, derived from the same file bytes, never compared. Choose dimensions whose 64-bit product wraps small — bsize is then small and the bounds check passes — while the low 32 bits stay large, so the Shape still describes billions of elements.
3. The write
The extractors loop on array size, not on the validated byte size (gguf_quants.cpp:88-100, same shape at :45 and :67):
for (int64_t i = 0; i < scales_arr.size(); i++) {
uint8_t* block_data = data + i * bytes_per_block;
scales[i] = *((float16_t*)block_data);
...
}
scales_arr.size() is the true size_t product. The buffers were sized by the truncated value. unpack_32_4 overruns weights the same way.
quantity
value
scales_arr.size() (loop trip count)
34,359,771,136
sb_nbytes as computed
65,536
sb_nbytes computed correctly
68,719,542,272
Reproduction
No build needed. This 96-byte file drives num_weights to exactly 0, so bsize is 0 and the allocation is 0 bytes, against a 2^59-iteration write loop:
Changing the init to size_t{1} alone makes the (now correct) 68 GB allocation fail, and allocator::Buffer::raw_ptr() at mlx/backend/no_gpu/allocator.cpp:174-179 returns nullptr unchecked. You get a null-pointer write SEGV at gguf_quants.cpp:97 instead. I verified this.
A complete fix needs:
std::accumulate(..., size_t{1}, std::multiplies<size_t>()) in both places, with checked 64-bit arithmetic (__builtin_mul_overflow or equivalent).
Reject dimensions that are non-positive or exceed int32 range before narrowing in get_shape().
Cross-check the shape-derived element count against tensor.num_weights, and the extractors' read span (blocks * bytes_per_block) against tensor.bsize. This is the one that actually closes it — without it the two widths can always be made to disagree.
Check allocator::malloc results before use.
For reference on the fix shape, llama.cpp hardened the equivalent arithmetic in its own GGUF parser across CVE-2025-53630, CVE-2026-27940, and CVE-2026-33298 — the last of those is the same pattern as this one (size calculation wraps to a small value while the code still iterates the full tensor dimensions).
Related, lower severity
Reporting these here so they can be fixed in the same pass.
Unchecked integer overflow in the safetensors bounds check — mlx/io/safetensors.cpp:186: if (offset + data_offsets[1] > file_size) wraps. A header with shape:[-82], dtype:"U8", data_offsets:[0, 18446744073709551534] is accepted and the array reports size = 18446744073709551534. expected_nbytes at :170-173 also wraps via static_cast<size_t>(dim) on a negative int32. Reads go through pread, which is bounds-safe, so impact looks limited to DoS.
Negative dimensions survive safetensors validation — shape:[-1,-1] yields an accepted array with size() == 1 and negative strides. I exercised add/sum/astype/transpose/reshape/concatenate without producing an OOB.
gguflib header parsing is unbounded — gguf_get_key and gguf_set_data_offset add file-supplied string lengths to ctx->off and dereference without comparing against ctx->size. PR Bound GGUF tensor data offsets against the file mapping #4179 bounded tensor data offsets only.
Environment
mlx 0.32.0 from PyPI; ml-explore/mlx at 21d897d for the ASAN build. macOS 26.6.1 (Darwin 25.6.0), arm64, Apple clang. Build: cmake -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_FLAGS="-fsanitize=address -g" -DMLX_BUILD_METAL=OFF.
Present in v0.29.3, v0.30.0, v0.31.0, v0.32.0 and on main. Not bisected earlier than v0.29.3.
Happy to send a PR for the fix list above if that's useful.
Reported privately as GHSA-2284-h945-8w3c; @zcbenz asked that it be filed publicly instead, so here it is with full detail.
mx.load()on a crafted GGUF file writes past the end of a heap allocation. Confirmed under AddressSanitizer at21d897d(v0.32.0-189) and as SIGSEGV againstmlx==0.32.0from PyPI. Three crash sites, one per quantized type.Root cause
Two independent defects that only matter in combination.
1.
std::accumulatededucesint—mlx/io/gguf_quants.cpp:125-139std::accumulate's return type is deduced frominit, not from the binary op. The literal1isint, soT = intand each intermediate product is assigned back into anint.std::multiplies<size_t>computes a 64-bit product and then it gets truncated on assignment. Bothw_nbytesandsb_nbytesare(true product) mod 2^32, scaled by element size.2. The
Shapeelement count is never checked against the validated byte sizecheck_tensor_in_file()(mlx/io/gguf.cpp:229-247) validatestensor.offsetandtensor.bsizeagainst the file mapping. That check is correct, and it is the only one.get_shape()(mlx/io/gguf.cpp:49-57) separately narrows each 64-bit file dimension into 32-bitShapeElemwith no range or sign check:So the element count exists in two widths, derived from the same file bytes, never compared. Choose dimensions whose 64-bit product wraps small —
bsizeis then small and the bounds check passes — while the low 32 bits stay large, so theShapestill describes billions of elements.3. The write
The extractors loop on array size, not on the validated byte size (
gguf_quants.cpp:88-100, same shape at:45and:67):scales_arr.size()is the truesize_tproduct. The buffers were sized by the truncated value.unpack_32_4overrunsweightsthe same way.scales_arr.size()(loop trip count)sb_nbytesas computedsb_nbytescomputed correctlyReproduction
No build needed. This 96-byte file drives
num_weightsto exactly 0, sobsizeis 0 and the allocation is 0 bytes, against a 2^59-iteration write loop:sha256(poc.gguf) = e8fe5e2c84854df04364e26f396c7cd53b5e7d6014ff66c3e7e44e65d78d81a6ASAN at HEAD
extract_q4_0_data(:45) andextract_q4_1_data(:67) give the same overflow.PoC generator
poc_gen.py— builds all four test files, no dependenciesThe one-line fix is not sufficient
Changing the init to
size_t{1}alone makes the (now correct) 68 GB allocation fail, andallocator::Buffer::raw_ptr()atmlx/backend/no_gpu/allocator.cpp:174-179returnsnullptrunchecked. You get a null-pointer write SEGV atgguf_quants.cpp:97instead. I verified this.A complete fix needs:
std::accumulate(..., size_t{1}, std::multiplies<size_t>())in both places, with checked 64-bit arithmetic (__builtin_mul_overflowor equivalent).ndim == 0—gguf_quants.cpp:116indexesshape[shape.size() - 1], and gguflib'sassert(ndim <= 8)permits 0.int32range before narrowing inget_shape().tensor.num_weights, and the extractors' read span (blocks * bytes_per_block) againsttensor.bsize. This is the one that actually closes it — without it the two widths can always be made to disagree.allocator::mallocresults before use.For reference on the fix shape, llama.cpp hardened the equivalent arithmetic in its own GGUF parser across CVE-2025-53630, CVE-2026-27940, and CVE-2026-33298 — the last of those is the same pattern as this one (size calculation wraps to a small value while the code still iterates the full tensor dimensions).
Related, lower severity
Reporting these here so they can be fixed in the same pass.
mlx/io/safetensors.cpp:186:if (offset + data_offsets[1] > file_size)wraps. A header withshape:[-82],dtype:"U8",data_offsets:[0, 18446744073709551534]is accepted and the array reportssize = 18446744073709551534.expected_nbytesat:170-173also wraps viastatic_cast<size_t>(dim)on a negativeint32. Reads go throughpread, which is bounds-safe, so impact looks limited to DoS.shape:[-1,-1]yields an accepted array withsize() == 1and negative strides. I exercisedadd/sum/astype/transpose/reshape/concatenatewithout producing an OOB.gguf_get_keyandgguf_set_data_offsetadd file-supplied string lengths toctx->offand dereference without comparing againstctx->size. PR Bound GGUF tensor data offsets against the file mapping #4179 bounded tensor data offsets only.Environment
mlx0.32.0 from PyPI;ml-explore/mlxat21d897dfor the ASAN build. macOS 26.6.1 (Darwin 25.6.0), arm64, Apple clang. Build:cmake -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_FLAGS="-fsanitize=address -g" -DMLX_BUILD_METAL=OFF.Present in v0.29.3, v0.30.0, v0.31.0, v0.32.0 and on
main. Not bisected earlier than v0.29.3.Happy to send a PR for the fix list above if that's useful.