b01lersCTF 2024 Writeup
This weekend (Apr 12-14, 2024), I was honored to work with such great people as a b01lers CTF challenge author. It was my first time writing CTF challenges. I learned a lot, arguably even more than I usually do as a CTF participant, not only through the process of making the challenges but also as an organizer and from some rookie mistakes that I made (I will elaborate later). It was an interesting and invaluable experience, and I am looking forward to next year's b01lers CTF already.
As a challenge author, I suppose I am semi-obligated to release the intended solutions to my challenges. This writeup exists to fulfill that responsibility. I will describe each challenge and my solution in detail, as well as some selected solutions by participants that I find beautiful (I mean solutions, not participants, kek).
Before I start, I want to give a huge thanks to the b01lers president (CaptainNapkins), team officers and fellow challenge authors for this opportunity and for making b01lers CTF go smoothly!
Crypto
half-big-rsa
To begin with, we are given the equation $c = m^e \text{ mod } n$ where $n$ is a 4096-bit prime number. Normally, we'd be able to compute $d := e^{-1} \text{ mod } n-1$ and find $m$ by computing $c^d \text{ mod } n$, but unfortunately $e^{-1} \text{ mod } n-1$ does not exist as $g := \gcd(e, n-1) = 18 \neq 1$.
Fortunately, we have $\gcd(e/g, n-1) = 1$, so $d_1 := (e/g)^{-1} \text{ mod } n-1$ exists. That is, there exists $k \in \mathbb{Z}$ such that
\[ \frac{e}{g} d_1 = (n-1) k + 1 \implies e d_1 = (n-1)gk + g \]and hence, $c^{d_1} = m^{e d_1} = m^g = m^{18} \text{ mod } n$. Denote $m_1 := m^{g} \text{ mod } n$. It is clear that $m_1^{e/g} = c \text{ mod } n$.
We are left with taking 18-th roots of $m_1$ in $\mathbb{F}_n$. We could use nth_root
function in sagemath, but given that $n$ is a 4096-bit prime, it'd take forever. Let's use some trick. Factordb says:
Since $g = 18 = 2 \times 3^2$, we have $\gcd(g, (n-1)/g) = 6$, but
\[ \gcd\left(24 g, \frac{n-1}{24 g} \right) = 1 \]because $24g = 2^4 \times 3^3$. So, we can raise $m_1$ to the power of $24$,
\[ m_2 := (m_1)^{24} = m^{24g} \mod n \]and compute the inverse of $24g$ -- there exists $k' \in \mathbb{Z}$ such that
\begin{align*} d_2 := (24g)^{-1} \text{ mod } \frac{n-1}{24 g} & \implies 24 g d_2 = 1 + \frac{n-1}{24 g} k' \\ & \implies 24 g \cdot (24 g\; d_2) = 24g + (n-1)k' \end{align*}Hence,
\begin{align*} (m_2)^{24g d_2} = m^{24g \cdot (24g d_2)} = m^{24g} \mod n \end{align*}and therefore, $m$ would be one of the $24g$-th roots of $(m_2)^{24g d_2}$ in $\mathbb{F}_n$. That is, let $\rho$ be a $24g$-th root of unity in $\mathbb{F}_n$, then $m = (m_2)^{d_2} \rho^i \text{ mod } n$ for some integer $i$.
This can be done by finding a generator of a subgroup $E$ of order $24g = 432$ in $\mathbb{F}_n^\times$. Note that $E$ is cyclic since $n$ is prime. This can be done brute forcefully but in most cases (and apparently and fortunately, for this case as well) it should finish very quickly.
We are then left with computing the said $(m_2)^{d_2} \rho^i \text{ mod } n$ for $i = 0,1,\dots, 24g-1$.
Flag: bctf{Pr1M3_NUM83r5_4r3_C001_bu7_7H3Y_4r3_57r0N6_0N1Y_WH3N_7H3Y_4r3_
MU171P113D_3V3N_1F_7H3Y_4r3_b1g}
The full solution script sol.py
is provided below for the sake of completeness:
sol.py
(Click to expand)
After the CTF ended, some participants noted me that using the nth_root
function on Sagemath worked well for this challenge. One of them (ConnerM) shared their code with me, I tested it myself and apparently that was true (although it takes a few minutes)!
I guess I did not do a good job choosing the parameters. It might be interesting to study what parameters we should choose to frustrate such brute force attacks.
shamir-for-dummies
server-dist.py
(Click to expand)
This is a Shamir's secret sharing (SSS) problem. Suppose that $n$ parties want to share a secret $s \in \mathbb{F}$ where $\mathbb{F}$ is a field, with reconstruction threshold $k \leq n$ (i.e., any group of $k-1$ parties or less has no access to $s$, but $k$ parties or more has). Central authority (i.e., whoever possesses the secret $s$) samples a polynomial $p(X) \in \mathbb{F}[X]/(X^k)$ of degree $k-1$ randomly given $p(0) = s$. Then it chooses the evaluation points $\alpha_1, \alpha_2, \dots, \alpha_n \in \mathbb{F}$, computes and sends the $i$-th share of the secret $s_i := p(\alpha_i)$ to the $i$-th party. By Fundamental theorem of Algebra, $p(X)$ can be uniquely determined only when $k$ or more points $(\alpha_i, p(\alpha_i))$ are given. The security is guaranteed only when $\mathbb{F}$ is a finite field, which in our problem would be $\mathbb{F}_p$ for some given prime $p$. I recommend the readers to refer to this Wikipedia article for further details.
server-dist.py
file might be a bit long, but in reality, there are only a few lines where we need to pay attention to.
First, the server knows the secret $s$ (obviously), sets $n = k$, and they are sampling the polynomial $p(X) \in \mathbb{F}_p[X]/(X^{n})$ of degree $n-1$. Second, they lets you choose the evaluation places $\alpha_i$'s for all $i = 1,2,\dots, n$. Third, you have to choose them wisely so that the sum of all shares $p(\alpha_i)$'s is a multiple of the secret.
Let $p(X) = s + c_1 X + c_2 X^2 + \cdots + c_{n-1} X^{n-1}$ where $c_1, \dots, c_{n-1} \in \mathbb{F}_p$. Let $\alpha_i$'s be evaluation places. Then,
\begin{align*} \sum_{i=1}^n p(\alpha_i) & = s + c_1 \alpha_1 + c_2 \alpha_1^2 + \cdots + c_{n-1} \alpha_1^{n-1} \\ & \qquad + s + c_1 \alpha_2 + c_2 \alpha_2^2 + \cdots + c_{n-1} \alpha_2^{n-1} \\ & \qquad + \cdots \\ & \qquad + s + c_1 \alpha_n + c_2 \alpha_n^2 + \cdots + c_{n-1} \alpha_n^{n-1} \\ & = ns + c_1 \sum_{i=1}^n \alpha_i + c_2 \sum_{i=1}^n \alpha_i^2 + \cdots + c_{n-1} \sum_{i=1}^n \alpha_{i}^{n-1} \end{align*}So if we can find $\alpha_i$'s such that those sums all get canceled to $0 \text{ mod } p$, we are done. The first sum $\sum_{i=1}^n \alpha_i$ is exactly $0$ if $\alpha_i = \omega^i$ where $\omega$ is a non-trivial $n$-th root of unity in modulo $p$.
\begin{align*} \sum_{i=1}^n \alpha_i & = \omega + \omega^2 + \cdots + \omega^{n-1} + \omega^n \\ & = 1 + \omega + \omega^2 + \cdots + \omega^{n-1} \\ & = \frac{(1-\omega)(1 + \omega + \omega^2 + \cdots + \omega^{n-1})}{1-\omega} \\ & = \frac{1-\omega^n}{1-\omega} \\ & = 0 \mod p \end{align*}because $\omega^n = 1 \text{ mod } p$. It is easy to see that the second sum $\sum_{i=1}^n \alpha_i^2$ is also $0 \text{ mod } p$ by the same process
\begin{align*} \sum_{i=1}^n \alpha_i^2 & = \omega^2 + (\omega^2)^2 + \cdots + (\omega^{n-1})^2 + (\omega^n)^2 \\ & = 1 + \omega^2 + (\omega^2)^2 + \cdots + (\omega^{n-1})^2 \\ & = \frac{(1-\omega^2)(1 + \omega^2 + (\omega^2)^2 + \cdots + (\omega^{n-1})^2)}{1-\omega^2} \\ & = \frac{1-\omega^{2n}}{1-\omega^2} \\ & = 0 \mod p \end{align*}One way to generalize this is to view $\omega^2$ as another $n$-root of unity in mod $p$. Hence, by this logic, we can conclude that all sums $\sum_{i=1}^n \alpha_i, \dots, \sum_{i=1}^n \alpha_i^{n-1}$ are $0 \text{ mod } p$.
We are then left with just $ns \text{ mod } p$, which we can just have them divide it by $n$.
Flag: bctf{P0LYN0m14l_1N_M0d_P_12_73H_P0W3Rh0u23_0F_73H_5h4M1r}
This challenge was inspired by Maji et al.'s EuroCrypt '21 (see Remark 1) and Maji et al.'s ITC '22 (see Theorem 5) papers on an example attack against Shamir secret sharing scheme when evaluation places for the polynomial are chosen poorly.
R(esurre)C(tion)4
server-dist.py
(Click to expand)
This is a well-known insecure implementation of RC4. The only part that you need to pay attention to is the following:
Logically, this is equivalent to:
but implementation-wise, it is not. If you are swapping two values via XOR operation like server-dist.py
does, if the two values are the same, then they both end up becoming a $0$ as the XOR of two same numbers is equal to $0$.
If the plaintext is long---like very long---then we are certainly bound to reach a point where S[i] = S[j]
(and/or i = j
). Once this happens, as said above, S
will be updated as S[i] = S[j] = 0
, and this will be 'accumulated' and eventually transform S
into a sequence of zeroes, which leaves (some parts of) the plaintext unencrypted. Therefore, our goal is to just make the plaintext very long by padding it with a bunch of garbage data or empty characters/spaces, so the plaintext appears at the end of the ciphertext.
Flag: bctf{1f_gOOgL3_541D_1T_15_b4D_Th3n_1T_15_b4D}
This challenge was inspired by the closing keynote talk (titled "Where Code Meets Chip") at the 2024 Annual CERIAS Security Symposium, given by Philippe Biondi from Airbus.
Misc
basketball-scholar
The pete.png
picture looks like this:
Around the top-left corner of this picture, there is a sequence of pixels that do not seem 'natural' in the sense that they do not 'blend in' with the pixels adjacent to them.
After magnifying it even more, one can see that they are like an 'arithmetic progression' in the sense that one pixel is three pixels down and three pixels right from the previous pixel.
(I don't know why every pixel in this picture is in a different shape. I guess it is just my computer being weird.)
One can notice that plugging this picture into common stego tools such as StegSolve does not give any meaningful outputs directly towards the answer. However, cloacked-pixel generates this LSB plot:
which gives a pretty strong evidence that this is an instance of LSB steganography. So, we can use the lsb
function in the stegano
package in Python to extract the string.
And VGhpcyBIVyB3YXMgdG9vIGVhc3kgYmN0ZntHb19iMDFsZXJDVEZtYWtlcnMhfQ==
at the end is This HW was too easy bctf{Go_b01lerCTFmakers!}
in base64.
Flag: bctf{Go_b01lerCTFmakers!}
During the CTF, we received a few comments that this challenge looked very guessy in the beginning, but at the end it was created very logically and actually not guessy at all. I will call this a win.
TeXnically…
Apparently I cannot copy-paste the server-dist.py
code directly here. Please click this link server-dist.py
instead.
Though, the only crucial part on the server-dist.py
file is this line:
The challenge description says "you can program with LaTeX!" and this line fits its vibe. This is how you define a Boolean variable in LaTeX.
Anyway, let us try playing around with the server. Netcat into the server and type some random string like \texttt{b01ler up!}
as suggested, we get this response:
So, it looks like the server first complies the .tex
file into a .pdf
file, and scans and prints the strings on the .pdf
file by converting it into a .txt
file.
However, we are not able to see the reply from the server ("My reply:" is empty). Let us try disabling/enabling the Boolean variable we found above.
We got something this time! This is a bunch of Lorem Ipsums, but near the end of the reply, we see this:
So it looks like the reply is longer than one page, as Page 1
indicates. Then normally, we'd expect Page 2
to appear as well because it is going over one page, but Page 2
is not there! This gives a hypothesis that the PDF file is just one page, and the rest of the content is going over the bottom margin. So we can try "pulling" it upward by using \vspace{}
After scrolling down a bit, we get something important:
Voila.
Flag: bctf{WH47_Y0U_533_15_WH47_Y0U_G37,_L473X}
As expected, there were many different solutions to this challenge. There was one by Rev4184 who used the fact that the commands can be redefined with renewcommand{}
This line basically 'supresses' the command \lipsum
by having it return nothing upon being called.
The solution by Elius and firekern is perhaps the shortest yet most beautiful.
This command basically converts all character l
's in the file into EOL.
This was quite mindblowing. I would have never thought of this solution.
This challenge unsurprisingly was marked as the least favorite challenge by a handful number of teams. It required some trial-and-errors, which was annoying, but to make it even worse, the server was very unstable and slow. Hitting Enter multiple times allegedly was a requirement to make the server responsive and print the output at all. This of course wasn't something that I intended.
Hilariously enough, this challenge was down for four hours, because one of the teams unintentionally RCE/ACE attacked the server. In particular, they somehow managed to inject a code os.system('ls')
into subprocess.py
file. This broke the server, and made everyone who connects to the server get directory listing.
There were largely two issues with the server files largely. The first one was that pdflatex should not have had permission to run commands other than compiling the .tex
file that it is given. This can be done by adding --no-shell-escape
flare at the end.
Another issue was that the server was creating and compiling the .tex
file in the same (home) directory. This could potentially conflict if multiple users are interacting with the server simultaneously. This was fixed using the tempfile
package in Python.
In summary, this challenge has taught me many lessons, and gave me a big reminder that security vulnerabilities often arise from lack of attention to details.