GitHub Security Advisory: GHSA-vvpj-8cmc-gx39
Package: picklescan (PIP)
Severity: CRITICAL (CVSS 10.0)
CWE: CWE-183: Permissive List of Allowed Inputs, CWE-693: Protection Mechanism Failure
Vulnerable versions: < 1.0.4
Patched in: 1.0.4

Summary: PickleScan's pkgutil.resolve_name has a universal blocklist bypass

Details:
## Summary

`pkgutil.resolve_name()` is a Python stdlib function that resolves any `"module:attribute"` string to the corresponding Python object at runtime. By using `pkgutil.resolve_name` as the first REDUCE call in a pickle, an attacker can obtain a reference to ANY blocked function (e.g., `os.system`, `builtins.exec`, `subprocess.call`) without that function appearing in the pickle's opcodes. picklescan only sees `pkgutil.resolve_name` (which is not blocked) and misses the actual dangerous function entirely.

This defeats picklescan's **entire blocklist concept** — every single entry in `_unsafe_globals` can be bypassed.

## Severity

**Critical** (CVSS 10.0) — Universal bypass of all blocklist entries. Any blocked function can be invoked.

## Affected Versions

- picklescan <= 1.0.3 (all versions including latest)

## Details

### How It Works

A pickle file uses two chained REDUCE calls:

```
1. STACK_GLOBAL: push pkgutil.resolve_name
2. REDUCE: call resolve_name("os:system") → returns os.system function object
3. REDUCE: call the returned function("malicious command") → RCE
```

picklescan's opcode scanner sees:
- `STACK_GLOBAL` with module=`pkgutil`, name=`resolve_name` → **NOT in blocklist** → CLEAN
- The second `REDUCE` operates on a stack value (the return of the first call), not on a global import → **invisible to scanner**

The string `"os:system"` is just data (a SHORT_BINUNICODE argument to the first REDUCE) — picklescan does not analyze REDUCE arguments, only GLOBAL/INST/STACK_GLOBAL references.

### Decompiled Pickle (what the data actually does)

```python
from pkgutil import resolve_name
_var0 = resolve_name('os:system')          # Returns the actual os.system function
_var1 = _var0('malicious_command')          # Calls os.system('malicious_command')
result = _var1
```

### Confirmed Bypass Targets

Every entry in picklescan's blocklist can be reached via resolve_name:

| Chain | Resolves To | Confirmed RCE | picklescan Result |
|-------|------------|---------------|-------------------|
| `resolve_name("os:system")` | `os.system` | YES | CLEAN |
| `resolve_name("builtins:exec")` | `builtins.exec` | YES | CLEAN |
| `resolve_name("builtins:eval")` | `builtins.eval` | YES | CLEAN |
| `resolve_name("subprocess:getoutput")` | `subprocess.getoutput` | YES | CLEAN |
| `resolve_name("subprocess:getstatusoutput")` | `subprocess.getstatusoutput` | YES | CLEAN |
| `resolve_name("subprocess:call")` | `subprocess.call` | YES (shell=True needed) | CLEAN |
| `resolve_name("subprocess:check_call")` | `subprocess.check_call` | YES (shell=True needed) | CLEAN |
| `resolve_name("subprocess:check_output")` | `subprocess.check_output` | YES (shell=True needed) | CLEAN |
| `resolve_name("posix:system")` | `posix.system` | YES | CLEAN |
| `resolve_name("cProfile:run")` | `cProfile.run` | YES | CLEAN |
| `resolve_name("profile:run")` | `profile.run` | YES | CLEAN |
| `resolve_name("pty:spawn")` | `pty.spawn` | YES | CLEAN |

**Total:** 11+ confirmed R