Skip to main content

tract-nnef EUVDEUVD-2026-77792

| CVE-2026-55093 MEDIUM
Out-of-bounds Read (CWE-125)
2026-06-18 https://github.com/sonos/tract GHSA-x5mv-8wgw-29hg
6.1
CVSS 3.1 · Vendor: https://github.com/sonos/tract
Share

Severity by source

Vendor (https://github.com/sonos/tract) PRIMARY
6.1 MEDIUM
AV:L/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:H
vuln.today AI
6.1 MEDIUM

Local vector because a file must be loaded by the target process; UI:R because an operator or pipeline must invoke model loading; C:L for bounded adjacent-heap read; A:H for reliable load-time SIGSEGV; no integrity impact demonstrated.

3.1 AV:L/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:H
4.0 AV:L/AC:L/AT:N/PR:N/UI:A/VC:L/VI:N/VA:H/SC:N/SI:N/SA:N

Primary rating from Vendor (https://github.com/sonos/tract).

CVSS VectorVendor: https://github.com/sonos/tract

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

Lifecycle Timeline

3
Source Code Evidence Fetched
Jun 18, 2026 - 16:05 vuln.today
Analysis Generated
Jun 18, 2026 - 16:05 vuln.today
CVE Published
Jun 18, 2026 - 15:05 github-advisory
MEDIUM 6.1

DescriptionCVE.org

  • 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_of1 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 0x0000000000000041heap OOB read (adjacent-heap disclosure).
  • s[1<<40]SIGSEGV (signal 11).
  1. 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).
  1. 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_tensoras_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)

AnalysisAI

Integer overflow in tract-nnef's NNEF .dat tensor parser allows loading a crafted model archive to produce a Tensor whose reported element count (e.g. 2^61+7) vastly exceeds its 56-byte heap allocation, causing a bounded heap out-of-bounds read during model build and a reliable SIGSEGV (DoS) on any access past the mapped region. Affected are all applications using tract-nnef < 0.21.16, 0.22.0-0.22.2, or 0.23.0-0.23.1 that load attacker-controlled NNEF model archives via the public model_for_path / model_for_read API. No public exploit is available at time of analysis, though the reporter demonstrated the issue against affected crate versions; no CISA KEV entry exists.

Technical ContextAI

The affected crates are tract-nnef (nnef/src/tensors.rs, read_tensor) and tract-data (data/src/tensor.rs), published by Sonos under the tract neural-network inference framework for Rust (pkg:rust/tract-nnef). The root cause is CWE-190 (integer overflow): read_tensor computes the element count len = shape.iter().product::<usize>() and the allocation size len * size_of(dt) using unchecked Rust usize arithmetic. In --release builds (where Rust omits overflow checks by default), both products silently wrap modulo 2^64. The attacker selects tensor dimensions whose true product vastly overflows usize but whose wrapped product equals the small data_size_bytes field (a u32 in the file header), so the existing consistency check at step (2) cannot detect the overflow - all three quantities (consistency-check LHS, allocation size, and reported len) are derived from the same wrapped product and remain mutually consistent. The CWE-125 (out-of-bounds read) consequence follows from as_slice_unchecked calling std::slice::from_raw_parts(ptr, self.len()) where self.len() is the unreduced 2^61+7 over a 56-byte allocation. The only guard in as_slice_unchecked - if byte_len() == 0 { return &[] } - is defeated by any non-zero allocation. The flaw is specific to the dense DatLoader path; the block-quant path received a comparable guard in commit eacd13ccb (2026-03-23) but the dense path was left unguarded, making this a missed-sibling fix.

RemediationAI

Upgrade tract-nnef to the patched releases: 0.21.16 (for the 0.21.x line), 0.22.2 (for the 0.22.x line), or 0.23.1 (for the 0.23.x line), as confirmed by the GitHub Security Advisory at https://github.com/sonos/tract/security/advisories/GHSA-x5mv-8wgw-29hg. The fix replaces the unchecked product::<usize>() with checked arithmetic (try_fold + checked_mul) in read_tensor and adds a post-overflow guard on the allocation path in Tensor::uninitialized_aligned_dt. If an immediate upgrade is not feasible, the following compensating controls can be applied at the application layer: (1) Validate all incoming NNEF .dat tensor headers before passing to tract-nnef - specifically, reject any tensor where product(dims) overflows u64 or where product(dims) * size_of(dt) in checked arithmetic does not equal data_size_bytes; this blocks the attack at the boundary without modifying the crate. Trade-off: requires application-level header parsing. (2) Restrict the model loading pipeline to trusted, internally-signed NNEF archives only, preventing untrusted files from reaching the vulnerable API. Trade-off: breaks any workflow that consumes third-party or user-supplied models. (3) Run the model-loading process in an isolated subprocess or sandboxed environment (e.g., seccomp, process isolation) so that a SIGSEGV on load terminates only the subprocess rather than the parent service. Trade-off: adds latency and operational complexity but limits DoS blast radius.

Share

EUVD-2026-77792 vulnerability details – vuln.today

This site uses cookies essential for authentication and security. No tracking or analytics cookies are used. Privacy Policy