PT-2026-50729 · Crates.Io · Tract-Nnef

Publicado

2026-06-18

·

Atualizado

2026-06-18

·

CVE-2026-55093

CVSS v3.1

6.1

Média

VetorAV:L/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:H
  • Component: tract-nnef (nnef/src/tensors.rs::read tensor) + tract-data (data/src/tensor.rs)
  • Affected versions: < 0.21.16, 0.22.00.22.2, 0.23.00.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 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)

Correção

Integer Overflow

Out of bounds Read

Encontrou algum problema na descrição? Tem algo a acrescentar? Fique à vontade para nos escrever 👾

Enumeração de Fraquezas

Identificadores relacionados

CVE-2026-55093
GHSA-X5MV-8WGW-29HG

Produtos afetados

Tract-Nnef