CVE-2026-33139
PySpector plugin validation bypass allows arbitrary command execution.
- CVSS 8.3
- CWE-184
- Input Validation and Sanitization
- Local
PySpector is a static analysis security testing (SAST) Framework engineered for modern Python development workflows. PySpector versions 0.1.6 and prior are affected by a security validation bypass in the plugin system. The validate_plugin_code() function in plugin_system.py, performs static AST analysis to block dangerous API calls before a plugin is trusted and executed. However, the internal resolve_name() helper only handles ast.Name and ast.Attribute node types, returning None for all others. When a plugin uses indirect function calls via getattr() (such as getattr(os, 'system')) the outer call's func node is of type ast.Call, causing resolve_name() to return None, and the security check to be silently skipped. The plugin incorrectly passes the trust workflow, and executes arbitrary system commands on the user's machine when loaded. This issue has been patched in version 0.1.7.
- CWE
- CWE-184
- CVSS base score
- 8.3
- Published
- 2026-03-20
- OWASP
- A08 Software and Data Integrity Failures
- Orthogonal defect classification
- Checking
- Code defect classification
- Missing Check
- Category
- Input Validation and Sanitization
- Subcategory
- Command Injection
- Accessibility scope
- Local
- Impact
- Arbitrary Code Execution
- Affected component
- PySpector
- Fixed by upgrading
- Yes
Solution
Upgrade PySpector to version 0.1.7 or later.
Vulnerable code sample
import ast
BLOCKED_CALLS = {
'os.system',
'os.popen',
'subprocess.run',
'subprocess.call',
'subprocess.check_call',
'subprocess.check_output',
'eval',
'exec',
'__import__'
}
def resolve_name(node):
"""
Vulnerable helper function to resolve the full name of a function call.
Only handles simple Name and Attribute access.
"""
if isinstance(node, ast.Name):
return node.id
elif isinstance(node, ast.Attribute) and isinstance(node.value, (ast.Name, ast.Attribute)):
# Recursively resolve the base object name
base_name = resolve_name(node.value)
if base_name:
return f"{base_name}.{node.attr}"
# THE FLAW: If the node is anything else (like ast.Call for getattr), it returns None.
return None
class SecurityVisitor(ast.NodeVisitor):
"""
An AST visitor that checks for dangerous function calls.
"""
def __init__(self):
self.is_safe = True
self.violations = []
def visit_Call(self, node):
"""
Visit a Call node and check if it's a blocked function.
"""
# Resolve the function name using the vulnerable helper
func_name = resolve_name(node.func)
# If func_name is resolved (not None), check against the blocklist.
# If it's None (due to getattr), this check is silently skipped.
if func_name and func_name in BLOCKED_CALLS:
self.is_safe = False
self.violations.append(func_name)
# Continue traversing the tree
self.generic_visit(node)
def validate_plugin_code(code_string: str) -> bool:
"""
Performs static AST analysis on plugin code to block dangerous API calls.
Returns True if the code is considered safe, False otherwise.
This function is vulnerable because its check can be bypassed.
"""
try:
tree = ast.parse(code_string)
visitor = SecurityVisitor()
visitor.visit(tree)
if not visitor.is_safe:
print(f"Validation failed: Detected disallowed calls: {visitor.violations}")
return False
return True
except SyntaxError:
# Invalid Python code is considered unsafe
return FalsePatched code sample
import ast
import sys
# A simplified representation of the plugin system from PySpector.
# This code demonstrates the fix for the validation bypass.
# List of function calls considered dangerous and should be blocked.
DANGEROUS_CALLS = {
'os.system',
'os.popen',
'subprocess.run',
'subprocess.call',
'subprocess.check_call',
'subprocess.check_output',
'eval',
'exec',
}
def resolve_name(node):
"""
Recursively resolves the full name of a function call from an AST node.
This is the patched version of the function.
"""
if isinstance(node, ast.Name):
return node.id
if isinstance(node, ast.Attribute):
# Recursively resolve the object part of the attribute, e.g., 'os' in 'os.system'
base = resolve_name(node.value)
if base:
return f"{base}.{node.attr}"
# --- START OF THE FIX ---
# The original vulnerability was that ast.Call was not handled, returning None.
# The fix is to add logic to handle ast.Call nodes, specifically for 'getattr'.
elif isinstance(node, ast.Call):
# Check if the function being called is 'getattr'
func_name = resolve_name(node.func)
if func_name == 'getattr' and len(node.args) == 2:
# The first argument to getattr is the object (e.g., os)
obj_name = resolve_name(node.args[0])
# The second argument is the attribute name as a string (e.g., 'system')
# In Python 3.8+, string literals are ast.Constant. Before that, ast.Str.
if isinstance(node.args[1], (ast.Constant, ast.Str)):
attr_name = node.args[1].value if isinstance(node.args[1], ast.Constant) else node.args[1].s
if obj_name and isinstance(attr_name, str):
# Reconstruct the full name, e.g., "os.system"
return f"{obj_name}.{attr_name}"
# --- END OF THE FIX ---
# If the name cannot be resolved, return None. The original code did this
# for ast.Call, which caused the bypass.
return None
class CallVisitor(ast.NodeVisitor):
"""
An AST visitor that finds all function calls and checks them against a blocklist.
"""
def __init__(self):
self.dangerous_calls_found = []
def visit_Call(self, node):
# For each call, try to resolve its full name
full_name = resolve_name(node.func)
if full_name and full_name in DANGEROUS_CALLS:
self.dangerous_calls_found.append(full_name)
# Continue traversing the AST
self.generic_visit(node)
def validate_plugin_code(code_string: str) -> bool:
"""
Performs static analysis on plugin code to check for dangerous API calls.
Returns True if the code is safe, False otherwise.
"""
print(f"--- Validating code ---\n{code_string.strip()}\n-----------------------")
try:
tree = ast.parse(code_string)
visitor = CallVisitor()
visitor.visit(tree)
if visitor.dangerous_calls_found:
print(f"[!] DANGEROUS: Found blocked API calls: {', '.join(visitor.dangerous_calls_found)}")
return False
else:
print("[+] SAFE: No blocked API calls were found.")
return True
except SyntaxError as e:
print(f"[-] ERROR: Invalid Python syntax in plugin code: {e}")
return False
if __name__ == '__main__':
# This plugin code uses the exact technique described in the CVE to bypass
# the security check. A vulnerable validator would miss this. The fixed
# validator correctly identifies it as dangerous.
vulnerable_plugin_code = """
import os
import subprocess
print("This plugin will try to execute a command.")
# The vulnerability: using getattr() to hide the call to os.system
# The AST node for `getattr(os, 'system')` is an `ast.Call`, which the
# vulnerable `resolve_name` function would ignore.
getattr(os, 'system')('echo "[EXEC] CVE-2026-33139 exploit successful!"')
"""
# A plugin with a direct dangerous call, which was always caught.
direct_dangerous_plugin_code = """
import os
os.system('echo "Direct call"')
"""
# A safe plugin for comparison.
safe_plugin_code = """
import os
print("This is a safe plugin.")
file_list = os.listdir('.')
print(f"Files in current directory: {len(file_list)}")
"""
print("Demonstrating the FIXED validation logic:\n")
print("Test 1: Analyzing a safe plugin.")
is_safe = validate_plugin_code(safe_plugin_code)
print(f"Validation result: {'PASSED (Code is safe)' if is_safe else 'FAILED (Code is dangerous)'}\n")
print("Test 2: Analyzing a plugin with a direct dangerous call.")
is_safe = validate_plugin_code(direct_dangerous_plugin_code)
print(f"Validation result: {'PASSED (Code is safe)' if is_safe else 'FAILED (Code is dangerous)'}\n")
print("Test 3: Analyzing the plugin that exploits the vulnerability.")
print("A VULNERABLE validator would incorrectly label this as 'SAFE'.")
print("The FIXED validator should correctly label it as 'DANGEROUS'.")
is_safe = validate_plugin_code(vulnerable_plugin_code)
print(f"Validation result: {'PASSED (Code is safe)' if is_safe else 'FAILED (Code is dangerous)'}\n")Payload
import os
getattr(os, 'system')('touch /tmp/pwned')
Cite this entry
@misc{vaitp:cve202633139,
title = {{PySpector plugin validation bypass allows arbitrary command execution.}},
author = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
year = {2026},
note = {VAITP Python Vulnerability Dataset, entry CVE-2026-33139},
howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2026-33139/}}
}
Introducing the "VAITP dataset": a specialized repository of Python vulnerabilities and patches, meticulously compiled for the use of the security research community. As Python's prominence grows, understanding and addressing potential security vulnerabilities become crucial. Crafted by and for the cybersecurity community, this dataset offers a valuable resource for researchers, analysts, and developers to analyze and mitigate the security risks associated with Python. Through the comprehensive exploration of vulnerabilities and corresponding patches, the VAITP dataset fosters a safer and more resilient Python ecosystem, encouraging collaborative advancements in programming security.
The supreme art of war is to subdue the enemy without fighting.
Sun Tzu – “The Art of War”
:: Shaping the future through research and ingenuity ::
