Notebook/Essay

Signed-to-Unsigned Tensor Offset Wraparound in stable-diffusion.cpp

How a signed 32-bit storage_offset in a PyTorch ZIP archive sign-extended to 64-bit unsigned arithmetic, wrapped offset + nbytes past the bounds check in stable-diffusion.cpp (CVE-2026-115571 / GHSA-cfhc-rw9j-rp26), and how sibling arithmetic and duplicate-key bugs surfaced across SafeTensors, GGUF, and imatrix loaders (CVE-2026-70659).

on this page
Official Advisories & Upstream Fixes

CVEs & Advisories: CVE-2026-115571 (GHSA-cfhc-rw9j-rp26) · CVE-2026-70659 (GHSA-6q7q-2v9c-x8m7) · GHSA-fc82-rcm2-p4vr · GHSA-6x2x-qq2m-qqj9
Target: leejet/stable-diffusion.cpp · Upstream Fixes: Commits e92e86f (PR #1832), 4fcc6fef (PR #1750), c2fd0fc, and c8191e2 (PR #1404)

Diffusion inference engines sit at the crossroads of every major weight container format in modern machine learning. Unlike text-only LLM runtimes that can standardize on a single format such as GGUF, a practical diffusion engine must ingest legacy PyTorch .ckpt and .pt ZIP archives, Hugging Face .safetensors shards, quantized .gguf checkpoints, LoRA adapters, ControlNet weights, TAESD autoencoders, and quantization importance matrices (.imatrix). Every one of those containers embeds untrusted integer metadata: file offsets, header lengths, per-axis tensor dimensions (ne[0..3]), storage element counts, and string lengths.

In October 2026, the stable-diffusion.cpp project published GHSA-cfhc-rw9j-rp26 (CVE-2026-115571, patched in commit e92e86f / PR #1832), following GHSA-6q7q-2v9c-x8m7 (CVE-2026-70659, patched in PR #1750 4fcc6fef) and the earlier loader fixes in commits c2fd0fc and c8191e2 (GHSA-fc82-rcm2-p4vr and GHSA-6x2x-qq2m-qqj9). Taken together, these vulnerabilities illustrate three recurring structural patterns in C and C++ model loaders:

  1. Producer-Consumer Signed-to-Unsigned Offset Confusion (CVE-2026-115571 / GHSA-cfhc-rw9j-rp26): When stable-diffusion.cpp hardened its restricted pickle bytecode interpreter in July 2026 (GHSA-f7m6-x2wx-qxjq, GHSA-pcc7-mp9v-cghh, GHSA-hc8x-6mjr-cm3j, and GHSA-mjpv-wcmw-644f), those fixes bounded stack manipulation and opcode parsing inside the pickle VM itself. However, the pickle VM still parsed _rebuild_tensor_v2’s storage_offset as a signed integer (BININT / BININT1 / BININT2) and multiplied it into an unsigned uint64_t offset field. Downstream in torch_zip_io.cpp:80, the consumer checked tensor.offset + tensor_nbytes > entry_size in 64-bit unsigned arithmetic. A single negative storage_offset = -1 wrapped tensor.offset to 0xFFFFFFFFFFFFFFFC, wrapped tensor.offset + 4 to 0 <= entry_size, bypassed the bounds check, and drove an out-of-bounds heap read in model_loader.cpp:1120.
  2. Dimension Product and Header Framing Wraparound (GHSA-fc82-rcm2-p4vr & GHSA-6x2x-qq2m-qqj9): Across the SafeTensors and GGUF loaders, 64-bit header lengths (header_size + 8), signed 32-bit or 64-bit per-axis tensor extents (ne[0] * ne[1] * ne[2] * ne[3]), and byte-size calculations wrapped before buffer allocation or memcpy.
  3. Stateful Key Collisions in Auxiliary Loaders (CVE-2026-70659 / GHSA-6q7q-2v9c-x8m7): In IMatrixCollector::load_imatrix (src/runtime/imatrix.cpp), entries were keyed by tensor name in a std::unordered_map, but the destination values and counts vectors were resized only when empty (if (e.values.empty())). A file containing two records with the same tensor name (first with nval = 1, second with nval = 67108864) skipped the second resize and wrote attacker-controlled floats and integers past the end of both heap buffers.

1. Architecture of the stable-diffusion.cpp Loader Pipeline

When a user invokes sd-cli (whether to generate an image via -m model.ckpt or convert weights via sd-cli -M convert -m input.ckpt -o output.gguf), ModelLoader::init_from_file in src/runtime/model_loader.cpp dispatches on the input file’s magic bytes and extension into one of four primary parsers:

                   +-----------------------------------+
                   |   ModelLoader::init_from_file     |
                   |   (src/runtime/model_loader.cpp)  |
                   +-----------------+-----------------+
                                     |
         +------------------+--------+--------+------------------+
         |                  |                 |                  |
         v                  v                 v                  v
+----------------+ +----------------+ +---------------+ +----------------+
|   GGUF Loader  | |  SafeTensors   | |  Torch ZIP /  | | IMatrix Loader |
| init_from_gguf | |    Loader      | | Pickle Loader | |  load_imatrix  |
|   _file()      | | init_from_safe | | torch_zip_io  | |  (imatrix.cpp) |
|                | | tensors_file() | | + pickle_io   | |                |
+--------+-------+ +--------+-------+ +-------+-------+ +--------+-------+
         |                  |                 |                  |
         +------------------+--------+--------+                  |
                                     |                           |
                                     v                           v
                   +-----------------+-----------------+ +-------+--------+
                   |     TensorStorage Descriptors     | |  IMatrixStats  |
                   |  (name, type, ne[4], offset, sz)  | | values/counts  |
                   +-----------------+-----------------+ +----------------+
                                     |
                                     v
                   +-----------------+-----------------+
                   |    ModelLoader::load_tensors      |
                   |  read_buffer + offset -> memcpy   |
                   +-----------------------------------+

Three design properties of this pipeline govern how bugs manifest:

  1. Decoupled Metadata Parsing and Tensor Materialization: The format parsers (init_from_gguf_file, init_from_safetensors_file, parse_torch_zip) do not immediately copy tensor weights into ggml_tensor buffers. Instead, they populate a vector of TensorStorage records, where each TensorStorage stores the tensor’s name, data type (ggml_type), dimensions (int64_t ne[SD_MAX_DIMS]), file/archive index, byte offset, and element count. The actual byte copy happens later inside ModelLoader::load_tensors (model_loader.cpp:1120).
  2. Custom Restricted Pickle VM for PyTorch Checkpoints: Because Python’s pickle format is an executable stack machine, stable-diffusion.cpp implements a restricted C++ pickle bytecode interpreter (src/runtime/pickle_io.cpp) solely to extract torch._utils._rebuild_tensor_v2 calls and storage references (FloatStorage, HalfStorage, BFloat16Storage, ByteStorage) from archive/data.pkl inside a PyTorch ZIP archive (src/runtime/torch_zip_io.cpp).
  3. Implicit Trust in Parser-Validated TensorStorage Invariants: Once a format parser appends a TensorStorage struct to tensor_storages, ModelLoader::load_tensors assumes that tensor_storage.offset and tensor_storage.nbytes_to_read() have already been validated against the backing file or ZIP entry size.

Whenever a check at the boundary between metadata parsing and TensorStorage construction wraps in 32-bit or 64-bit arithmetic, ModelLoader::load_tensors executes an out-of-bounds memcpy directly against the heap buffer holding the file or ZIP entry.


2. Deep Dive: CVE-2026-115571 (GHSA-cfhc-rw9j-rp26) in the Torch ZIP Loader

2.1 Why the Four Earlier Pickle VM Advisories Did Not Prevent CVE-2026-115571

In early July 2026, stable-diffusion.cpp published four security advisories in its PyTorch ZIP / pickle loader (GHSA-f7m6-x2wx-qxjq, GHSA-pcc7-mp9v-cghh, GHSA-hc8x-6mjr-cm3j, and GHSA-mjpv-wcmw-644f), patched in commits 0ba4dd3 and 2b45fc2. All four addressed vulnerabilities inside the restricted pickle stack machine in pickle_io.cpp:

  • Unchecked stack underflows when popping MARK frames or tuple arguments;
  • Out-of-bounds reads when decoding variable-length opcode operands past the end of data.pkl;
  • Unvalidated memo-table indices (BINGET / LONG_BINGET).

Once those four patches landed, the pickle bytecode interpreter itself could no longer be crashed by malformed opcodes or truncated operand streams. Yet the values produced by a syntactically valid pickle stream were still passed directly into TensorStorage arithmetic.

2.2 Stage 1: Signed storage_offset Multiplication in pickle_io.cpp

In PyTorch’s archive/data.pkl format, a tensor is reconstructed by invoking torch._utils._rebuild_tensor_v2(storage, storage_offset, size, stride, requires_grad, backward_hooks). When the REDUCE ('R') opcode executes in src/runtime/pickle_io.cpp, the interpreter pops the argument tuple from the stack and extracts:

  1. A reference to the backing storage object (storage), which already records element_nbytes (for example 4 for FloatStorage or 2 for HalfStorage);
  2. The storage_offset integer (the starting element index within that storage blob);
  3. The size tuple (ne[0..n_dims-1]); and
  4. The stride tuple.

How is storage_offset encoded in pickle? For non-negative offsets, Python emits BININT1 (0x4b, 1-byte unsigned), BININT2 (0x4d, 2-byte unsigned), or BININT (0x4a, 4-byte signed two’s-complement int32_t). When pickle_io.cpp decodes BININT ('J'), it reads a signed 32-bit integer into int_value and computes the byte offset inside the storage file:

// src/runtime/pickle_io.cpp (pre-e92e86f)
// arg 1 of _rebuild_tensor_v2 is storage_offset:
tensor_storage.offset = arg_storage_offset.int_value * tensor_storage.element_nbytes;

Here tensor_storage.offset has type uint64_t (or size_t), while arg_storage_offset.int_value is signed. When a crafted data.pkl specifies storage_offset = -1 (BININT 0xff 0xff 0xff 0xff) on a 4-byte FloatStorage tensor (element_nbytes = 4), C++ integer promotions sign-extend -1 to uint64_t (0xFFFFFFFFFFFFFFFFULL) and multiply by 4 modulo 2^64:

tensor_storage.offset = (-1) * 4 (mod 2^64) = 2^64 - 4 = 0xFFFFFFFFFFFFFFFC

No check in pickle_io.cpp rejected arg_storage_offset.int_value < 0.

2.3 Stage 2: 64-Bit Unsigned Addition Overflow in torch_zip_io.cpp:80

After pickle_io.cpp finishes parsing archive/data.pkl, src/runtime/torch_zip_io.cpp matches each TensorStorage against its backing archive/data/<storage_key> entry in the ZIP central directory and validates that the tensor slice fits inside the uncompressed ZIP entry (entry_size):

// src/runtime/torch_zip_io.cpp:76-84 (assessed at a8a91b2)
uint64_t tensor_nbytes = tensor.nbytes_to_read();
if (tensor.offset + tensor_nbytes > entry_size) {
    LOG_ERROR("Invalid tensor offset/size in zip entry: offset=%llu, size=%llu, entry_size=%llu\n",
              tensor.offset, tensor_nbytes, entry_size);
    return false;
}

Look closely at the guard on line 80:

tensor.offset + tensor_nbytes > entry_size

Both tensor.offset and tensor_nbytes are unsigned 64-bit integers (uint64_t). Suppose our crafted tensor is a 1-element float32 tensor (ne = [1]), so tensor_nbytes = 4, and the backing ZIP entry archive/data/0 contains 16 bytes (entry_size = 16). With storage_offset = -1:

  • tensor.offset = 0xFFFFFFFFFFFFFFFC (2^64 - 4)
  • tensor_nbytes = 4
  • In 64-bit unsigned arithmetic:
    tensor.offset + tensor_nbytes = (2^64 - 4) + 4 = 0 (mod 2^64)
  • The bounds check evaluates 0 > 16, which is false.

More generally, for any negative storage_offset = -k where 1 <= k * element_nbytes <= tensor_nbytes, the sum tensor.offset + tensor_nbytes wraps modulo 2^64 into [0, tensor_nbytes - element_nbytes]. As long as entry_size is at least tensor_nbytes, the addition overflow check passes every time.

2.4 Stage 3: Heap Out-of-Bounds Read in model_loader.cpp:1120

Once torch_zip_io.cpp accepts the archive, ModelLoader::load_tensors (src/runtime/model_loader.cpp) decompresses the ZIP entry (16 bytes) into a std::vector<uint8_t> read_buffer on the heap and copies the tensor payload into the destination ggml_tensor buffer:

// src/runtime/model_loader.cpp:1116-1124 (assessed at a8a91b2)
memcpy(dst_buf,
       read_buffer.data() + tensor_storage.offset,
       tensor_storage.nbytes_to_read());

On a 64-bit process, pointer arithmetic is modulo 2^64:

read_buffer.data() + 0xFFFFFFFFFFFFFFFC == read_buffer.data() - 4

Consequently:

  • For storage_offset = -1 (offset = -4), memcpy reads 4 bytes starting 4 bytes before read_buffer.data() (reading the allocator chunk header or adjacent heap object directly into the tensor weights buffer).
  • For storage_offset = -256 with tensor_nbytes = 1024 and entry_size = 1024, offset is -1024, offset + tensor_nbytes wraps to 0 <= 1024, and memcpy reads 1,024 bytes of preceding heap memory (read_buffer.data() - 1024 .. read_buffer.data()) into the destination tensor.
  • For large negative or positive storage_offset values that wrap offset + tensor_nbytes <= entry_size when tensor_nbytes is large, read_buffer.data() + tensor.offset points into unmapped memory and crashes the process (SIGSEGV).

Running sd-cli -M convert -m crafted.ckpt -o out.gguf on an ASan-instrumented build immediately confirms the primitive:

  • Control (storage_offset = 0): Loads cleanly and writes out.gguf (exit 0).
  • Crafted (storage_offset = -1): Triggers ERROR: AddressSanitizer: heap-buffer-overflow on address ... READ of size 4 at src/runtime/model_loader.cpp:1120.

2.5 Upstream Remediation (e92e86f / PR #1832) & Downstream Submodule Exposure

Upstream maintainer leejet remediated the vulnerability in PR #1832 (commit e92e86f / b1f79fb) and published GHSA-cfhc-rw9j-rp26 (CVE-2026-115571) with three defense-in-depth layers:

  1. Signed-pickle validation before conversion: Rejects negative storage_offset values (int_value < 0), negative dimension extents, and multiplication overflow when converting element offsets to byte offsets in pickle_io.cpp.
  2. Overflow-safe range check in torch_zip_io.cpp: Checks tensor.offset > entry_size || tensor_nbytes > entry_size - tensor.offset instead of tensor.offset + tensor_nbytes > entry_size.
  3. Final copy-site bounds check in model_loader.cpp: Verifies at the memcpy site in ModelLoader::load_tensors that tensor_storage.offset <= read_buffer.size() && nbytes_to_read <= read_buffer.size() - tensor_storage.offset across all loader backends, including legacy checkpoint paths.

Because stable-diffusion.cpp is vendored as a git submodule by downstream multimodal runtimes (such as jart/llamafile, which pinned stable-diffusion.cpp at commit f83d6be prior to e92e86f), downstream consumers should update their submodule pin to include e92e86f.


3. Sibling Loader Bugs: SafeTensors, GGUF, and CVE-2026-70659 (load_imatrix)

Distinct arithmetic and container-state invariants also surfaced across the other three model formats supported by stable-diffusion.cpp:

3.1 SafeTensors Header Size and Shape Multiplication Overflow (GHSA-fc82-rcm2-p4vr & GHSA-6x2x-qq2m-qqj9)

A .safetensors file begins with an 8-byte little-endian uint64_t header_size, followed by header_size bytes of UTF-8 JSON metadata, followed by raw tensor data starting at byte offset 8 + header_size. In the pre-fix SafeTensors loader (init_from_safetensors_file in src/runtime/model_loader.cpp):

  1. Header framing wraparound (GHSA-fc82-rcm2-p4vr): The loader read uint64_t header_size and checked whether 8 + header_size > file_size (or allocated a buffer of header_size + 1 bytes). When header_size was in [UINT64_MAX - 7, UINT64_MAX], 8 + header_size wrapped modulo 2^64 to [0, 7], passing the file-size check on any 8-byte file and under-allocating the JSON buffer before fread.
  2. Shape product overflow (GHSA-6x2x-qq2m-qqj9): For each tensor entry in the JSON header, the loader parsed the shape array into tensor_storage.ne[i] and computed the element count (ne[0] * ne[1] * ne[2] * ne[3]) and byte length (n_elements * type_size) without checking for 64-bit multiplication overflow against the tensor’s [data_offsets[0], data_offsets[1]] byte span.

Both issues were fixed upstream in commits c2fd0fc and c8191e2 (PR #1404).

3.2 CVE-2026-70659 (GHSA-6q7q-2v9c-x8m7): Heap Out-of-Bounds Write in IMatrixCollector::load_imatrix

While GGUF, SafeTensors, and Torch ZIP carry model weights, quantization workflows also load .imatrix (importance matrix) files via --imat-in. Here is how IMatrixCollector::load_imatrix (src/runtime/imatrix.cpp, assessed at 3590aa8) parsed each entry before PR #1750:

// src/runtime/imatrix.cpp (pre-PR #1750)
auto& e = stats_[std::move(name)];          // std::unordered_map<std::string, IMatrixStats>
...
in.read((char*)&nval, sizeof(nval));
if (in.fail() || nval < 1) {
    return false;
}

if (e.values.empty()) {                     // <-- resizes ONLY when empty!
    e.values.resize(nval, 0);
    e.counts.resize(nval, 0);
}
std::vector<float> tmp(nval);
in.read((char*)tmp.data(), nval * sizeof(float));
if (in.fail()) {
    return false;
}

for (int i = 0; i < nval; i++) {
    e.values[i] += tmp[i];                  // <-- Heap OOB WRITE when nval > e.values.size()!
    e.counts[i] += ncall;                   // <-- Heap OOB WRITE!
}

If a crafted .imatrix file contains n_entries = 2 where both entries use the name "blk.0" (Entry 0 with nval = 1, Entry 1 with nval = 1048576), e.values.empty() is false on Entry 1, skipping the resize and writing 4 MiB of controlled floats and ints past both 4-byte heap vectors. Upstream PR #1750 (4fcc6fef, published as GHSA-6q7q-2v9c-x8m7 / CVE-2026-70659) closed this by rejecting any repeated tensor name whose nval does not match e.values.size().


4. Comparative Anatomy of the stable-diffusion.cpp Loader Bugs

CVE / AdvisoryFormat & FileVulnerable ExpressionBroken InvariantConcrete Memory ImpactUpstream Fix
CVE-2026-115571 (GHSA-cfhc-rw9j-rp26)PyTorch ZIP (.ckpt / .pt, pickle_io.cpp, torch_zip_io.cpp:80, model_loader.cpp:1120)offset = int_value * elem_nbytes then offset + nbytes > entry_sizeNegative storage_offset sign-extends to uint64_t and wraps offset + nbytes modulo 2^64Heap OOB read (read_buffer.data() - k) into tensor weights or SIGSEGVCommit e92e86f (PR #1832 / b1f79fb)
CVE-2026-70659 (GHSA-6q7q-2v9c-x8m7)Importance Matrix (.imatrix, src/runtime/imatrix.cpp:302)if (e.values.empty()) resize(nval); then for (i < nval) e.values[i] += tmp[i];Duplicate key in stats_ map with larger second nval skips resizeControlled heap OOB write (float and int32 arrays)PR #1750 (4fcc6fef)
GHSA-fc82-rcm2-p4vrSafeTensors (.safetensors, src/runtime/model_loader.cpp)8 + header_size > file_size and header_size + 164-bit unsigned addition wraparound near UINT64_MAXUnder-allocated heap buffer / OOM abortCommit c2fd0fc (PR #1404)
GHSA-6x2x-qq2m-qqj9SafeTensors (.safetensors, src/runtime/model_loader.cpp)ne[0] * ne[1] * ne[2] * ne[3] * elem_bytesUnchecked 64-bit dimension product overflow vs data_offsets spanMismatched tensor buffer allocation vs payload copyCommit c8191e2 (PR #1404)

5. Engineering Takeaways for C/C++ Model Loader Authors

  1. Never Write offset + size > limit in Unsigned Arithmetic: Across GGUF, SafeTensors, Torch ZIP, IRPA, and FlatBuffers, offset + size > limit is the single most common bounds-check bug in C and C++ parsers. Always write subtraction-based range checks after bounding the first operand:
    if (offset > limit || size > limit - offset) {
        return false;
    }
  2. Validate Producer-Consumer Contracts Across Internal Structs: Hardening a bytecode parser (pickle_io.cpp) against stack underflows and out-of-bounds opcodes does not guarantee that the semantic values it writes into an intermediate struct (TensorStorage) obey the preconditions assumed by downstream consumers (torch_zip_io.cpp and model_loader.cpp).
  3. Every Map-Keyed Accumulation Loop Must Re-Validate Existing Entry Dimensions: Whenever a file format stores named records that populate a hash map (std::unordered_map<std::string, Entry>), an attacker can emit duplicate keys. Always check else if (entry.buf.size() != (size_t)n) return false;.
  4. Audit All Format Loaders in Lockstep: When a vulnerability is found in one loader, treat every sibling loader in the same binary (GGUF, SafeTensors, Torch ZIP, imatrix) as a mandatory audit target.