TrustLab IITB 2025 - Prelims Writeup
Cryptography:
1. N00bRandomness:
Analysis:
In challenge.py ,
We notice following in main():
ct1 = maskbytes(msg1, A, C, SEED)
ct3 = maskbytes(flag, A, C, SEED)
print("PLAIN1_HEX =", msg1.hex())
print("CIPH3_HEX =", ct3.hex())
This tells us that CIPH3 will reveal the flag , CIPH1 is encrypted using PLAIN1, and CIPH2 is independent and therefore useless ,so we focus on only these two.
We also notice, s = seed & 0xFF so we know that keystream starts from 0 .
Objective:
We need to get key for CIPH3 to decrypt it , from CIPH1 using the PLAIN1 as the key then again use the same recipe in CyberChef but replace input text as CIPH3.
I. Get key for CIPH3 XOR, ie recover key:
Use input as CIPH1 with XOR key as PLAIN1.
Convert it to Hex again. Store the Output.
II. Use the Output as key:
Use the same recipe but replace input with CIPH3 and key for XOR with the saved key , Cyber chef will auto recommend from Hex if you didn’t modify the recipe (you can remove to Hex block, if you want to since it is unnecessary) and there you get the flag.
Flag= trustctf{y0u_d0nt_3v3n_n33d_2_b_sm4rt_4_th15}
Web
2. Breached
Plan:
Inspect the given source, server.py to understand where the flag comes from.
Download runtime_db.csv containing all users.
Use the provided HaveIBeenChowned (HIBC) to find which account was breached.
Reproduce the HMAC-token logic and compute the flag candidate.
Describe alternative checks and next steps if the candidate is rejected.
1) Read the app and identify the flag logic
File: server.py
load users from CSV: load_users_with_meta (path='app/runtime_db.csv') — the CSV stores email,password_hash.
FLAG_SECRET and ADMIN_API_KEY are loaded from .env. Example values found in .env:
· FLAG_SECRET=3HZ0jv5EuC4WHoJnxGKDxuoD9mCkxHMlJz3MucS6U40k7lLdqDqlF2pmeDRT2W
·ADMIN_API_KEY=6208d4e88be3d7a2c6845189a23954420f037a262d13a833b9ace3ef98a35ee0
token generation function:
* token_for_email(email: str) -> str:
* uses hmac.new(FLAG_SECRET.encode('utf-8'), email.encode('utf-8'), hashlib.sha256).hexdigest().
* This produces a 64‑hex character token (SHA256 HMAC hex digest).
In /welcome route:
If session has email, the code computes mytoken = token_for_email(email).
It compares mytoken to FLAG_TOKEN via hmac.compare_digest(mytoken, FLAG_TOKEN).
If equal, the flag string shown is trustctf{<token[:12]>} (first 12 hex chars of the HMAC).So the first 12 characters will be the flag.
Implication: The flag corresponds to the HMAC of the admin email using FLAG_SECRET. If we can identify the admin email, we can compute token_for_email(admin_email) locally (we have FLAG_SECRET from .env) and produce trustctf{<first12>}.
2) Obtain the database:
We have an admin-only endpoint /download_db ,which checks for the ADMIN_API_KEY. We used the ADMIN_API_KEY from .env to send a request to https://tlctf2025-data-app.chals.io/download_db?api_key=<ADMIN_API_KEY> and saved the response as runtime_db.csv.
File has header email,password_hash.
The CSV contains the entries we expect, e.g.:
flaguser@example.com,5e502d72a3d25d7c1a6d056b597c67cd (last line)
blake.baker20@acme.test,1da1bd7ade565168798200ebecabec78
This also confirms ,we have a full export and can iterate over all accounts.
Commands used (example):
Download and save DB:
- requests.get('https://tlctf2025-data-app.chals.io/download_db', params={'api_key': ADMIN_API_KEY})
3) Use HIBC to find the breached admin
The challenge gave https://tlctf2025-hibc.chals.io/ — a local breach API with endpoint /check_email?email=....
Strategy:
Query /check_email?email=<each email> for all rows in runtime_db.csv.
The HIBC response contains fields like {'email': ..., 'pwned': True/False}.
If plaintext_password is present, we could attempt to log in directly.
Scanning:
After scanning all 499 emails the only account that returned pwned as True (breached) which was:
- blake.baker20@acme.test with response {'email': 'blake.baker20@acme.test', 'plaintext_password': None, 'pwned': True}
flaguser@example.com was present in runtime_db.csv, but HIBC returned pwned as false thus, it’s not the breached account per HIBC.
Code used :
with open('runtime_db.csv', 'r') as f:
reader = csv.DictReader(f)
for i, row in enumerate(reader, 1):
email = row['email']
try:
r2 = requests.get("https://tlctf2025-hibc.chals.io/check_email",
params={'email': email},
verify=False, timeout=5)
d = r2.json()
if d.get('plaintext_password'):
print(f"\n FOUND: {email}")
print(f" Password: {d['plaintext_password']}")
print(f" pwned: {d.get('pwned')}")
4) Compute the flag from the admin email
With admin candidate blake.baker20@acme.test and FLAG_SECRET from .env, compute:
token = HMAC-SHA256(FLAG_SECRET, email).hexdigest()
flag = trustctf{token[:12]}
Code used :
import hmac, hashlib
FLAG_SECRET = "3HZ0jv5EuC4WHoJnxGKDxuoD9mCkxHMlJz3MucS6U40k7lLdqDqlF2pmeDRT2W5F"
email = "blake.baker20@acme.test"
token = hmac.new(FLAG_SECRET.encode(), email.encode(), hashlib.sha256).hexdigest()
print(email)
print(token)
print(f"trustctf{{{token[:12]}}}")
Result:
- token = aefefb18de559dc272e7789ba617064886b1f953d953d6e963070ce7dd3bcda1
Flag = trustctf{aefefb18de55}
3. SecureAPI:
We’re given a file named app-public.py
The code clearly shows it can’t be SQLi since it has definitive checks and there’s no way to inject a query here without returning an error.
We have a /register endpoint where we can register a new user .
/login will just give us a token which we could probably use . This seems to be directly pointing at IDOR already so I checked the other endpoints.
The snippet clearly says we need balance above 10000:
resp = {'username': row['username'], 'balance': row['balance']}
if row['balance'] >= 10000:
resp['flag'] = 'trustctf{REDACTED}'
return jsonify(resp)
This is not possible with our account since we get only 100.
You could check admin’s balance using: /api/balance?username=<your registered user>&username=admin
Now in /balance :
query_target = request.args.getlist('username')
if query_target:
actual_target = query_target[-1]
cur = db.execute('SELECT username, balance FROM users WHERE username = ?', (actual_target,))
else:
if not auth_user:
return jsonify({'error': 'missing or invalid token'}), 401
cur = db.execute('SELECT username, balance FROM users WHERE username = ?', (auth_user,))
We notice that it takes only cares about the username but we it treats it as a tuple so we can in fact send two instead of one. So all we need to do is send our newly registered user with admin with our login token and we get the flag.
Flag=trustctf{1n53cur3_0bj3c7_r3f3r3nc3_f7w}
Reversing
4. Gorey:
We have got a chall file .
We know that it is a go file .
A simple graph analysis on IDA Pro revealed the following:
It just showed a looping structure which means I had to cut of certain inputs , upon closer inspection we find EAST and WEST from main.main when we convert it to strings:
Now it was obvious that the maze has two moves atleast. I wrote a pwntool script since it was obvious at this point, that I need to find my way out of the maze by following a certain order of steps.
from pwn import *
import sys
context.binary = elf = ELF("./chall")
context.log_level = "info"
def extract_maze_data():
"""
Reads the binary and extracts the maze string dynamically.
It looks for the top border of the maze.
"""
print("[-] Extracting maze from binary...")
with open("./chall", "rb") as f:
data = f.read()
# The maze starts with a long string of '1's (Top Border).
# We search for a significant chunk (e.g., 60) to be safe.
start_signature = b"1" * 60
start_idx = data.find(start_signature)
if start_idx == -1:
log.error("Could not find the start of the maze in the binary!")
sys.exit(1)
# The maze ends at the 'E' character.
end_idx = data.find(b"E", start_idx)
if end_idx == -1:
log.error("Could not find the end ('E') of the maze!")
sys.exit(1)
# Extract the raw maze bytes
maze_bytes = data[start_idx : end_idx + 1]
return maze_bytes
def bfs_solve(maze_bytes, width):
"""
Attempt to solve the maze with a specific width.
Returns path list if successful, None otherwise.
"""
try:
start_pos = maze_bytes.index(b"S")
end_pos = maze_bytes.index(b"E")
except ValueError:
return None
moves = {
"NORTH": -width,
"SOUTH": width,
"EAST": 1,
"WEST": -1
}
queue = [(start_pos, [])]
visited = {start_pos}
while queue:
curr, path = queue.pop(0)
if curr == end_pos:
return path
for move_name, offset in moves.items():
next_pos = curr + offset
# Boundary checks
if next_pos < 0 or next_pos >= len(maze_bytes):
continue
tile = maze_bytes[next_pos]
# '1' (byte 49) is a wall.
if tile != 49 and next_pos not in visited:
visited.add(next_pos)
new_path = path + [move_name]
queue.append((next_pos, new_path))
return None
def main():
# 1. Extract
maze_data = extract_maze_data()
log.success(f"Maze extracted! Length: {len(maze_data)} bytes.")
# 2. Solve with Width Brute-force
# We suspect the width is around 71 or 72 based on the file analysis.
# We check a range to be sure.
solution_path = None
real_width = 0
log.info("Attempting to brute-force maze width...")
for w in range(60, 100):
path = bfs_solve(maze_data, w)
if path:
solution_path = path
real_width = w
break
if not solution_path:
log.error("Failed to solve maze for any width in range 60-100.")
sys.exit(1)
log.success(f"Solution found! Width: {real_width}, Moves: {len(solution_path)}")
# 3. Save to file
filename = "solution_moves.txt"
with open(filename, "w") as f:
for move in solution_path:
f.write(move + "\n")
log.success(f"Moves saved to {filename}")
# 4. Execute
p = process("./chall")
p.recvuntil(b"It must drive you mad, trying to escape form something you can't see!\n")
log.info("Sending moves...")
for move in solution_path:
p.sendline(move.encode())
p.interactive()
if name == "__main__":
main()
However this generated input as follows :
SOUTH
SOUTH
EAST
EAST
EAST
EAST
NORTH
NORTH ….
This will lead to an error due bad width , but notice a pattern here?... There was no single entry for a move , each move was repeated twice which struck to me so I wrote a script to remove immediate duplicates .
I put that as my input into the challenge and got the following :
Flag : trustctf{y0uv3_35c4p3d_7h3_6l4d3_71m3_f0r_f4z3_7w0}



