Back to blog
Sep 15, 2025
18 min read

My First CTF on the UTD Competitive Team

My experience and writeups for the FortID CTF 2025 as part of CSG, one of the best academic CTF teams in the country

This weekend was my first official CTF on the UT Dallas Competitive CTF Team hosted by the Computer Security Group (CSG).

We competed in the FortID CTF against 553 teams and achieved 20th place with 14/32 challenges solved and 3,198 points (Top 10 cutoff was 4,830 and 1st place had 8,546).

My Experience

This was a 48 hour competition hosted by the employees of Blockhouse Technology in Zagreb, Croatia.

There were 6 categories (Intro, Crypto, Rev, Misc, Web, & Pwn) and a total of 32 challenges, split pretty evenly between the categories. I focused on Crypto and Misc. (OSINT & Stego), but made sure to take a peek at every challenge.

I started working on the challenges individually midday Friday, about 4 hours after they were released. I still had classes, clubs, and other work to attend to, so I was working on them off and on all day. 10 PM to around 3 AM was when I was finally able to work uninterrupted and make some serious progress with the challenges. I got started again at 10 AM the next day and then joined my team around 12:30 PM. We kept working till around 9:30 PM. So I put probably just under 20 hours into this competition.

Writeup

My first solves were in the Intro category as I started working on the CTF shortly after it opened, but a little earlier than most of my team.

Intro - Info

“Take your time and read the information on our homepage.”


This was a very easy challenge. Embedded in the background of the FortID homepage was very faint text that became clearer the longer I had the site open (while I supposedly read the rules
 or maybe I just viewed the source and searched for the FortId{ prefix
).

Very beginner Web CTF stuff.

FortID CTF Homepage

After this, I started to work on the OSINT Exam challenge (see later), but after cracking the first of the six images and sharing my findings with my team, I decided to circle back around and knock out some more of the Intro challenges.

Intro - Rev

“In order to find the flag, you must first search for the flag.”


My next solve was a reverse engineering challenge.

I was provided a binary ./chall which I decompiled with Binary Ninja and I ran it:

\$ ./chall
Enter flag: flag
Nope

Looks like a “password” reversing challenge, where the flag is somewhere in the binary. Probably obfuscated somehow.

I noticed in the disassembled pseudocode my input was being encoded as a sequence of <>= characters (in any order) and compared to a sequence hardcoded in the program as a string <><<<>>=<>>=<>>><<>=<>>><><=<><<
.

The encoding process took each character and it would perform binary search to find the character, starting at the midpoint between 0x00 and 0xff. Every time the search discovered the inputted character was higher than the midpoint it would encode a <.> would be encoded if it was lower and = would only be encoded when the character was found. Then it verified the encoded sequence matched the hardcoded sequence and would output “Correct!” or “Nope” depending on if the sequences matched.

I knew the process and the hardcoded sequence, so by undoing this process I knew I could reveal the string we needed to input (the flag) to have the program succeed.

So I wrote a quick little Python function:

def decode(sequence):
    bytes_out = []
    for group in sequence.split('=')[:-1]:  
        low, high = 0, 255
        for ch in group:
            mid = (low + high) >> 1
            if ch == '<':
                high = mid - 1
            elif ch == '>':
                low = mid + 1
        target = (low + high) >> 1
        bytes_out.append(target)
    return bytes(bytes_out).decode('latin-1')

Which I used to process the hardcoded sequence from Binary Ninja and produce the flag.

Not too bad. I really liked this “binary search cipher”.

Intro - Web

“In tangled webs of code we weave, JavaScript whispers, making pages breathe, Turning static dreams into worlds that live.”


Immediately after cracking the reversing challenge I went onto do the web challenge which provided a index.html file for me to analyze. Opening the file in my browser just showed a dark background with a dark rectangle and a slight shadow around it.

Looking at the source code itself, I found:

(function()
{ // You can skip this part, there’s an easier way: 
const G=V

which was almost convincing. I found it was heavily obfuscated with pointless loops and random functions, but I ran the source code through a JS beautifier to help with the obfuscation and discovered

const KEY = Uint8Array.of(...);
const ENC = Uint8Array.of(...);

which were defined as hardcoded arithmetic expressions rather than simple values.

and later:

const DEC = new Uint8Array(ENC.length);
for (let i=0; i<ENC.length; i++) {
    DEC[i] = ENC[i] ^ KEY[i % KEY.length];
}
const FLAG = new TextDecoder().decode(DEC);

Which is a simple XOR between these two arrays.

I copied the arrays into a Python script where they were evaluated and xored together, producing the flag.

Crypto - Cascader

“Just found this super cool key exchange protocol while scrolling Hacker News between meetings

It’s based on some clean recurrence math — none of that dinosaur-era number theory stuff finally, crypto that doesn’t look like it was invented in the 70s

It came with a working implementation too, so i plugged it right in and shipped to prod

A few people said I should’ve used something more “proven” but honestly
 this just feels right

Anyway, i packaged it into a challenge. Curious to see what the skeptics say now”


This challenge came with 3 files.

cascader.pdf, a research paper on a novel key-exchange protocol by Anders Lindman

chall.js, which implemented the key-exchange protocol.

output.txt, with a redacted key-exchange protocol:

Alice private  <REDACTED>
Bob private  <REDACTED>
Alice public  81967497404473670873986762408662347640688858544889917659709378751872081150739
Bob public  25638634989672271296647305730621408042240305773269414164982933528002524403752
Alice Shared  <REDACTED>
Bob Shared  <REDACTED>
Alice's and Bob's shared secrets equal?  true
ct (hex):    e2f84b71e84c8d696923702ddb1e35993e9108289e2d14ae8f05441ad48d1a67ead74f5f230d39dbfaae5709448c2690237ac6ab88fc26c8f362284d1e8063491d63f7c15cc3b024c62b5069605b73dd2c54fdcb2823c0c235b20e52dc5630c5f3

This appears to be a Diffie-Hellman-style exchange between our old friends Alice and Bob.

Crash Course: Cryptography & Key-Exchange Protocols

Basically, Alice and Bob are our go-to characters for participants in cryptographic protocols. One important type of protocol are key-exchange protocols where the participants share a secret code word. The problem is we assume any communication between Alice and Bob can be listened into by an attacker (typically named Mallory, for malicious, or Eve, for evil). So we need a way for them to both have this secret code word, without ever telling each other.

You might wonder why can’t they communicate privately? Why don’t they just encrypt their messages when they share this code word? Well, a key exchange is essential to encryption. Bob can’t decrypt Alice’s messages without knowing the secret key she used to encrypt that message.

You might be familiar with a Caesar cipher, where letters are shifted by a certain number (e.g. 3. A → D, B → E, C → F 
). If Alice sends an encrypted message to Bob like

Olssv Dvysk

Bob would have no idea what this means unless he knew the secret number. For this message, the secret number is 7, so Bob can unshift the original message by 7 to reveal what Alice sent.

Olssv Dvysk
Hello World

Alice and Bob had to share the 7, “the secret code word” or secret key, in order to be able to communicate through encrypted means.

Now, a Caesar cipher isn’t a secure encryption method. The private key only has 25 options (excluding the 0 or 26 shift which would leave the message unchanged) so it can easily be broken by trying all possible keys like this on CyberChef. However even with algorithms that cannot (yet) be brute-forced like Advanced Encryption Standard (AES), the cipher used to secure a lot of data today, requires a shared secret key to perform its operations.

So we can’t encrypt communications between Alice and Bob because we need to exchange a key to encrypt communications. Now, how do we do this safely, especially if we assume Mallory is intercepting our unencrypted messages?

Short answer: math.

Diagram of diffie-hellman key-exchange

Alice and Bob don’t actually exchange this shared secret key in public at all. Rather, they share just enough information to let the other participant produce the secret key when combined with some secret information each participant keeps private. Mallory can grab the information shared publicly, but because she never has the secret information so she can’t create the secret key herself. And that’s the idea behind the Diffie-Hellman exchange. This video on DHKE does a good job explaining both the intuition and the math behind this protocol.

The important thing is we have a method share keys securely and, more importantly for this challenge, the Cascader key exchange protocol is not a secure method.

Breaking the Cascader

The Cascader has Alice and Bob pick their secret numbers (private keys) and a shared starting number called the seed. They combine their secret numbers with this seed using function called LinearRecurrence with the seed. They swap these public numbers and use their own secret again with this LinearRecurrence, seeded by their counterparty’s public number, to produce the same secret key, without ever sharing their secret numbers publicly.

This sounds just like Diffie-Hellman. So where does it go wrong?

Well, we don’t actually need the secret numbers to figure out the secret key
 oops.

function linearRecurrence(seed, exponents) {
    let result = seed;
    let exp = 1n;
    while (exponents > 0n) {
        if (exponents % 2n === 1n) {
            let mult = 1n;
            for (let i = 0; i < exp; i++) {
                result = 3n * result * mult % MOD;
                mult <<= 1n;
            }
        }
        exponents >>= 1n;
        exp++;
    }
    return result;
}

So the 1n, 2n, 3n stuff is just how JavaScript handles big integers. Like how 1, 2, 3 needs to be 1.0, 2.0, 3.0 to have floats, we need 1n, 2n, 3n to have big integers of that value, but you can just ignore the suffixes.

We have the seed (the initial value), exponents (which we use as an iterator), and MOD (a large prime number). After initializing the variables, our main loop processes exponents bit-by-bit. It runs by dividing exponents by 2, until it reaches 0. So it makes $log_2(\text{exponents})$ iterations and each time exp increments up from 0 to this number.

Then within the loop it checks whether the current bit is 1 (if (exponents % 2n === 1n)), and if it is we perform an unusual operation. We define mult which is initially 1, but grows by doubling 1, 2, 4, 8 .... In a new loop, it takes result, which initially stores the seed, times 3 times mult modulo MOD, and does this exp times.

Basically this whole process decomposes exponents into its bits, and performs this “inner multiplication and doubling” procedure a certain number of times, depending on the location of the 1s. This looks complicated and is confusing, but it has predictable patterns. And patterns are the problem cryptography is tasked with solving. Good cryptography destroys patterns irreversibly and bad cryptography leaves a trace we can follow.

The Math Behind the Attack

This complicated product (with the doubling or tripling), lets call it $P(x)$, actually preserves linear relationships and is limited to produce results of the form $(3^i \cdot 2^j) \mod \text{MOD}$.

So the LinearRecurrence function produces something like:

$$ \text{LinearRecurrence}(\text{seed}, x) = \text{seed} \cdot P(x) \mod \text{MOD} $$

We ran this function first to produce the public numbers for Alice and Bob:

Let’s say A and B are Alice and Bob’s public numbers and a and b are their secret numbers.

Then we generate A and B with

$$ \text{LinearRecurrence}(\text{seed}, a) = \text{seed} \cdot P(a) \mod \text{MOD} = A $$

$$ \text{LinearRecurrence}(\text{seed}, b) = \text{seed} \cdot P(b) \mod \text{MOD} = B $$

To get the secret key, Alice will seed LinearRecurrance with Bob’s public number and run it again.

$$ \text{LinearRecurrence}(B, a) = B \cdot P(a) \mod \text{MOD} $$

Note that $B = \text{seed} \cdot P(b) \mod \text{MOD}$ so

$$ (\text{seed} \cdot P(b) \mod \text{MOD}) \cdot P(a) \mod \text{MOD} = \text{seed} \cdot P(b) \cdot P(a) \mod \text{MOD} = \text{Secret key} $$

Bob can do a similar operation with Alice’s public number.

$$ \text{LinearRecurrence}(A, b) = A \cdot P(b) \mod \text{MOD} = \text{seed} \cdot P(a) \cdot P(b) \mod \text{MOD} = \text{Secret key} $$

And get the same secret key. Pretty cool. But is it secure?

Remember we have the seed and their public numbers. We can multiply these public numbers together.

$$ A \times B = \text{seed}^2 \cdot P(a) \cdot P(b) \mod \text{MOD} $$

We can find the modular inverse of the seed to basically undo the multiplication.

$$ (A \times B) \times \text{seed}^{-1} = \text{seed} \cdot P(a) \cdot P(b) \mod \text{MOD} = \text{Secret key} $$

So using public information (A, B, and the seed) and a little math, we can recreate the secret key which means we can break the whole security of the key exchange.

Implementing the Attack

From here implementing this is fairly simple. We just need to grab the data from output.txt. Apply Fermat’s Little Theorem. And then decrypt the ciphertext with AES.

import re
from pathlib import Path
from hashlib import sha256
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

txt = Path("output.txt").read_text()

alice_pub = int(re.search(r"Alice public\s+([0-9]+)", txt).group(1))
bob_pub   = int(re.search(r"Bob public\s+([0-9]+)", txt).group(1))
ct_hex    = re.search(r"ct \(hex\):\s*([0-9a-fA-F]+)", txt).group(1)

KEY_SIZE_BITS = 256
MAX_INT = 1 << KEY_SIZE_BITS
MOD = MAX_INT - 189
SEED = MAX_INT // 5

inv_seed = pow(SEED, MOD-2, MOD)
shared = (alice_pub * bob_pub * inv_seed) % MOD
shared_bytes = shared.to_bytes(32, "big")

aes_key = sha256(shared_bytes).digest()

data = bytes.fromhex(ct_hex)
iv = data[:12]
tag = data[-16:]
ciphertext = data[12:-16]

aesgcm = AESGCM(aes_key)
try:
    plaintext = aesgcm.decrypt(iv, ciphertext + tag, None)
    print(plaintext.decode('utf-8'))
except Exception as e:
    print("Decryption failed:", e)

Intro - Misc

This picture seems oddly familiar
 but something about it feels ever so slightly off.


I didn’t actually solve this one. A teammate actually finished it first, but I was really close.

We were given about-us.webp

About Us dot Webp

I was able to reverse image search and find a LinkedIn post of the same image.

About Us dot Jpg

Which visually appeared to be the same image, but applying a filter like what I found here

About Us differences

Little dots appeared indicating tiny modifications throughout the image. I thought had a vague idea about the steganography going on, but also these looked like possible normal photo edits. Or maybe some small edits were made to the binary to hide the flag. I wasn’t sure, but I stepped away from it to work on something else and in that time my teammates cracked it.

They used a random website with a the same photo taken from the company website instead of LinkedIn which immediately revealed the flag.

I had the right idea, wrong image. The differences in resolution resulted in those tiny dots, where if I had the right image and checked their differences I would’ve seen the flag.

Misc - Meta 2.0

“Data science is old news, kids today are all about metadata science


https://fortid-meta.chals.io/”


This isn’t much of a writeup. I got lucky.

This came with handout.zip which had a Dockerfile for setting up the environment, app.py with python source code for this web page which apparently had an arbitrary file write vulnerability and some other stuff.

This web page allows you to upload a file and it produces the metadata from that file.

FortID Meta 2.0 Challenge Webpage

And so I started just uploading random files I had to just poke around. And so I uploaded stuff from this challenge and other challenges like app.py, Dockerfile, requirements.txt and about-us.jpg.

Uploading about-us.jpg got me the flag.

FortID Meta 2.0 Solved

I’m not sure why, but I’m not complaining.

Misc - Hard Rock

”Such an Ohio-core song, giving sigma rizz to a lore-drop nobody asked for.

Flag format: FORTID{...}”


We were given secret.mp3, which means this is likely an audio steganography challenge. It had some music with a constant high pitched sound in the background and it was just over 17 minutes long.

I used Sonic Visualizer and discovered the high pitched sound was in channel 1 (left) and the music in channel 2 (right). Looking at 10,000 Hz in a spectrogram I could a strong constant pitch and then about 600-700 Hz above and below this was a faint pattern.

Spectograph with morse code

Morse code. 17 minutes of it. Non-audible, barely visible.

I used some python to generate and split the spectrogram into images, each 30 seconds long. I tried inputting the images to visual morse code solvers and when that I didn’t work I attempted various enhancement methods, but wasn’t quite able to automate it.

I decoded the first few letters “PLUSPLUSPLUS” and realized this was an esoteric programming language, BrainF***. A teammate took over trying to enhance and automate processing the images while I started decoding on and off by hand. Fortunately they managed to automate and solved this one before I finished doing it by hand.

Misc - OSINT Exam

“Can you pass our OSINT certification exam?

nc 0.cloud.chals.io 27689”


We were handed a zip folder with 6 images and the app running on that port posted an OSINT question and the answer format. Each question had to be answered before moving onto the next image.

Location 1

OSINT exam location Q: One of our branch headquarters is located @ {location1.png}. What is the full name of that coworking space and who is the managing director of that branch?

Format: <name_of_coworking_space> <managing_director_name> <managing_director_surname>


A reverse image search immediately revealed several posts that this was the LHoFT. That is the Luxemburg House of Technology (great name!). I misunderstood the question and I was looking for the managing director of LHoFT, but couldn’t find anyone with that title.

My teammates found the managing director of Blockhouse Technology at the LHoFT branch pretty quickly on the LHoFT website.

Location 2

OSINT exam location Q: One of our branch headquarters is located @ {location2.png}. What is the full address of that building?

Format: <house_number> <street_name> <town> <postcode> <country>


Initially, I thought it was the Kipling house due to a reverse image search, but I didn’t have the question at that time (before we solved Q1). Since we know we’re looking at Blockhouse Technology branches and the architecture appears to match several locations in the UK/Oxford, I tried a little google dorking. Searching “Blockhouse technology” UK office located the LinkedIn page with the correct address (linkedin). My teammates cracked this one first using similar methods.

Location 3

OSINT exam location Q: Our branch with the best BBQ is located @ {location3.png}. What is the full address of that building?

Format: <street_name> <house number> <postcode> <town> <country>


My teammates also cracked this one, while I was working on Intro - Crypto.

Reverse image search yielded nothing. We already had two offices in the UK and Luxembourg, but the Zagreb office in Croatia hadn’t been mentioned. Just a little Google Dorking was all it took to find the address, which happened to be correct.

Location 4

OSINT exam location Q: Two of our team members ran a road race together earlier this year @ {location4.png}. What city was the race at and what were their finish times (hh:mm:ss)?

Format: <city> <slower_result> <faster_result>


My teammates had found a website to lookup race times. And they had the Blockhouse Technology company page on LinkedIn. They started checking employees, but ran into an issue. Many accounts had blank photos and names redacted as “LinkedIn Member”. But I knew what to do. I popped it open under my account and everyone’s names and images popped up. Even though they were all the way across the world in Croatia, some were 3rd degree connections. I suspect due to being connected with a few people from Romania who I interviewed for my podcast. Because of this link (or some other random connection), LinkedIn permitted me access to their profiles.

Yeah. You need to be LinkedIn-maxing for OSINT challenges.

After trying a couple names, we tracked down the guy on the left. His LinkedIn profile picture was a bad angle and he had no facial hair and looked younger, so it took longer than it should’ve and we weren’t even sure it was him.

I used a facial recognition website to find a similar image, but the site only shared the top level domain unless I paid. But that was enough, with some Google Dorking. I searched: site:https://ioinformatics.org/ "ivan paljak" and only got 1 search result.

IOI Profile

His profile for the International Olympiad in Informatics, which matched a clearer picture to his name and his LinkedIn profile. We pulled his time from the race website and started looking for his friend.

But all of our previous methods failed.

We were convinced he had no online presence.

But then my teammates noted an interesting username on the FortID CTF discord server.

ipaljak. An Admin. Ivan Paljak was in the discord.

The Blockhouse staff are the ones running the CTF. Its very possible that the other guy in the photo is also an Admin. We started cross referencing the discord Administrator usernames with the staff until one name hit on the race website.

Race Photos

And there he was. We grabbed the times and moved on to the next question.

Location 5

OSINT exam location Q: The squad from our Zagreb office took part in a local charity futsal tournament in 2024 @ {location_5.png}. What was the tournament called and what place did they win?

Format: <tournament_name> <place_won>


My teammate, Celina, cracked this one. In her own words:

“For 5, I found it through the e student hint in the photo. E student is a student organization that organizes the Kopacka Solidarnosti futsal tournament that happens to go to a non profit. The website only had information on the tournament from 2023 but some google dorking led me to a LinkedIn page from the winners of the 2024 tournament confirming there was a tournament that year. Then just guessing and checking the placements 😆”

Location 6

OSINT exam location Q: One of our senior members has an artistic side to them as you can see from the beautiful flower @ {location6.png}. What is the name of the book with that member's self-portrait on its cover?

Format: <book title>


This one actually was probably the easiest. We already had the senior staff from browsing the Blockhouse Technology website. Basic Google Dorking by searching: “insert name” book immediately found the textbook.

And closed a very fun and challenging OSINT series. OSINT are one of my favorite categories because they are some of the most collaborative CTF challenges and everyone seems to have a unique approach that can contribute to the search.