Horilla HR Authenticated RCE via Code Injection

2026-07-23

Horilla is an open-source HR management system built on Django. While looking at the payroll module, I found that 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. Time for a writeup.

The Vulnerability

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.

Timeline

Although the advisory is the product of another researcher who may have reported this a bit earlier than me, I am presenting the communication timeline.

  1. Discovered Vulnerability: 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)