Skip to main content

Command Palette

Search for a command to run...

TrustLab IITB 2025 - Prelims Writeup

Updated
9 min read

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):

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}