Competition on CTF time: CSI CTF
Final position: #100 / 1022
I was responsible for the web, pwn, linux and reversing challenges, and feel that I have done pretty well. I did most of the challenges from the first wave of challenges, as well as several from the second wave. Here I present a brief summary of how I did them.
Misc
Miscellaneous challenges tend to be the easiest ones for me as I’m still a beginner in most security concepts, but have a lot of competitive programming experience and random knowledge about programming languages. I managed almost all of the challenges, except for Prison break
.
BroBot
This was definitely one of the more interesting ones, as one could interact with the server only through the texttospeech bot. After examining the provided repository it is quite easy to spot the critical line of code:
fs.write(f"echo '{text}'")
we can clearly inject a command into this. The general format that would work is:
'; <command>; echo'
This will print 2 empty strings and the command in the middle, so we will receive the command’s output as the speech response.
I started by sending the ls
command, and couldn’t quite catch the name of the file, but I realized that the extension is .txt
. The next command I sent was cat *.txt
, which returned the flag (there needed to be some guessing that the words are separated by underscores, as well as the correct case)
Escape Plan
Here we are put into a shell that tells us several available commands that call different ciphers. It took me quite a bit of time (as I am new to this kind of challenge) but eventually I figured out that the script is just executing the eval
command to evaluate whatever I send as my input.
Initially I didn’t realize that it is that simple, and tried to use the shift_cipher_key
function provided by the script to print my commands. This is what I came up with (after some research online about the __import__
object):
shift_cipher_key([*str(__import__('os').popen("<command>").read())], 0)
Clearly this is clunky, but it worked, and substituting ls
and cat crypto.py
I managed to get the source code.
Here I saw that a simple eval
is used, and hence the following construct should work as well:
print(__import__('os').popen("<command>").read())
To launch an interactive shell I decided to use the subprocess module:
__import__('subprocess').run('/bin/sh', stdin=__import__('sys').stdin, stdout=__import__('sys').stdout)
After launching the shell I again spent quite a bit of time searching around, but didn’t get find much on the server. Finally after examining the .git
directory I found a public GitHub repository link. Searching the commit history there I found the flag.
Friends
This was the first challenge I did from the Misc category, and it was quite a cool one. We are presented with the code for the function, and the first thing I noticed is this line
if (x < 3 or x > 100):
exit()
Meaning that the input range is quite small. Additionally the input is always rounded to be an integer:
x = round(float(input()), 0)
We pretty much want an input an x
for which x == y
, where y = fancy(notfancy(x))
(more or less)
I wrote a quick script to test this out and it turns out that none of the values in the range [4, 99] work. So there must be a trick. I immediately start thinking about some interesting values that I could try to pass to the function: None, NaN, +/infinity
. NaN
seemed the most promising since it tends to have strange behaviour in many languages (None
didn’t seem like it would be accepted by float
, and +/infinity
wouldn’t lie within [4, 99]). It turns out my guess was correct and typing in nan
prints the contents of the file.
To my surprise, this was not the flag. The file had some hindilooking codelike thingy, each block of that code containing a number and a character. After sorting these blocks by hand I got the flag. This was not difficult, but completely unnecessary, and very annoying. A great little challege, made significantly less enjoyable by this unnecessary obstacle.
No DIStractions
This was a pretty easy challenge, not that interesting. I needed to get the flag from the ctf’s Discord bot. Once I figured out that the bot takes commands starting with a dot, I DM’ed it the message .flag
and got the flag.
Machine Fix
This was a pretty simple programming challenge, which I excel at due to my background in competitive programming.
We are presented with a function convert
. After a bit of investigation it is quite easy to see that the function converts an int to its ternary representation, as it keeps taking remainder mod $3$ and dividing by $3$.
The main loop then contains
while(n<=523693181734689806809285195318):
str1=convert(n)
str2=convert(n1)
str2='0'*(len(str1)len(str2))+str2
for i in range(len(str1)):
if(str1[i]!=str2[i]):
count+=1
n+=1
str1
, str2
represent ternary representations of n
, n1
, and we count the number of places that these differ at. In particular, if n
is divisible by $3^k$ but not $3^{k+1}$, then the $k+1$ least significant digits will differ, and the others won’t (try a few examples on a piece of paper if this does not seem clear).
So we now understand what the function does and see the pattern that it follows. That is still not enough, as running the loop 523693181734689806809285195318 times would take forever. Luckily, we can make the following observation:

Every iteration will contribute 1 to count, as the least significant digit will differ

Every third iteration (when n is divisible by 3) will contribute an additional 1 to count, as the second to least significant bit will differ

Every ninth iteration will contribute another additional 1

And so on…
This allows us to arrive at the following straightforward code, that takes only $\lceil\log_3(523693181734689806809285195318)\rceil = 63$ iterations of the loop
n = 523693181734689806809285195318
ans = 0
while n > 0:
ans += n
n //= 3
print(ans)
Prime Roll
This was probably the easiest problem in the whole set. We have a die that has 1e9+7
sides, and it is rolled a ridiculously large number of times: more than $2^{1e9+7}$ times. In particular, the chance of us not rolling 1e9+7
is miniscule beyond imagination, and hence we can easily conclude that the largest throw will be 1e9+7
with essentially (though not exactly) probability 1. Thus for the flag input a bunch of $9$s.
For a more rigorous proof one can use the fact that $(11/n)^n \to 1/e$, and here we have something like $(11/n)^{2^n}$ for the probability of not throwing 1e9+7
(with n = 1e9+7
).
Mafia
I enjoyed this problem quite a bit compared to other problems. And I have a suspicion that there is no (deterministic) algorithm that cannot be countered by carefully distributing the money between the mafia members. Here are the approaches I tried:

The first thing that came to my mind is to just binary search through every mafia member in order to find out how much money they have. This clearly won’t work though, as I would need to make around $20$ queries per mafia member, and I am only allowed to average less than $4$.

The next thought is to do the same, but first filter out a large proportion of the mafia members by testing who has at least $x$ money for large $x$. Once we are left with several mafia members, we can binary search the amount of money that they have. Choosing $x$ between $500\ 000$ and $900\ 000$ seemed to give reasonable results.
 While this works, and this is how I got the flag initially, it is fairly inconsistent. I needed to repeat the algorithm multiple times, as I would often either filter out too few or all of the mafia members.

The last approach is to slightly improve the original binary search idea.

Whenever we encounter a new mafia member, we check if they have more money than all of our previous encounters. If they don’t, we can safely skip them

Otherwise binary search the new maximum amount of money
Why does this work? We need around $300+20*\text{[number of binary searches]}$ queries. How many binary searches will we need? In the worst case all of them, but that is highly unlikely. The first time we perform a search we can expect the number of mafia members that have even more money gets decreased by around a half (if we choose the first mafia member randomly). Similarly the second time we perform the search this further gets decreased in half, and so on, until we encounter the mafia member with the largest amount of money. This crude estimation lets us expect $\log_2(300) < 10$ binary searches, which is clearly good enough to fit into the required $1000$. Here is the code for the described algorithm:

import socket
import random
import sys
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(("chall.csivit.com", 30721))
mafia = list(range(1, 301))
# shuffle the mafia in case their money is ordered
random.shuffle(mafia)
rem_queries = 1000
min_money = 1
max_money = 1000000
def query_1(money, mafia):
global ans, rem_queries, min_money
rem_queries = 1
sock.sendall(f"1 {mafia} {money}\n".encode())
ans = sock.recv(2).decode('utf8')[0]
if ans == 'G':
min_money = money + 1
if ans == 'E':
min_money = money
def query_2(money):
global ans
sock.sendall(f"2 {money}\n".encode())
ans = sock.recv(1024).decode('utf8')
for member in mafia:
print(member, rem_queries)
query_1(min_money + 1, member)
if ans == 'L' or ans == 'E':
continue
cur_max = max_money
while ans != 'E':
query_1((min_money + cur_max) // 2, member)
if ans == 'L':
cur_max = (min_money + cur_max) // 2  1
query_2(min_money)
print(ans)
While the above algorithm works well on average, it can be countered with dynamic responses. For example, the server could set money for the mafia members to $2, 4, …$ and so on in order in which we access them. In this case we would run a binary search for every member and wouldn’t fit in the $1000$ query limit. I couldn’t think of an algorithm that would always work for this limit, but for a slightly higher limit ($1200$) I want to share an algorithm that will always return the correct answer, regardless of how much money which member has.

As above, initialize
min_money = 1

We start by setting an increment value
inc = 10000
, then100
, and finally1
($3$ iterations)
For each mafia member:

Check if the member has
cur_max + inc
, and updatecur_max
if they do 
Repeat until we get a negative answer


It should be clear why the above algorithm returns the correct answer. The reason it takes only $1200$ queries ($400$ per increment value) is that we make $300$ (at most) queries for which members have less than requested money, and increment the max money value at most $100$ times:

The first iteration we increment at most $100$ times, as $100*10 000$ is already more than $1\ 000\ 000$.

At the start of the second iteration we know that the final answer is within $10\ 000$ of the current one. So we again increment by $100$ at most $100$ times.

The third iteration the answer is within $100$ of the current one. Now we increment by $1$ at most $100$ times.
Linux
Most of these challenges seemed pretty interesting, though I wasn’t able to do many of them. I think I was pretty close to getting into the second Hack the Box server, but wasn’t able to in the end.
AKA
I was greeted by a shell that transforms most of the commands to cowsay. After some trial and error I realised that /bin/bash opens a normal shell and I was able to quickly find the flag.
find32
This was a confusing challenge, a mix of unrelated things that I needed to do. When we enter the shell, we find a bunch of files that seem to be full of random text, what seemed on first look to be base 32 encoded data. This seemed to correspond to the title.
However, after quite a bit of time trying to decode the files I realized that they have illegal characters for base32. A quick grep r {
found me the following string, leaving me quite annoyed: csictf{not_the_flag}{user2:AAE976A5232713355D58584CFE5A5}
Now I thought to myself: nice, there is another user and I have their password hash of some sort! I tried some online databases, as well as tried decoding from base 16 but they didn’t seem to work. After a while I tried just using the string as the password, and it worked. Again, I felt very annoyed at the challenge.
Now we login as user2 and we find a bunch of seemingly identical files. I decide that one of them must be slightly different so I decide to use the diff
command to find the differing line, which contains the flag
HTB 0x1
This was the one HTB box that I was able to crack. It was fairly straightforward, as I it had an ftp server to which I was able to connect anonymously. The flag was inside.
Pwn
These challenges were fairly fun, and just around the level of challenges that I am able to solve. I did the first wave of challenges and then unfortunately didn’t have time to try the second wave but will surely try to do them after the competition.
pwn intended 0x1
This was fairly straightforward, we simply need to overwrite a variable with non0 bytes during a call to gets
. This means that just a long string of a
s works as a payload.
pwn intended 0x2
This was slightly trickier, but has the same idea. We need a certain amount of padding, and then have to overwrite a particular variable with a specific value. The payload I used was 'a'*(0x300x4) + '\xbe\xba\xfe\xca'
.
pwn intended 0x3
I spent a lot of time (probably over an hour) trying to write shellcode to the stack and executing, and afterwards realised that the stack is nonexecutable memory. So I started for a way to get a shell in different ways like return oriented programming and other complicated strategies.
Fortunately at some point I decided to list out the available functions in gdb with info functions
and noticed the function flag
. I engineered the main function then to return to the flag function, and it printed the flag.
I used the following payload: 'a'*0x28 + '\xce\x11\x40\x00\x00\x00\x00\x00'
.
Web
Again I only did the first wave of challenges but I enjoyed most of them (although they were certainly some of the easiest challenges).
Warm Up
Honestly this was a pretty difficult challenge for me since I am not very good at PHP specifics. It took me quite some time to figure out that PHP converts strings starting with 0e
into a float that equals exactly $0$, and hence it is possible to find “collisions” between such strings. Afterwards a simple Google search found me a GitHub repo with many such collisions, and I used Postman to retrieve my flag.
Cascade
The flag was hidden as a comment in a CSS file, not much more to say.
Oreo
This challenge required setting a base64encoded cookie to the value “chocolate”, as hinted in the task description.
Mr Rami
This challenge seriously bamboozled me. I spent a good couple hours examining the bot, making myself an admin, examining the server the bot gives access to, and reading the code for the bot. It didn’t lead to anything.
The funny thing is I looked in the discord chat and found a hint saying that this challenge is easy, that it is obvious once you get the hint, and so on. At some point I even realised that this challenge was just a bunch of references to Mr Robot, and was expecting to encounter a robots.txt file at some point. What I forgot was to check the initial seemingly useless webpage that just links to the bot. When I finally checked, there was a robots.txt, and it contained the flag. I felt extremely annoyed to have missed such an obvious hint, but a good lesson to learn for the future.
Secure Portal
For this challenge I just needed to do some deobfuscation of a script. I ended up with the following:
function CheckPassword(results) {
/** @type {!Array} */
...
window.localStorage.getItem("912", "BE*");
window.localStorage.getItem("47", "bb=");
window.localStorage.getItem("02", "5W");
window.localStorage.getItem("16", "^7M");
window.localStorage.getItem("1214", "pg");
window.localStorage.getItem("79", "+n");
window.localStorage.getItem("1416", "4t");
window.localStorage.getItem("24", "$F");
...
}
From which we can clearly construct the password 5W$Fbb=+nBE*pg4t^7M
. Login with the correct password to get the flag.