Fix signed shape-product wrap in safetensors reader (DoS via NULL deref) - #1875
Fix signed shape-product wrap in safetensors reader (DoS via NULL deref)#1875x14ngch3n wants to merge 3 commits into
Conversation
read_safetensors_file parsed each tensor dimension with
shape[i].get<int64_t>() and stored it directly into ne[] with no range
or overflow check. TensorStorage::nelements() (tensor_storage.h) then
multiplies the dims as a signed int64 product, and read_safetensors_file
uses nbytes() (derived from that product) for its only size check:
tensor_size_ok = (tensor_storage.nbytes() == tensor_data_size);
with tensor_data_size = end - begin, already bounded against the file.
A crafted .safetensors with shape [2^32, 2^32] makes the int64 product
overflow to exactly 0, so nbytes() == 0. Pairing it with an empty,
in-bounds data range (data_offsets [x, x]) makes 0 == 0 pass the size
check, and the tensor is accepted with huge ne[] but nbytes() == 0.
ggml_new_tensor computes its allocation size from the same ne[] and
also wraps to 0, returning a tensor whose data pointer is NULL. The
first consumer access then dereferences NULL -> crash (denial of
service). Only an exact-zero wrap is reachable (a near-wrap to a small
nonzero product is infeasible with IEEE-double-representable dims), so
the impact is a NULL-deref DoS, not a heap out-of-bounds.
Negative dimensions were also accepted at parse time and reached the
int64 product; they happened to be caught by the size check only when
the file range did not coincidentally match.
Fix: validate each parsed dimension (reject <= 0) and compute the
element-count product with __builtin_mul_overflow, rejecting the tensor
up front if any dim is non-positive or the product overflows. This
mirrors the checks the upstream ggml/tensor paths assume hold.
Verified with AddressSanitizer (-O1) harnesses against this reader:
- shape [2^32, 2^32] (was: ACCEPTED -> NULL deref) now REJECTED with
"overflowed/invalid shape product".
- shape [-1, 2] (negative dim) now REJECTED with a clear message.
- legitimate tensor [2, 2] still accepted (nelements=4, no regression).
Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7a03cf0efd
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| int64_t product = 1; | ||
| bool overflow = false; | ||
| for (int i = 0; i < n_dims && !overflow; i++) { | ||
| if (__builtin_mul_overflow(product, ne[i], &product)) { |
There was a problem hiding this comment.
Validate byte-size overflow as well as element-count overflow
A crafted F32 tensor with shape [4611686018427387904] still passes this check because its element count fits in int64_t, but TensorStorage::nbytes() subsequently multiplies it by 4 and wraps to zero. With equal in-bounds data offsets, the size check therefore accepts the tensor and preserves the zero-byte/huge-dimension state that leads to the NULL-buffer dereference this change intends to prevent. Validate the required byte count with overflow detection before constructing the storage.
Useful? React with 👍 / 👎.
| // valid tensor shape; zero is handled by the empty-tensor rules | ||
| // elsewhere and a negative dim would let the int64 shape product | ||
| // wrap, masking the real (huge) element count. | ||
| if (ne[i] <= 0) { |
There was a problem hiding this comment.
Preserve valid empty safetensors tensors
Safetensors permits empty tensors whose shape contains a zero extent and whose data offsets are equal, so rejecting ne[i] == 0 makes an otherwise valid checkpoint or shard fail before ModelLoader can ignore an unused empty entry. Reject negative dimensions and overflowed products, but handle or safely skip zero-sized tensors separately.
Useful? React with 👍 / 👎.
Keep only the invariant note on the n_dims==5 collapse; drop comments that only describe what the code does, per the repo comment policy.
Review feedback on leejet#1875. The element-count product check alone does not cover TensorStorage::nbytes(), which is nelements()*ggml_type_size(type)/ggml_blck_size(type): a tensor whose element count fits in int64_t (e.g. a 2^62-element F32 tensor) overflows only at the byte-size multiply (x4 -> 0), reproducing the zero-size / NULL-buffer path the reader is meant to reject. Add a second overflow check on nelems*ggml_type_size(type) before the storage is constructed. A zero extent is a valid safetensors empty tensor (equal data offsets), so reject only negative dimensions, drop the product<=0 clause, and skip a genuinely empty tensor (nelems == 0) rather than build a zero-byte storage. Co-Authored-By: Claude <noreply@anthropic.com>
|
Thanks for the review — both points addressed in the latest push (4ed4053). P1 (byte-size overflow): Added a second P2 (zero dimensions): Now rejects only negative dimensions ( Builds clean ( |
Summary
read_safetensors_file(src/model_io/safetensors_io.cpp) parses each tensor dimension withshape[i].get<int64_t>()and stores it directly intone[]with no range or overflow check.TensorStorage::nelements()(src/model_io/tensor_storage.h) multiplies the dims as a signedint64product, and the reader usesnbytes()(derived from that product) for its only size check:A crafted
.safetensorswithshape = [4294967296, 4294967296]makes theint64product overflow to exactly 0, sonbytes() == 0. Pairing it with an empty, in-bounds data range (data_offsets [x, x]) makes0 == 0pass the size check, and the tensor is accepted with hugene[]butnbytes() == 0.Impact — ASAN-confirmed NULL deref (DoS)
ggml_new_tensorcomputes its allocation size from the samene[]and also wraps to 0, returning a tensor whosedatapointer is NULL (ggml_nbytes == 0, data == 0x0). The first consumer access then dereferences NULL → crash.Impact scope: denial of service (crash) via a malicious model file. Only an exact-zero wrap is reachable — a near-wrap to a small nonzero product is infeasible with IEEE-double-representable dims (
a·b = N·2^64 + kwith botha,b < 2^53), so the consequence is a NULL-deref crash, not a heap out-of-bounds. Negative dimensions were also accepted at parse time and reached theint64product.The fix
Validate each parsed dimension (reject
<= 0) and compute the element-count product with__builtin_mul_overflow, rejecting the tensor up front if any dim is non-positive or the product overflows. This mirrors the invariants the downstream ggml/tensor paths assume hold.Verification
AddressSanitizer (
-O1) harnesses against this reader:shape = [2^32, 2^32](was: ACCEPTED → NULL deref) now REJECTED — "tensor 't' has an overflowed/invalid shape product".shape = [-1, 2](negative dim) now REJECTED — "dimension 0 is -1".[2, 2]still accepted (nelements=4) — no regression.Repro harness and PoC tensors available on request.