NCA CTF Writeup

We had a chance to participate in the NCA CTF last weekend in which we managed to secure the win. Following are some of the solutions to the CTF challenges that were present in the platform.

Web Challenges

RobotBase

Opening the link, we were greeted with a login button that lead to login panel which looked like

As the name of the challenge suggested, the first thing I checked was for robots.txt

The following entries were present in it.

User-agent: *
Disallow: /robot-db/

Browsing /robot-db led to the download of file w0_robotdb.db

Running file command, we get the following output

┌──(root💀adm-StomachUniverse)-[~/ctf/writeups]
└─# file w0_robotdb.db                              
w0_robotdb.db: SQLite 3.x database, last written using SQLite version 3046001, page size 8192, file counter 11, database pages 4, 1st free page 3, free pages 2, cookie 0x5, schema 4, UTF-8, version-valid-for 11

From the file output, it was clear that this was a SQLite database. So I quickly loaded the file on an online SQLite viewer. Looking through the rows present in the table, I stumbled across the most likely credentials which was stored without any encryption/hashing mechanism.

Logging in with credentials fl4g:asudfgr3456_g1v3_m3_th3_fl4g_x435naslkjf, we successfully get the flag.

Epic Glitch

The challenge had a file Epic-Glitch.zip attached to it. Upon inspecting, the zip file seem to contain the source of web application which was a basic Flask application.

The contents of the file app.py were

from flask import Flask, render_template, request, redirect, url_for, session, make_response
import sqlite3
import os
import base64

app = Flask(__name__)
app.secret_key = os.urandom(16)

SECRET_KEY = b'REDACTED' ## REDACTED, SHHHHHHHHHHHH IT'S SECRET

def xor(data, key):
    return bytes(a ^ b for a, b in zip(data, key * (len(data) // len(key) + 1)))

def create_token(username, uid):
    token_data = f'{{"username": "{username}", "uid": {uid}}}'.encode()
    encrypted_token = xor(token_data, SECRET_KEY)
    return base64.b64encode(encrypted_token).decode()

def decode_token(token):
    encrypted_token = base64.b64decode(token)
    decrypted_token = xor(encrypted_token, SECRET_KEY)
    return decrypted_token.decode()

def get_db_connection():
    conn = sqlite3.connect('ctf.db')
    conn.row_factory = sqlite3.Row
    return conn

@app.route('/')
def index():
    return render_template('dummy.html')

@app.route('/register', methods=['GET', 'POST'])
def register():
    if request.method == 'POST':
        username = request.form['username']
        password = request.form['password']

        if username == 'admin':
            return "Username 'admin' is already taken.", 400

        conn = get_db_connection()
        conn.execute("INSERT INTO users (username, password, uid) VALUES (?, ?, ?)",
                     (username, password, 1))
        conn.commit()
        conn.close()

        return redirect(url_for('login'))

    return render_template('register.html')

@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        username = request.form['username']
        password = request.form['password']

        conn = get_db_connection()
        user = conn.execute('SELECT * FROM users WHERE username = ? AND password = ?', (username, password)).fetchone()
        conn.close()

        if user:
            token = create_token(user['username'], user['uid'])
            response = make_response(redirect(url_for('dashboard')))
            response.set_cookie('auth_token', token)
            return response
        else:
            return "Invalid credentials", 403

    return render_template('login.html')

@app.route('/dashboard')
def dashboard():
    auth_token = request.cookies.get('auth_token')

    if not auth_token:
        return redirect(url_for('login'))

    try:
        user_info = decode_token(auth_token)
        # the uid is 0 below
        if '"uid": 0' in user_info:
            return "nca{f4k3_fl4g_f0r_t35t1ng}"
        else:
            return "What are you looking for? The flag? :D"
    except Exception:
        return "Invalid token", 400

if __name__ == '__main__':
    app.run(debug=False)

So in a nutshell, the file contained logic of different pages within the website like login, register, dashboard pages etc. Looking at the dashboard() function, we find out that if we have auth-token cookie in our request whose decoded value contains uid with value 0 we will get the flag. So with this, I started to look at the register() logic and I found out that all the default users were getting id of 1.

 conn.execute("INSERT INTO users (username, password, uid) VALUES (?, ?, ?)",
                     (username, password, 1))

Once the user was created, we could login using the credentials. The login function called create_token() function to create auth token using the user information recieved from database. Inspecting the create_token() function we see that, the user information was being XORED and then base64 encoded.

def create_token(username, uid):
    token_data = f'{{"username": "{username}", "uid": {uid}}}'.encode()
    encrypted_token = xor(token_data, SECRET_KEY)
    return base64.b64encode(encrypted_token).decode()

So the plan here is simple, we need to create a new user and generate token. The token would be ciphertext while the plaintext would be `{{"username": "{our_username}", "uid": {uid}}} . Once we have the ciphertext and plaintext we can easily retrieve the key.

So I created a new user with username nepal and got this token

C0cHABUXDRUEE0dKRVAdFRUCGEtaRVIQGxdSX0NFFA== which is our ciphertext. Meanwhile, our plaintext now is {{"username": "nepal", "uid": 1}. To get the key, we can first base64 decode our token and XOR the result with our plaintext. Following script does the operation.

import base64

token = "C0cHABUXDRUEE0dKRVAdFRUCGEtaRVIQGxdSX0NFFA=="
decoded_token = base64.b64decode(token)

def xor(data, key):
    return bytes(a ^ b for a, b in zip(data, key * (len(data) // len(key) + 1)))

plaintext_str = f'{{"username": "nepal", "uid": 1}}'
plaintext = plaintext_str.encode()
key = xor(decoded_token, plaintext)
print(f": Key {key}")

Running the above script we, get result as : Key b'perspectiveperspectiveperspecti'

Here’s a cyberchef recipe to do the same.

Thus our key is perspective Now using the key we can forge a new token with with uid 0 using our new found new found key. So simple we XOR {"username": "nepal", "uid": 0} with key perspective. Notice the uid has been changed to 0. Then we simply base64encode it. The cyberchef recipe to perform this operation is here.

Once we have forged the token, we can replace it in our auth_token to recieve the flag

My Name is Marie Biscuit V2

The link contained a simple login panel. Using the credentials provided in the description, I was able to login and got this screen.

From the challenge name, it looked like a challenge related to cookie, so I started checking out the cookie. The cookie contained a JWT token.

I double checked using the jwt.io to ensure it was a JWT token.

Looking at the payload field, I guessed that if we somehow managed forge a token with username admin we would probably get the flag. For this we needed the secret key that was used to sign the JWT token. So, I launched JohnTheRipper with the rockyou.txt wordlist and started bruteforcing. After few seconds, I got the following output.

┌──(root💀adm-StomachUniverse)-[~/ctf]
└─# john jwt.txt --wordlist=~/wordlists/rockyou.txt
Using default input encoding: UTF-8
Loaded 1 password hash (HMAC-SHA256 [password is key, SHA256 512/512 AVX512BW 16x])
Will run 32 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
cybernepal       (?)     
1g 0:00:00:04 DONE (2024-09-25 01:33) 0.2272g/s 2025Kp/s 2025Kc/s 2025KC/s dany12_..crompond
Use the "--show" option to display all of the cracked passwords reliably
Session completed.

So the secret used to the sign the token was cybernepal. With this I created a new token with username admin on the payload field.

Using the newly forged token on the cookie, I was able to get the flag.

┌──(root💀adm-StomachUniverse)-[~/ctf]
└─# curl -H "Cookie: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNzI3MjMxMjQzfQ.ssSTMaEIJYABTNX50wSSo2-XNHvzrZ4nOtnvBJHlGuw" http://147.79.67.234:2332/ | grep nca
        <h3>nca{m4rr13_b15cu1t_l0v3rs_h3r3_b0155}</h3>

Mero Kitab

Opening the link, we see the following prompt that asks to specify a username in base64 format.

Clicking on the example, a GET request is made to

http://147.79.67.234:3223/?username=aGFyaWJhaGFkdXI=

The username consisted the string haribahadur in base64 format and the output of page looked like.

I quickly base64 encoded hello and tested it.

http://147.79.67.234:3223/?username=aGVsbG8= and got similar result like the previous one that said No kitab available for you

Trying base64 encoded admin i.e. YWRtaW4= led to error which was pretty suprising. URL encoding the last character still led to error.

I tried appending to the encoded value thinking it could be some form of SQL Injection and suprisingly got the flag.

http://147.79.67.234:3223/?username=YWRtaW4='

At the time of writing this writeup I have noticed that, the vulnerability probably has nothing to do with SQL injection. As all of following payloads lead to the flag.

GET /?username=YWRtaW4%3d

GET /?username=YWRtaW4%

Another pattern I found was admin encoded in base64 + special character also led to flag. Examples:

GET /?username=YWRtaW4=$

GET /?username=YWRtaW4=@

GET /?username=YWRtaW4=.

My guess would it be adding the characters somehow bypassed the admin blacklisting.