b01lers hosts a series of CTF bootcamp workshops that serves an introductory crash course on CTF topics (web, rev, crypto, and pwn) and basic techniques for new members every fall semester. Bootcamp CTF is then held afterwards, as some sort of a 'final exam' of the course.
Bootcamp CTF was the first CTF competition that I participated (it was in 2022, and I went with enigcryptist, markroxor, and Abe), and I have been an (try-my-best-to-be) active member of b01lers ever since then. This CTF hence means a lot to me. I also personally think the quality of problems in b01lers Bootcamp CTF surpasses any other beginner-friendly CTFs, and they represent a good range of various topics and difficulties.
The competition took place in Oct 21st from 12-5 PM. I could not solve many problems on time due to the tight timeframe (I joined one hour late and had to leave early). I however still solved some more problems after the competition ended. This writeup hence may not be a faithful representation of my performance during the competition.
Here is the link to the competition website: https://bootcamp.b01lers.com/. There is no CTFTime event page as it is not a public CTF, it is only open to Purdue students.
Crypto
buzzy_bee
What's all the buzz about? Can you tell me how many years it's been since the bees have had a day off?
The first few lines of cipher.txt file looks like this.
This looks like a typical substitution cipher problem. I don't know if this was done intentionally, but surprisingly most of the online substitution cipher decoders either crashes or cannot solve this problem correctly. But this one can: https://planetcalc.com/8047/:
Scrolling and ctrl+f-ing through the decoded sentences, you should be able to find this sentence:
Flag: TWENTY SEVEN MILLION YEARS
drm_pad
We are offering a one-time deal -- get a free sample today! But don't get greedy.
Pay attention to the following lines in product.py:
All the product keys (product_keys) are encrypted by being XOR-ed with the same manufacturer key (manufacturer_key) as the secret key, and they are stored as serial numbers (serial_nums) for each product. We can hence retrieve the manufacturer key by XORing the serial number and product key for the free sample. We can then get the serial numbers for all the remaining items and compute their serial numbers by XORing them with the manufacturer key.
Then just plug them into the console (I promise you that I will use pwntools next time).
Flag: flag{n3v3r_r3u53_x0r_k3y5_0r_4cc3p7_drm}
tag_check
I found someone's baggage claim tag on the ground. I wonder if I can do something with this...
We have a textbook-RSA digital signature scheme, where tags are computed as the digital signature of the id of bag: \( \texttt{tag} = \texttt{id}^d \; \text{ mod } N \) (where \( N\) is the flight number, in this problem).
My initial approach was to use the forgery attacks that we learn in cryptography courses (i.e., Topic 23 of this class) until I wasted an hour on it (because this isn't quite the forgery problem technically, since we are not 'forging' any signature but finding the message where they collide) and noticed that there is a much easier way than that. Let \( \texttt{id} \) be the signature. Given \( \texttt{tag}\), we can retrieve \( \texttt{id} \) because
\[
\texttt{tag}^e = (\texttt{id}^{d})^e = \texttt{id}^{de} = \texttt{id} \; \text{ mod } N
\]
(We technically do not even need to do that because we already have \( \texttt{id} \) already.)
But the lesson (?) is that, if we have \( \texttt{id}\), since we are \( \text{mod}\)-ing with \( N\), the signature of \( \texttt{id} + N \) would be the same as the signature of \( \texttt{id} \), which is \( \texttt{tag} \).
\[
\begin{align*}
\texttt{tag}'
& := (\texttt{id} + N)^d \; \text{ mod } N \\
& = \texttt{id}^d \; \text{ mod } N \\
& = \texttt{tag}
\end{align*}
\]
Of course \( \texttt{id} + N \equiv \texttt{id} \;\;(\text{mod } N) \), but note that tag.py does not take \( \text{mod } N\), it just checks whether they are exactly the same or not). So we just need to compute and feed \( \texttt{id} + N \) into the console.
This is an AES-CBC-based MAC scheme. We want to find two messages where the two tags collide just like in the previous problem.
Notice that server.py encrypts the message as follows:
It first pads the message to make its length a multiple of 16; if it is already a multiple of 16, it appends 16 more bytes. It then encrypts it with a key generated randomly, and the IV which is 16 zero bytes.
With that in mind, let's quickly recall how AES-CBC encryption works:
Let our plaintext \( m = 00...00 \) be 16 \(00\)'s. It will then padded as 16 \(00\)'s and 16 \(16\)'s (\( 10\)'s, in hex) \( m_{\text{pad}} = 00...00 \| 10...10\). Each block should have length 16 since it is AES-CBC. The block of \( m\) would be \( m_1 = 00...00\) and the second block would be \( m_2 = 10...10\). Then the corresponding ciphertext blocks \( c_1\) and \( c_2\) would be:
The goal is to find \( m' \neq m \) such that its last ciphertext block is the same as \( c_2 \). For simplicity, consider \( m' \) whose length is also a multiple of 16 (and it turns out this is sufficient --- stay tuned). To do this, its second-last ciphertext block should be \( \text{Enc}_k(00...00) \) since its padding would again be \( 10... 10 \), and \( 00...00 \) would be generated when the IV (or the previous ciphertext block) is the same as the current plaintext block, because XOR of two identical string is a string of zeroes. Hence, we can simply craft \( m' \) as an extension of \( m_{\text{pad}}\) as follows:
\[
m' = m_{\text{pad}} \| c_2 = m_1 \| m_2 \| c_2 = 00...00 \| 10... 10 \| c_2
\]
Then we will have \( m'_{\text{pad}} = m_1' \| m_2' \| m_3' \| m_4' \) such that
\[ m_1' = 00...00, m_2' = 10...10, m_3' = c_2, m_4'= 10...10 \]
and \( c' = \text{Enc}_k(m') = c'_1 \| c'_2 \| c'_3 \| c'_4 \) would be:
\[
\begin{align*}
c'_1 & = \text{Enc}_k(m_1' \oplus \texttt{IV}) = \text{Enc}_k(00...00) \\
c'_2 & = \text{Enc}_k(m_2' \oplus c_1') = \text{Enc}_k(10...10 \oplus \text{Enc}_k(00...00)) = c_2 \\
c'_3 & = \text{Enc}_k(m_3' \oplus c_2') = \text{Enc}_k(c_2 \oplus c_2) = \text{Enc}_k(00...00) \\
c'_4 & = \text{Enc}_k(m_4' \oplus c_3') = \text{Enc}_k(10...10 \oplus \text{Enc}_k(00...00)) = c_2
\end{align*}
\]
We can test this algorithm is indeed correct for all \( k \) follows:
A slightly funny story here: This is one of the problems that I could not solve due to the timeframe. In the next meeting after this CTF, enigcryptist described his solution to this problem, which apparently was the same as mine algorithm-wise. Getting confused, I came back to my place and checked my code, it turns out I forgot to add the padding for Plant 2 --- I just defined padded_message2 = padded_message1 + cipher1[-16:] without padding it. Maybe, for the next CTFs, I should just give it a shot first, before coding up and testing my algorithm actually works unless absolutely necessary.
Web
b01lers Entrance Exam
pass this entrance exam to begin your ctf career. flag is in 5 parts.
Beginner friendly!
The website at first sight doesn't have anything meaningful other than the link to rickroll (it is meaningful because it's funny, lol). The first part of the flag turns out to be hidden inside the website.
The second part is in the CSS file:
The third part is in the JS file (refresh it if it does not show up):
The server responds with the fourth part of the flag to POST requests.
Last but not least, the fifth part is in the hidden directory that can be found in the robots.txt file.
First, go to the page for webnote and create an account (just type anything for the username and password). I created some notes by randomly filling out the form, then I noticed that each notes are created with the following URL format: http://ctf.b01lers.com:8006/notes/n where n is some number. For instance:
This suggests that we can possibly read someone else's posts by changing n. And it turns out, when n=3,
I am a little bit surprised that we did not have to use the other webpage (admit bot one) but I guess that's for the continuation of this challenge (see next section) and it is just left here as another layer of complication.
Suspicious Note
I noticed there were some suspicous notes on my note service, so I hired an admin to review any reported notes.
I also gave them my other secret flag to store in their private notes.
So it appears that the admin bot is just some code that takes a number n (post number) as an input and checks (visits) http://ctf.b01lers.com:8006/notes/n (assuming we can trust its return message "Admin will review the post").
Based on the challenge prompt, the flag is hidden inside one of the private notes that are visible only to admin. I first tried making a new account with username "admin" but the website won't let me.
I tried to figure out how the website figures out which user is which. This is important because otherwise my private note can be visible to the other user. After playing around with the developer tools, I noticed that it is the cookie value user_id. For example, I created the two accounts and they have the different user_id values:
I tried it on my another laptop and they remain the same as long as the usernames are the same. Also, I was able to move from my one account to the other simply by changing the user_id without logging in with the password, so it is pretty clear that user_id is the one that differentiates users.
Hence, we should figure out a way to 'hijack' the user_id of admin. This can be done easily with webhook (for 'eavesdropping' HTML requests) and a JS script that grabs the document.cookie of the visitor.
Then, once we give its post number (which in my case was 112) to the admin, then upon their visit, their cookies will be transmitted to our webhook page.
There it is! We get the new user_id value that definitely do not belong to us: Gf%2FXmf6yVjIrb%2FYTKYXjQJ+67LLPgjtF6+S5tVXQIgo%3D2. Changing our user_id value to that value on the developer tools gives us access to the someone else's account, and that turns out to be admin!
Going over admin's private posts, we can discover that the flag is inside the post name "Admin Key."
Start by putting that sosh executable file into Ghidra. The main() function looks like this.
It is using the scanf function to scan the password, which does not check the length of the input, and stores it in local_38 which is 47 bytes long. The password checking mechanism simply checks whether local_9 == '\0' or not.
Too easy, then. We can just overwrite local_9 with something else other than '\0' by overflowing local_38 by writing just one more than its length (47).
Looking at the code and interacting with it a bit, you will realize that the program itself has nothing to do with flags (i.e., passing all the tests won't give you the flag). So, the first thing you could try would be to have the program print the flag by putting f = open("flag.txt", "r"); print(f.read()).