on this page
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:
- Producer-Consumer Signed-to-Unsigned Offset Confusion (
CVE-2026-115571/GHSA-cfhc-rw9j-rp26): Whenstable-diffusion.cpphardened its restricted pickle bytecode interpreter in July 2026 (GHSA-f7m6-x2wx-qxjq,GHSA-pcc7-mp9v-cghh,GHSA-hc8x-6mjr-cm3j, andGHSA-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’sstorage_offsetas a signed integer (BININT/BININT1/BININT2) and multiplied it into an unsigneduint64_t offsetfield. Downstream intorch_zip_io.cpp:80, the consumer checkedtensor.offset + tensor_nbytes > entry_sizein 64-bit unsigned arithmetic. A single negativestorage_offset = -1wrappedtensor.offsetto0xFFFFFFFFFFFFFFFC, wrappedtensor.offset + 4to0 <= entry_size, bypassed the bounds check, and drove an out-of-bounds heap read inmodel_loader.cpp:1120. - 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 ormemcpy. - Stateful Key Collisions in Auxiliary Loaders (
CVE-2026-70659/GHSA-6q7q-2v9c-x8m7): InIMatrixCollector::load_imatrix(src/runtime/imatrix.cpp), entries were keyed by tensor name in astd::unordered_map, but the destinationvaluesandcountsvectors were resized only when empty (if (e.values.empty())). A file containing two records with the same tensor name (first withnval = 1, second withnval = 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:
- 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 intoggml_tensorbuffers. Instead, they populate a vector ofTensorStoragerecords, where eachTensorStoragestores the tensor’s name, data type (ggml_type), dimensions (int64_t ne[SD_MAX_DIMS]), file/archive index, byteoffset, and element count. The actual byte copy happens later insideModelLoader::load_tensors(model_loader.cpp:1120). - Custom Restricted Pickle VM for PyTorch Checkpoints: Because Python’s
pickleformat is an executable stack machine,stable-diffusion.cppimplements a restricted C++ pickle bytecode interpreter (src/runtime/pickle_io.cpp) solely to extracttorch._utils._rebuild_tensor_v2calls and storage references (FloatStorage,HalfStorage,BFloat16Storage,ByteStorage) fromarchive/data.pklinside a PyTorch ZIP archive (src/runtime/torch_zip_io.cpp). - Implicit Trust in Parser-Validated
TensorStorageInvariants: Once a format parser appends aTensorStoragestruct totensor_storages,ModelLoader::load_tensorsassumes thattensor_storage.offsetandtensor_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
MARKframes 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:
- A reference to the backing storage object (
storage), which already recordselement_nbytes(for example4forFloatStorageor2forHalfStorage); - The
storage_offsetinteger (the starting element index within that storage blob); - The
sizetuple (ne[0..n_dims-1]); and - The
stridetuple.
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 isfalse.
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),memcpyreads4bytes starting 4 bytes beforeread_buffer.data()(reading the allocator chunk header or adjacent heap object directly into the tensor weights buffer). - For
storage_offset = -256withtensor_nbytes = 1024andentry_size = 1024,offsetis-1024,offset + tensor_nbyteswraps to0 <= 1024, andmemcpyreads 1,024 bytes of preceding heap memory (read_buffer.data() - 1024 .. read_buffer.data()) into the destination tensor. - For large negative or positive
storage_offsetvalues that wrapoffset + tensor_nbytes <= entry_sizewhentensor_nbytesis large,read_buffer.data() + tensor.offsetpoints 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 writesout.gguf(exit 0). - Crafted (
storage_offset = -1): TriggersERROR: 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:
- Signed-pickle validation before conversion: Rejects negative
storage_offsetvalues (int_value < 0), negative dimension extents, and multiplication overflow when converting element offsets to byte offsets inpickle_io.cpp. - Overflow-safe range check in
torch_zip_io.cpp: Checkstensor.offset > entry_size || tensor_nbytes > entry_size - tensor.offsetinstead oftensor.offset + tensor_nbytes > entry_size. - Final copy-site bounds check in
model_loader.cpp: Verifies at thememcpysite inModelLoader::load_tensorsthattensor_storage.offset <= read_buffer.size() && nbytes_to_read <= read_buffer.size() - tensor_storage.offsetacross 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):
- Header framing wraparound (
GHSA-fc82-rcm2-p4vr): The loader readuint64_t header_sizeand checked whether8 + header_size > file_size(or allocated a buffer ofheader_size + 1bytes). Whenheader_sizewas in[UINT64_MAX - 7, UINT64_MAX],8 + header_sizewrapped modulo2^64to[0, 7], passing the file-size check on any 8-byte file and under-allocating the JSON buffer beforefread. - Shape product overflow (
GHSA-6x2x-qq2m-qqj9): For each tensor entry in the JSON header, the loader parsed theshapearray intotensor_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 / Advisory | Format & File | Vulnerable Expression | Broken Invariant | Concrete Memory Impact | Upstream 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_size | Negative storage_offset sign-extends to uint64_t and wraps offset + nbytes modulo 2^64 | Heap OOB read (read_buffer.data() - k) into tensor weights or SIGSEGV | Commit 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 resize | Controlled heap OOB write (float and int32 arrays) | PR #1750 (4fcc6fef) |
GHSA-fc82-rcm2-p4vr | SafeTensors (.safetensors, src/runtime/model_loader.cpp) | 8 + header_size > file_size and header_size + 1 | 64-bit unsigned addition wraparound near UINT64_MAX | Under-allocated heap buffer / OOM abort | Commit c2fd0fc (PR #1404) |
GHSA-6x2x-qq2m-qqj9 | SafeTensors (.safetensors, src/runtime/model_loader.cpp) | ne[0] * ne[1] * ne[2] * ne[3] * elem_bytes | Unchecked 64-bit dimension product overflow vs data_offsets span | Mismatched tensor buffer allocation vs payload copy | Commit c8191e2 (PR #1404) |
5. Engineering Takeaways for C/C++ Model Loader Authors
- Never Write
offset + size > limitin Unsigned Arithmetic: Across GGUF, SafeTensors, Torch ZIP, IRPA, and FlatBuffers,offset + size > limitis 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; } - 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.cppandmodel_loader.cpp). - 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 checkelse if (entry.buf.size() != (size_t)n) return false;. - 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.