Loading...
Skip to main content

CVE-2026-55093 — tract-nnef: integer overflow in tract-nnef — CVE...

MEDIUM

tract-nnef: integer overflow in NNEF `.dat` tensor parser yields an out-of-bounds read on model load

Published June 18, 2026Updated September 10, 2026Source: osv

Summary

- **Component:** `tract-nnef` (`nnef/src/tensors.rs::read_tensor`) + `tract-data` (`data/src/tensor.rs`) - **Affected versions:** `< 0.21.16`, `0.22.0`–`0.22.2`, `0.23.0`–`0.23.1` — the dense `DatLoader` path was unguarded across all three release lines; patched in 0.21.16 / 0.22.2 / 0.23.1 - **Class:** CWE-190 (integer overflow) → CWE-125 (out-of-bounds read) - **Trigger:** loading a crafted NNEF model archive (`*.nnef.tgz` / `*.nnef.tar` / dir) via the public `tract_nnef::nnef().model_for_path` / `model_for_read` - **Impact:** `read_tensor` returns a memory-unsafe tensor (reported `len` 2^61 over a 56-byte heap allocation). Always-on primitive: a **bounded heap out-of-bounds read** during model build (`as_uniform`), an adjacent-heap information-disclosure reachable via the public load API. The resulting slice is an unsound `from_raw_parts(ptr, 2^61)` that **SIGSEGVs (DoS)** on any access past the mapped region (demonstrated by direct access). No out-of-bounds write and no RCE were achieved — tract's const-folding/`as_uniform` fast-paths fold simple consuming graphs without the full read. - **Severity:** Medium ## Summary `read_tensor` builds a tensor `shape` from attacker-controlled 32-bit dimensions and computes the element count `len = product(shape)` and the byte allocation `product(shape) * size_of(dt)` with **unchecked `usize` arithmetic**. In `--release` (no `overflow-checks`), both products wrap modulo 2^64. An attacker chooses dimensions so that the wrapped products collapse to a small value that satisfies the header consistency check, while the *true* element count remains astronomically large. `read_tensor` returns `Ok` with a `Tensor` whose reported `len` (e.g. 2^61+7) is far larger than its backing heap allocation (e.g. 56 bytes). The unchecked slice accessor `as_slice_unchecked` (`from_raw_parts(ptr, self.len)`) then produces a slice spanning ~18 exabytes over a 56-byte buffer. The out-of-bounds read fires automatically during model build (no inference required), reachable through the default `DatLoader` resource loader. ## Root cause `nnef/src/tensors.rs`, `read_tensor`: ``` let shape: TVec<usize> = header.dims[0..header.rank as usize].iter().map(|d| *d as _).collect(); let len = shape.iter().product::<usize>(); // (1) unchecked, wraps ... } else if header.bits_per_item != u32::MAX && len * (header.bits_per_item as usize / 8) != header.data_size_bytes as usize // (2) wrapped == u32 { bail!(...); } ... let mut tensor = unsafe { Tensor::uninitialized_dt(dt, &shape)? }; // (3) alloc off the same wrapped product ... reader.read_exact(plain.as_bytes_mut())?; // storage-bounded read, no overflow here Ok(tensor) ``` `data/src/tensor.rs`, `uninitialized_aligned_dt`: ``` let bytes = shape.iter().cloned().product::<usize>() * dt.size_of(); // (3) wraps to the same small value let storage = ... Blob::new_for_size_and_align(bytes, alignment) ...; ... tensor.update_strides_and_len(); // len = product(shape), wraps, no clamp ``` The three quantities — the consistency-check LHS `(2)`, the allocation `(3)`, and the reported `len` — are all the same wrapped `product(shape)*size_of`, so they stay mutually consistent and **the consistency check at `(2)` cannot catch the overflow**. `data_size_bytes` is a `u32`, so the attacker simply sets it to the wrapped value. Corruption sink — `data/src/tensor.rs::as_slice_unchecked` (and `data/src/tensor/plain_view.rs::as_slice_unchecked`): ``` if self.storage.byte_len() == 0 { &[] } else { std::slice::from_raw_parts(self.as_ptr_unchecked(), self.len()) } // len = 2^61 over a 56-byte alloc ``` The only guard is `byte_len() == 0`. A small **non-zero** allocation defeats it and yields an unsound oversized slice. ## Witness (F64) ``` dims = [33955849, 7005787, 359, 3, 3, 3] (rank 6, each <= u32::MAX) product(shape)= 2_305_843_009_213_693_959 = 2^61 + 7 bits_per_item = 64 (F64), item_type = 0, item_type_vendor = 0 data_size_bytes = 56 # == (2^61+7)*8 mod 2^64 ``` - `len * (bits/8) mod 2^64 = (2^61+7)*8 mod 2^64 = 56 == data_size_bytes` → consistency check passes. - allocation = `(2^61+7)*8 mod 2^64 = 56` bytes (7 × F64). - reported `len` = `2^61+7` elements. Only the `is_copy()` numeric arms (F16/F32/F64/int, and likely the `complex` arms) are exploitable. F64 is the cleanest (`bits/8` divides evenly). The `bool`, `String`, and block-quant paths are each guarded by an independent mechanism (size_of==1 prevents byte/element divergence; `String` bails on a missing `num_traits::Zero` impl; block-quant has its own `ensure!(expected_len == data_size_bytes)` and uses non-plain `Exotic` storage). ## Reachability (load-time, public API) ``` nnef().model_for_read(tar) -> proto_model_for_read nnef/src/framework.rs:303 -> DatLoader.try_load (any *.dat) nnef/src/resource.rs:97 (default loader, framework.rs:33) -> read_tensor -> Ok(Tensor{len=2^61+7, storage=56B}) nnef/src/tensors.rs:61 -> into_typed_model -> variable() fragment nnef/src/ops/nnef/deser.rs:74 ensure!(tensor.shape() == &*shape) deser.rs:122 (attacker matches shape in graph.nnef -> passes) -> Const::new -> wire_node core/src/model/typed.rs:67 -> Const::output_facts core/src/ops/konst.rs:54 -> TypedFact::try_from core/src/model/fact.rs:459 -> Tensor::as_uniform -> is_uniform_t::<f64> data/src/tensor.rs:1099 -> as_slice_unchecked::<f64> data/src/tensor.rs:1044 -> from_raw_parts(ptr, 2^61+7) over 56-byte buffer -> OOB READ ``` No shape-vs-storage re-validation exists anywhere on this path (`proto.validate()` checks only the AST; `Const::new` checks only `is_plain`; `check_for_access` checks only the datum type; even the *safe* `PlainView::as_slice` does `from_raw_parts(ptr, self.len)` with no length guard). ## Execution (proof of concept) Reproduced against the crate at the affected revision, `--release`, x86_64-linux. Three scenarios: 1. **Direct `read_tensor`** — feed the crafted 128-byte header + 56-byte payload: - `read_tensor -> Ok`, `shape=[33955849,7005787,359,3,3,3]`, `len()=2305843009213693959`, `as_bytes().len()=56`, `as_slice::<f64>().len()=2305843009213693959`. - `s[7]` (first element past the 56-byte allocation) returns `0x0000000000000041` → **heap OOB read** (adjacent-heap disclosure). - `s[1<<40]` → **SIGSEGV** (signal 11). 2. **Public load API** — build a malicious `.nnef.tar` (`graph.nnef` with `variable(label='weights', shape=[...])` + `weights.dat`) and call `nnef().model_for_read()`: - returns `Ok` with one `Const` node, `out[0].fact.uniform=Some(...)`, `len()=2305843009213693959` over a 56-byte buffer → confirms `as_uniform`/`is_uniform_t`/`as_slice_unchecked` performed an **OOB read on load** (bounded over-read here because `is_uniform`'s `.all()` short-circuits on the uniform `0x41` payload). 3. **Optimized graph** — same archive but the const is consumed (`output = mul(weights, weights)`), then `into_optimized` / `run`: - **Does not crash.** With both a uniform (`0x41×56`) and a non-uniform (`0..56`) payload, `into_optimized` const-folds `mul(const, const)` to a single node **without a full-length materialization** of the oversized const, and `run` completes. A reliable arbitrary-length crash through a *normal optimized graph* was therefore NOT demonstrated; the always-on primitive is the bounded load-time over-read (scenario 2), and the wild-slice SIGSEGV is shown via direct access (scenario 1). Runnable PoC sources are available to the maintainers on request. ## Detection - **Static:** flag `*.iter().product::<usize>()` over externally-controlled dimensions without `checked_*`/`try_into`, especially when the result feeds an allocation and a separately-tracked `len`. - **Runtime / fleet:** crash telemetry showing SIGSEGV inside `is_uniform_t` / `from_raw_parts` during NNEF model load; an ASAN build flags `heap-buffer-overflow READ` in `read_tensor`→`as_uniform`. - **Input filter (compensating):** reject NNEF `.dat` tensors where `product(dims)` overflows `u64`, or where `product(dims) * size_of(dt) != data_size_bytes` computed in **checked** arithmetic, before constructing the tensor. - **YARA-ish heuristic for `.dat` blobs:** NNEF magic `4E EF 01 00`, `rank<=8`, and any `dim >= 0x10000` whose checked product with the others overflows. ## Mitigation (suggested fix) In `read_tensor`, compute the element count and byte size with checked arithmetic and reject on overflow, mirroring the guard already present on the block-quant path (`ensure!(expected_len == data_size_bytes)` added in `eacd13ccb`): ``` let len = shape.iter().try_fold(1usize, |a, &d| a.checked_mul(d)) .context("tensor shape product overflows usize")?; let byte_size = len.checked_mul(dt.size_of()) .context("tensor byte size overflows usize")?; ensure!(byte_size == header.data_size_bytes as usize, "shape/len vs data_size_bytes mismatch"); ``` Defense in depth: make `Tensor::uninitialized_aligned_dt` reject when `product(shape)*size_of` overflows, and add a `len * size_of == storage.byte_len()` invariant check in the `as_slice*` accessors (or at `Tensor` construction) so a `len`/storage mismatch can never reach `from_raw_parts`. Mapping: CWE-190, CWE-125; mitigations align with input validation (OWASP ASVS V5) and safe integer handling (CERT INT32-C analogue). ## Prior art / why this is not already fixed - `eacd13ccb` (2026-03-23, "Add blob-size validation to BlockQuantStorage constructors") added overflow/blob-size validation **only to the block-quant path**; the dense `DatLoader`/`read_tensor` path was left unguarded. The maintainers fixed the sibling and missed this one. - PR #745 ("Fix UB by creating uninit Tensors with a non-null pointer") is a *different* UB (null base pointer on zero-length slices) in the same module family. - No CVE / RustSec / GHSA / OSV / Huntr entry matches this bug; last change to `nnef/src/tensors.rs` predates HEAD and added no overflow guard to the dense path. --- Reported by: s1ko (s1ko@riseup.net · github.com/s1ko)

Advisory Timeline

  • First published June 18, 2026 (116 days ago).
  • Last revised September 10, 2026 — 84 days after initial publication.

Remediation

Upgrade to the fixed version using your package manager.

Cargo
Update tract-nnef to 0.23.1 or later
cargo update -p tract-nnef --precise 0.23.1
Cargo
Update tract-nnef to 0.21.16 or later
cargo update -p tract-nnef --precise 0.21.16
Cargo
Update tract-nnef to 0.22.2 or later
cargo update -p tract-nnef --precise 0.22.2

After upgrading, run your dependency scanner again to confirm the vulnerability is resolved.

Affected Packages (3)

Affects 1 ecosystem: crates.io (1).

PackageEcosystemAffectedFixed In
tract-nnef
crates.io
All versions0.23.1
tract-nnef
crates.io
All versions0.21.16
tract-nnef
crates.io
All versions0.22.2

Vulnerability Classification

Common Weakness Enumeration (CWE) identifiers for this vulnerability type.

  • CWE-125
    Out-of-bounds ReadMITRE
  • CWE-190
    Integer OverflowMITRE

CVSS Score Breakdown

What the CVSS (Common Vulnerability Scoring System) 6.1 score means for each attack dimension.

Attack Vector
Local
Attack Complexity
Low
Privileges Required
None
User Interaction
Required
Scope
Unchanged
Confidentiality
Low
Integrity
None
Availability
High

CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:H

Frequently Asked Questions

What is CVE-2026-55093?
tract-nnef: integer overflow in NNEF `.dat` tensor parser yields an out-of-bounds read on model load This vulnerability has been assigned a severity rating of MEDIUM (CVSS score: 6.1/10).
How do I check if my project is affected by CVE-2026-55093?
CVE-2026-55093 affects tract-nnef. Use GeekWala's free vulnerability scanner to check your dependencies against CVE-2026-55093 and 200,000+ other known vulnerabilities.

Severity & Exploitability

CVSS Score
6.1

Exploitation requires specific conditions or has limited impact. Remediate within weeks.

Also Known As

GHSA-x5mv-8wgw-29hg
RUSTSEC-2026-0217

Related CVEs

  • CVE-2026-102821
    MEDIUM

    Russh: Unbounded memory exhaustion via CHANNEL_OPEN flood during a client-stalled rekey

  • CVE-2026-102820
    MEDIUM

    pageant: Out-of-bounds read / oversized allocation in `pageant` MemoryMap::read via a malicious Pageant agent (Windows)

Check if you're affected

Scan your dependencies to see if this vulnerability affects your projects.

Scan Your Dependencies