Since 2022 the Swedish agencies FRA, Must and Säkerhetspolisen jointly host an
annual CTF called Undutmaning. This year’s event took place on March 21st and
included a total of 24 challenges to be solved within 8 hours. The challenges
span a range of categories and various levels of difficulty.
After the CTF they also invite selected participants to a physical event where
you get to meet other participants and representatives of the host agencies. I
have been invited! I might add a picture from the in-person event after it
happens.
Update: No phones or cameras where allowed at the event. Please enjoy
these images of sherry blossom I passed on my way back from the event instead:
Although this CTF allowed competitors to compete in teams of any size,
I decided to enter as a single-person team.
My goal was to solve as many challenges as I could without feeling too stressed
out. I solved 6 challenges and ended almost exactly in the middle at 199th
place.
As this was my first Undutmaning, I am happy with my performance, and now I have
a goal for next year: get a higher ranking than last time.
Below I share my writeup for the challenges I solved:
While I didn’t inspect all 24 challenges, I suspect this forensics challenge
is the simplest one in the event. It presents you with a numpad with a note next
to it. The challenge is to input the correct code.
My initial thought was that the note is a list of passwords for various accounts
and/or services. We get a clear picture of the kinds of passwords that this
person chooses (sharks and/or cascada, and the year 1988). Since there appears
to be no timeout on too many failed input attempts we try all passwords on the
list one by one, by inputting the numbers that corresponds to the respective
letters. If none work we could systematically try other likely passwords that
fit the scheme.
Word
PIN
sharkysharky1988
7427597427591988
greatwhite88
473289448388
sharkattack88
7427528822588
bullshark1988
2855742751988
greatwhite1988
47328944831988
cascadashark88
22722327427588
whalesharkcascada1988
942537427522722321988
hammercascada1988
42663722722321988
cascada1988
22722321988
Turns out the correct password was “hammercascada1988”, revealing the flag
undut{99fd5378b42993139ec5042b02cc30b83df786ed}.
This rev challenge provides you with a server to connect to and the binary
run by the server.
Upon connecting, the server spits out some gibberish (different each time you
connect), and does not seem to respond to any commands:
$ sc undutmaning-manifest.chals.io
(connected to undutmaning-manifest.chals.io:443 and reading from stdin)
CASCADA Manual Server (by Sebbe) v2.2
��k�5>�ls
whoami
^C
The server binary is not stripped, which makes reversing a whole lot easier.
$ file server
server: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=75622eeeebd720c7f163d27b54c6a0dce1365dbb, for GNU/Linux 3.2.0, with debug_info, not stripped
I solved this challenge entirely using only static analysis in Ghidra, and in
hindsight I’m not sure dynamic analysis would be of much help for this
challenge.
The handle_client function handles the communication protocol between the
server and client:
# Pseudo-Python based on Ghidra-decompiled binarydefhandle_client(fd):manifest=reconstruct_manifest()manifest_pos=0send_banner(fd)# "CASCADA Manual Server ..."challenge=random_bytes(8)send_packet(fd,CHALLENGE,challenge)type,response=recv_packet(fd)if(type!=RESPONSEorresponse!=transform_challenge(challenge)):send_packet(fd,ERROR,"BAD RESPONSE")close(fd)returnsend_packet(fd,AUTH_OK)whileTrue:rtype,rdata=recv_packet(fd)# decrypt client commandapply_keystream_challenge_manifest_inplace((rtype,rdata),manifest,manifest_pos,challenge)ifrtype!=COMMAND:send_error(fd,"BAD TYPE")continueifrdata=="LIST":list_files_encrypted(fd,manifest,manifest_pos,challenge)elifrdata.startswith("REQUEST"):filename=extract_filename(rdata)send_file_from_dir_encrypted(fd,manifest,manifest_pos,challenge,filename)else:send_error(fd,"UNKNOWN COMMAND")cleanup()
The server first sends a 8-byte random challenge and authenticates the client if
the response matches the output of the transform_challenge function:
# Pseudo-Python based on Ghidra-decompiled binarydeftransform_challenge(challenge):result=[]foriinrange(8):x=SECRET_KEY[i]^challenge[i]x=rotate_left(x,i%3)ifi%2:x=bitwise_not(x)result.append(x&0xFF)returnresult
We can read the 8-byte SECRET_KEY from the binary knowing the position:
$ nm server | grep -w SECRET_KEY
0000000000006278 r SECRET_KEY
The server then accepts two types of commands: LIST and REQUEST which
appears to list and show files on the server, respectively. The server expects
the client’s commands to be encrypted, and will itself encrypt data sent back.
The apply_keystream_challenge_manifest_inplace function is used to decrypt the
received commands. It is also used within list_files_encrypted and
send_file_from_dir_encrypted to encrypt the data sent. We note that this is an
XOR-based cipher that uses the same function to encrypt and decrypt:
# Pseudo-Python based on Ghidra-decompiled binarydefapply_keystream_challenge_manifest_inplace(data,manifest,manifest_pos,challenge):foriinrange(len(data)):ifmanifest:m=manifest[(i+manifest_pos)%len(manifest)]else:m=0data[i]^=mdata[i]^=challenge[i%8]manifest_pos+=len(data)
The manifest used for the encryption/decryption is constructed with
reconstruct_manifest:
# Pseudo-Python based on Ghidra-decompiled binarydefreconstruct_manifest():manifest=[]foriinrange(0x1250):value=MANIFEST_OBF[i]value^=i*0x1Fvalue=rotate_right(value,i%7)value^=0xA5manifest.append(value)returnmanifest
The obfuscated manifest, MANIFEST_OBF, can be read from the binary knowing the
position:
$ nm server | grep -w MANIFEST_OBF
0000000000005020 r MANIFEST_OBF
The last detail is that the send_packet function formats the packets as
[type (2 bytes)][len (2 bytes)][payload]. The recv_packet expects the same
packet format.
Now we have all the pieces we need to enumerate and extract all files on the
server!
We write a client Python script with
pwntools that reads SECRET_KEY and MANIFEST_OBF from the binary and
connects to the server. It passes the server’s challenge through
transform_challenge to produce the expected response, authenticating. Next, it
sends the LIST command followed by a REQUEST command for each file listed.
All commands sent are encrypted with the
apply_keystream_challenge_manifest_inplace function, and all server responses
after the authentication are decrypted with the same function.
Towards the end of the last file we see the flag
undut{Jag tillhör inte längre något land. Jag är medborgare i havet.}:
[...]
- Använd handverktyg (rörvinda) enligt utbildning.
- Vid ihållande stopp: larma fastighetsteknik.
## Produkter och säkerhet- Använd alltid handskar.
- Bland aldrig blekmedel och ammoniak.
- Följ produktsäkerhetsblad.
- Om någon frågar så är flaggan `undut{Jag tillhör inte längre något land. Jag är medborgare i havet.}`
## Ansvar- Personalen ansvarar för daglig ordning.
- Städteam ansvarar för djupare rengöring och kemikalier.
## Kort etikettregel- Släng blöta och fasta artiklar i soptunna — inte i toaletten.
- Respektera skyltar och städpersonalens instruktioner.
[*] Closed connection to undutmaning-manifest.chals.io port 443
This OSINT challenge is quite straightforward. It presents you with an image
(see below) and asks you: Who founded the reputable organization?
A reverse image search results in a wide range of dome-like buildings, but none
that seem to have the same texture or appear to be surrounded by mountains:
Cropping the building of interest out of the image and doing an image search of
the mountains identifies the general location to the city Monterrey in Mexico.
The mountains are very recognizable in other images of the city, so it can
easily be verified.
I got no promising results for dome-like buildings in Monterrey, but having
narrowed down the search to the correct city had me thinking I would be able to
find it by roaming around in Google Earth while looking for other landmarks from
the image.
I positioned myself close to the mountains in the center of the image and spent
some effort lining up the two mountain stretches correctly. When I felt happy I
moved back in somewhat of a straight line, and soon enough I spotted them!
The coordinates reveal that the building with the dome-like roof (partially
visible behind another building in the bottom of the Google Earth view above) is
part of the Tecnológico de Monterrey. Its founder, Eugenio Garza Sada (and the
flag undut{eugenio_garza_sada}), is a search engine hit away.
This programming challenge presents you with 25 fish to distribute into 4
different fish tanks. Additionally, there is an extensive list of rules to abide
by while doing so. So the challenge is essentially to solve a
constraint satisfaction problem
(CSP).
I used the python-constraint CSP solver module to encode the constraints in
a Python script that finds a valid solution to
the problem:
This programming challenge presents you with a map of a maze and a server
that you can connect to to play a maze-solving game. Every six seconds, the
server gives an update with positions of sharks within the maze.
$ sc undutmaning-deep.chals.io
(connected to undutmaning-deep.chals.io:443 and reading from stdin)
Du står med vatten upp till låren i Laboratorium #23!
Vänta på en uppdatering från Perseus om var hajarna befinner sig innan du skyndar dig igenom labbet genom att ange en rad förflyttningar i form av vädersträck (N,S,Ö,V). T.ex. SSÖÖNV... osv.
Perseus informerar:
{"sharks": [[1, 5], [3, 14], [3, 23], [4, 1], [4, 11], [5, 8], [6, 12], [6, 19], [6, 23], [11, 8], [12, 11], [13, 16], [18, 13], [20, 19], [21, 11], [21, 22], [22, 1], [22, 10], [23, 8], [23, 15], [25, 7], [28, 7], [28, 10], [28, 21], [29, 22]]}
Perseus informerar:
{"sharks": [[1, 18], [3, 4], [3, 14], [4, 11], [5, 8], [6, 12], [6, 19], [6, 26], [8, 9], [12, 11], [13, 4], [13, 9], [13, 16], [14, 13], [14, 17], [18, 1], [20, 19], [21, 11], [22, 1], [22, 5], [23, 15], [27, 26], [28, 7], [28, 21], [29, 22]]}
Perseus informerar:
{"sharks": [[3, 14], [3, 23], [4, 1], [5, 8], [6, 12], [6, 19], [6, 26], [8, 9], [10, 25], [11, 8], [13, 4], [14, 13], [14, 17], [17, 4], [18, 1], [21, 11], [21, 16], [21, 22], [22, 1], [22, 5], [24, 13], [28, 7], [28, 10], [28, 21], [29, 22]]}
^C
While it is not entirely clear from the get-go, the goal is to make it from the
starting tile (top left) to the battery tile (bottom right), without colliding
with any walls or sharks. Until the first update, the position of the sharks are
unknown. The positions are then stationary until the next update.
After reaching the battery you must return to the starting tile again, avoiding
walls and sharks. The shark positions are again unknown until the next update.
I wrote a Python script using pwntools that
connects to the server and waits for shark positions that allow a clean path
from the start to the battery, using a breadth-first search with respect to the
maze and sharks. It sends that path to the server and then repeats the process
to get back to the start tile.
$ python deep_blue_sea.py
[+] Opening connection to undutmaning-deep.chals.io on port 443: Done
Du står med vatten upp till låren i Laboratorium #23!
Vänta på en uppdatering från Perseus om var hajarna befinner sig innan du skyndar dig igenom labbet genom att ange en rad förflyttningar i form av vädersträck (N,S,Ö,V). T.ex. SSÖÖNV... osv.
Perseus informerar:
{"sharks": [[3, 4], [3, 14], [4, 11], [5, 8], [6, 19], [6, 23], [8, 9], [13, 4], [13, 9], [13, 16], [14, 13], [14, 17], [17, 4], [18, 1], [18, 13], [21, 22], [22, 1], [23, 8], [23, 15], [24, 13], [25, 7], [27, 26], [28, 7], [28, 10], [29, 22]]}
> SSSSÖÖÖÖSSSSÖÖSSÖÖNNÖÖÖÖSSÖÖNNNNÖÖSSSSÖÖSSÖÖÖÖNNÖÖSSÖÖÖÖSSSSVVSSSSSSSSÖÖSSSS
Med andan i halsen hittar du fram och plockar upp batteriet.
Perseus informerar:
{"sharks": [[1, 5], [1, 18], [3, 4], [3, 23], [4, 11], [6, 12], [6, 19], [11, 8], [12, 11], [13, 9], [14, 13], [14, 17], [17, 4], [18, 1], [18, 13], [20, 19], [21, 11], [21, 16], [21, 22], [22, 1], [23, 8], [23, 15], [24, 13], [28, 21], [29, 22]]}
> NNNNVVNNNNNNNNÖÖNNNNVVVVNNVVSSVVVVNNVVNNNNVVSSSSVVNNVVVVNNVVVVNNVVVVNNNN
Du tog dig tillbaka! Bra jobbat!
undut{Beneath this glassy surface, a world of gliding monsters!}
[*] Closed connection to undutmaning-deep.chals.io port 443
Once back at the starting tile we see the flag
undut{Beneath this glassy surface, a world of gliding monsters!}.