Horilla HR Authenticated RCE and XSS

2026-07-23

Horilla is an open-source HR management system built on Django. It is trusted by over 11,000 teams worldwide, and recognized as a rising alternative in self-hosted tech communities alongside OrangeHRM. While looking through it, I found two issues worth writing up: an authenticated RCE in the payroll tax calculation module, and a Stored XSS in the Document Request upload feature. Both are detailed below.

Authenticated RCE via Filing Status Code Injection

The federal tax calculation logic lets a user with permission to create a "Filing Status" supply raw Python code that later gets passed straight to exec(). Any payslip generated for an employee assigned to that filing status runs the attacker's code with the privileges of the Django process.

The issue sits in payroll/methods/tax_calc.py, in the federal tax calculation routine:

elif filing.use_py:
    code = filing.python_code # DB field, user-controlled
    code = code.replace("print(", "pass_print(")
    pass_print = """
def pass_print(*args, **kwargs):
    return None
"""
    code = pass_print + code
    code = code.replace("  formated_result(", "#  formated_result(")
    local_vars = {}
    exec(code, {}, local_vars) # RCE here
    try:
        federal_tax = local_vars["calculate_federal_tax"](yearly_income)
    except Exception as e:
        logger.error(e)

python_code is a field on FilingStatus, populated directly from the python_code HTTP parameter when a filing status is created via /payroll/create-filing-status. It's stored unmodified, with no validation, parsing, or sandboxing.

The only attempt at "hardening" this code is two string replacements: swapping print( for a no-op function, and commenting out calls to formated_result(. Neither does anything to stop arbitrary code execution. exec() is called with an empty globals dict ({}), but that doesn't sandbox anything either, since __builtins__ still gets injected automatically and things like import os work fine.

The payload runs the moment a payslip is generated for any employee assigned to the malicious filing status, since that's when this code path is triggered.

Exploitation

Prerequisites: an authenticated session with permission to create a Filing Status (typically a payroll/HR admin role) and assign it to an employee.

  1. Go to Federal Tax and create a new filing status.
  2. Enable the "use Python" option (use_py).
  3. In the python_code field, submit:
import os
os.system("id > /tmp/pwned")

def calculate_federal_tax(income):
    return 0
  1. Save the filing status.
  2. Assign it to an active employee contract.
  3. Generate a payslip for that employee, which triggers the federal tax calculation.
  4. On the server, /tmp/pwned now exists and contains the output of id, confirming code execution under the Django process's user.

Impact

This is a Code Injection vulnerability (CWE-94) resulting in full Remote Code Execution, reachable by any authenticated user able to reach /payroll/create-filing-status. In most deployments that's a payroll or HR admin role, but the actual minimum required permission should be checked against the specific deployment, since Horilla's permission model may vary.

Given that this runs as the Django application user, impact ranges from reading the application's database credentials and secrets, to pivoting further into the host depending on how the deployment is configured.

Stored XSS via Document Upload Extension Bypass

The Document Request feature lets users upload files restricted to specific types (e.g. PDF), but that restriction is enforced solely through an extension check on the filename, with no validation of the actual file content. This can be trivially bypassed with a double extension, e.g. file.pdf.html.

When the uploaded file is retrieved through the document endpoint, the server returns it in a way that causes the browser to interpret and render the HTML content directly, so any embedded <script> executes in the application's origin. Worse, the endpoint that serves uploaded documents doesn't enforce authentication, so the malicious file is reachable by anyone with the link, authenticated or not.

Exploitation

  1. Go to the Document Request upload form.
  2. Prepare a weaponized HTML file with an XSS payload, named file.pdf.
  3. Upload it via the Document Request form. While intercepting the request, rename the file from file.pdf to file.pdf.html.
  4. Grab the resulting document URL, returned/displayed by the application when attempting to re-upload a file.
  5. Open that URL directly (optionally from an unauthenticated/incognito session, to confirm there's no access control on the document endpoint).
  6. The JavaScript payload executes in the browser, confirming the file was served as renderable HTML rather than as a restricted file type.

Impact

This is an Unrestricted File Upload vulnerability (CWE-434) that directly enables Stored XSS (CWE-79), due to insufficient validation of the uploaded file type combined with the application serving user-uploaded content as renderable HTML. Any user who opens a link to the malicious document, authenticated or not, is exposed to the XSS.

Timeline

Although the advisories are the products of another researcher who (probably?) reported this a bit earlier than me, I am presenting the communication timeline.

  1. Discovered Vulnerabilities: 25 June 2026
  2. Notified Vendor: 26 June 2026
  3. Vendor Response to report via Github Adivsories: 29 June 2026
  4. Reported via Github Advisories: 29 June 2026
  5. Advisory Release: 21 July 2026 (not my report)