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
| Vetor | AV: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.0–0.22.2,0.23.0–0.23.1— the denseDatLoaderpath 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 publictract nnef::nnef().model for path/model for read - Impact:
read tensorreturns a memory-unsafe tensor (reportedlen2^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 unsoundfrom 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 uniformfast-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 clampThe 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 allocThe 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^64len * (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 = 56bytes (7 × F64). - reported
len=2^61+7elements.
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 READNo 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:- 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) returns0x0000000000000041→ heap OOB read (adjacent-heap disclosure).s[1<<40]→ SIGSEGV (signal 11).
- Public load API — build a malicious
.nnef.tar(graph.nnefwithvariable(label='weights', shape=[...])+weights.dat) and callnnef().model for read():
- returns
Okwith oneConstnode,out[0].fact.uniform=Some(...),len()=2305843009213693959over a 56-byte buffer → confirmsas uniform/is uniform t/as slice uncheckedperformed an OOB read on load (bounded over-read here becauseis uniform's.all()short-circuits on the uniform0x41payload).
- Optimized graph — same archive but the const is consumed (
output = mul(weights, weights)), theninto optimized/run:
- Does not crash. With both a uniform (
0x41×56) and a non-uniform (0..56) payload,into optimizedconst-foldsmul(const, const)to a single node without a full-length materialization of the oversized const, andruncompletes. 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 withoutchecked */try into, especially when the result feeds an allocation and a separately-trackedlen. - Runtime / fleet: crash telemetry showing SIGSEGV inside
is uniform t/from raw partsduring NNEF model load; an ASAN build flagsheap-buffer-overflow READinread tensor→as uniform. - Input filter (compensating): reject NNEF
.dattensors whereproduct(dims)overflowsu64, or whereproduct(dims) * size of(dt) != data size bytescomputed in checked arithmetic, before constructing the tensor. - YARA-ish heuristic for
.datblobs: NNEF magic4E EF 01 00,rank<=8, and anydim >= 0x10000whose 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 denseDatLoader/read tensorpath 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.rspredates 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 👾
Identificadores relacionados
Produtos afetados
Tract-Nnef