When we open the website, we are welcomed to a basic page that says Flash! and presents a start button. Clicking start will cycle through 10 rounds, displaying a 7 digit unique number each time.
When we get to the end, we have to enter the total value.
I thought maybe I could just use a match/replace rule in burp to change the timeout of each round from 500ms to 5000ms, giving me time to write down each number. However, some of the rounds (4-7) don't show any value at all.
Note that each round provides a new session cookie, which we can decode with flask-unsign.
The challenge comes with source code, app.py is the most interesting.
from flask import Flask, session, render_template, request, redirect, url_for, make_response
import hmac, hashlib, secrets
used_tokens = set()
with open('./static/seed.txt', 'r') as f:
SEED = bytes.fromhex(f.read().strip())
def lcg_params(seed: bytes, session_id: str):
m = 2147483693
raw_a = hmac.new(seed, (session_id + "a").encode(), hashlib.sha256).digest()
a = (int.from_bytes(raw_a[:8], 'big') % (m - 1)) + 1
raw_c = hmac.new(seed, (session_id + "c").encode(), hashlib.sha256).digest()
c = (int.from_bytes(raw_c[:8], 'big') % (m - 1)) + 1
return m, a, c
def generate_round_digits(seed: bytes, session_id: str, round_index: int):
LCG_M, LCG_A, LCG_C = lcg_params(seed, session_id)
h0 = hmac.new(seed, session_id.encode(), hashlib.sha256).digest()
state = int.from_bytes(h0, 'big') % LCG_M
for _ in range(DIGITS_PER_ROUND * round_index):
state = (LCG_A * state + LCG_C) % LCG_M
digits = []
for _ in range(DIGITS_PER_ROUND):
state = (LCG_A * state + LCG_C) % LCG_M
digits.append(state % 10)
return digits
def reset_rng():
session.clear()
session['session_id'] = secrets.token_hex(16)
session['round'] = 0
TOTAL_ROUNDS = 10
DIGITS_PER_ROUND = 7
FLAG = "TsukuCTF25{**REDACTED**}"
app = Flask(__name__)
app.secret_key = secrets.token_bytes(16)
@app.route('/')
def index():
reset_rng()
return render_template('index.html')
@app.route('/flash')
def flash():
session_id = session.get('session_id')
if not session_id:
return redirect(url_for('index'))
r = session.get('round', 0)
if r >= TOTAL_ROUNDS:
return redirect(url_for('result'))
digits = generate_round_digits(SEED, session_id, r)
session['round'] = r + 1
visible = (session['round'] <= 3) or (session['round'] > 7)
return render_template('flash.html', round=session['round'], total=TOTAL_ROUNDS, digits=digits, visible=visible)
@app.route('/result', methods=['GET', 'POST'])
def result():
if request.method == 'GET':
if not session.get('session_id') or session.get('round', 0) < TOTAL_ROUNDS:
return redirect(url_for('flash'))
token = secrets.token_hex(16)
session['result_token'] = token
used_tokens.add(token)
return render_template('result.html', token=token)
form_token = request.form.get('token', '')
if ('result_token' not in session or form_token != session['result_token']
or form_token not in used_tokens):
return redirect(url_for('index'))
used_tokens.remove(form_token)
ans_str = request.form.get('answer', '').strip()
if not ans_str.isdigit():
return redirect(url_for('index'))
ans = int(ans_str)
session_id = session.get('session_id')
correct_sum = 0
for round_index in range(TOTAL_ROUNDS):
digits = generate_round_digits(SEED, session_id, round_index)
number = int(''.join(map(str, digits)))
correct_sum += number
session.clear()
resp = make_response(
render_template('result.html', submitted=ans, correct=correct_sum,
success=(ans == correct_sum), FLAG=FLAG if ans == correct_sum else None)
)
cookie_name = app.config.get('SESSION_COOKIE_NAME', 'session')
resp.set_cookie(cookie_name, '', expires=0)
return resp
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
Breaking it down
The Flask app generates a series of 10 pseudo-random 7-digit numbers using a deterministic PRNG seeded by a secret value and a session ID. The goal is to calculate the sum of all 10 numbers, but only rounds 1ā3 and 8ā10 are visible.
At startup, the app reads a secret SEED from ./static/seed.txt. This file is served from Flask's static/ directory, making it publicly downloadable.
When we visit /, the app generates a new session with a random session_id and sets the round to 0. The session_id is stored in the Flask session cookie, which is signed but decodable (using flask-unsign).
Each round's number is generated by a linear congruential generator (LCG), with parameters (a, c) and initial state derived from HMACs of the SEED and session_id. The generator advances the state based on the current round index and produces 7 digits per round.
The /flash endpoint shows the digits in an image if the round is in [0, 1, 2, 7, 8, 9]; otherwise, the digits are hidden. It progresses through the rounds automatically, and after round 10, we are redirected to /result.
At /result, we must submit the sum of all 10 numbers. The server recomputes all 10 numbers using the stored session_id and compares the submitted sum to the correct one. If the answer is correct, the flag is displayed.
Steps to solve:
Download seed.txt from /static/seed.txt.
Go through all 10 rounds in a single session, preserving the session cookie.
Decode the session_id from the cookie after the 10 rounds.
Use the known SEED and decoded session_id to regenerate all 10 numbers using the PRNG logic.
Compute the sum and submit it at /result to receive the flag.
We can use ChatGPT to make a quick solve script š
solve.py
import requests
import hmac
import hashlib
import base64
import json
import time
BASE_URL = "http://challs.tsukuctf.org:50000"
SEED_HEX = "b7c4c422a93fdc991075b22b79aa12bb19770b1c9b741dd44acbafd4bc6d1aabc1b9378f3b68ac345535673fcf07f089a8492dc1b05343a80b3d002f070771c6"
SEED = bytes.fromhex(SEED_HEX)
DIGITS_PER_ROUND = 7
TOTAL_ROUNDS = 10
LCG_M = 2147483693
session = requests.Session()
def decode_flask_cookie(cookie):
payload = cookie.split('.')[0]
padded = payload + '=' * (-len(payload) % 4)
decoded = base64.urlsafe_b64decode(padded)
return json.loads(decoded)
def lcg_params(seed: bytes, session_id: str):
raw_a = hmac.new(seed, (session_id + "a").encode(),
hashlib.sha256).digest()
a = (int.from_bytes(raw_a[:8], 'big') % (LCG_M - 1)) + 1
raw_c = hmac.new(seed, (session_id + "c").encode(),
hashlib.sha256).digest()
c = (int.from_bytes(raw_c[:8], 'big') % (LCG_M - 1)) + 1
return a, c
def generate_round_digits(seed: bytes, session_id: str, round_index: int):
a, c = lcg_params(seed, session_id)
h0 = hmac.new(seed, session_id.encode(), hashlib.sha256).digest()
state = int.from_bytes(h0, 'big') % LCG_M
for _ in range(DIGITS_PER_ROUND * round_index):
state = (a * state + c) % LCG_M
digits = []
for _ in range(DIGITS_PER_ROUND):
state = (a * state + c) % LCG_M
digits.append(state % 10)
return digits
# Step 1: Start a new session
print("[*] Resetting session...")
session.get(BASE_URL + "/")
# Step 2: Visit /flash 10 times, waiting for 500ms after each
print("[*] Playing through 10 rounds...")
for i in range(TOTAL_ROUNDS):
session.get(BASE_URL + "/flash")
print(f"Visited /flash - round {i + 1}")
time.sleep(0.6) # must wait >500ms to match auto-redirect timing
# Step 3: Extract session_id from cookie
cookie = session.cookies.get("session")
session_data = decode_flask_cookie(cookie)
session_id = session_data["session_id"]
print(f"[*] session_id: {session_id}")
# Step 4: Recompute numbers using SEED and session_id
print("[*] Generating numbers...")
total_sum = 0
for i in range(TOTAL_ROUNDS):
digits = generate_round_digits(SEED, session_id, i)
number = int("".join(map(str, digits)))
print(f"Round {i+1:2d}: {''.join(map(str, digits))} -> {number}")
total_sum += number
print(f"\nā Total sum to submit: {total_sum}")
# Step 5: GET /result to retrieve token
resp = session.get(BASE_URL + "/result")
token_line = [line for line in resp.text.splitlines()
if 'name="token"' in line]
if not token_line:
print("ā Couldn't find token. Are you sure 10 rounds completed?")
exit()
token = token_line[0].split('value="')[1].split('"')[0]
print(f"[*] token: {token}")
# Step 6: POST result with sum
resp = session.post(BASE_URL + "/result", data={
"token": token,
"answer": str(total_sum)
})
# Step 7: Extract flag
if "TsukuCTF25{" in resp.text:
start = resp.text.find("TsukuCTF25{")
end = resp.text.find("}", start) + 1
flag = resp.text[start:end]
print(f"\nš FLAG: {flag}")
else:
print("ā Submission failed or incorrect. Try again.")
Not sure if I took the intended approach here, since I didn't actually do any path traversal. I feel like I was tricked into doing a crypto challenge š