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.
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:
- Kapten Sträng (forensics)
- Knappast lätt (forensics)
- Manifest deepthingy (rev)
- Grundare (OSINT)
- Biologisk mångfald (programming)
- Deep Blue Sea (programming)
Kapten Sträng#
This forensics challenge presents you with a pcap file to examine. It contains a single TCP stream that corresponds to this exchange:
> ls **
solitaire
FROM_HQ:
PRIO_UPPDRAG_HK.txt
TO_HQ:
OPTI_CAM_01_OCRSCAN_000001.png
> cat FROM_HQ/PRIO_UPPDRAG_HK.txt
[...]
> ls **
solitaire
FROM_HQ:
PRIO_UPPDRAG_HK.txt
TO_HQ:
OPTI_CAM_01_OCRSCAN_000001.png
> rar a -pqazxswedc123 exfil.rar TO_HQ FROM_HQ
RAR 7.10 Copyright (c) 1993-2025 Alexander Roshal 12 Feb 2025
Trial version Type 'rar -?' for help
Evaluation copy. Please register.
Creating archive exfil.rar
Adding TO_HQ/OPTI_CAM_01_OCRSCAN_000001.png 99% OK
Adding FROM_HQ/PRIO_UPPDRAG_HK.txt 100% OK
Adding TO_HQ OK
Adding FROM_HQ OK
Done
> md5sum exfil.rar
464e63529654bb705fd60841021abce5 exfil.rar
> ls -s exfil.rar
757 exfil.rar
> download exfil.rar
This is followed by a transferred RAR file.
We can extract this file using tshark and verify its integrity against the md5 checksum.
$ tshark -r KaptenStrang.pcap \
-T fields \
-e tcp.payload \
-Y "tcp.srcport == 53287" \
| tr -d '\n' \
| xxd -r -p \
> exfil.rar
$ md5sum exfil.rar
464e63529654bb705fd60841021abce5 exfil.rar
Since the pcap contains the command used to create the archive we can simply
reuse the password (pqazxswedc123) to extract the files.
$ unrar e exfil.rar -p 'qazxswedc123'
$ exiftool OPTI_CAM_01_OCRSCAN_000001.png
ExifTool Version Number : 13.44
File Name : OPTI_CAM_01_OCRSCAN_000001.png
Directory : .
File Size : 767 kB
File Modification Date/Time : 2026:01:21 12:17:28-05:00
File Access Date/Time : 2026:03:21 09:08:34-04:00
File Inode Change Date/Time : 2026:03:21 09:08:34-04:00
File Permissions : -rw-rw-r--
File Type : PNG
File Type Extension : png
MIME Type : image/png
Image Width : 1024
Image Height : 559
Bit Depth : 8
Color Type : RGB
Compression : Deflate/Inflate
Filter : Adaptive
Interlace : Noninterlaced
Optics pov frame : undut{periskopdjup_-8m}
Image Size : 1024x559
Megapixels : 0.572
The flag undut{periskopdjup_-8m} is found in the EXIF metadata field “Optics
pov frame” of the extracted PNG file.
Knappast lätt#
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}.
Manifest deepthingy#
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 binary
def handle_client(fd):
manifest = reconstruct_manifest()
manifest_pos = 0
send_banner(fd) # "CASCADA Manual Server ..."
challenge = random_bytes(8)
send_packet(fd, CHALLENGE, challenge)
type, response = recv_packet(fd)
if (
type != RESPONSE or
response != transform_challenge(challenge)
):
send_packet(fd, ERROR, "BAD RESPONSE")
close(fd)
return
send_packet(fd, AUTH_OK)
while True:
rtype, rdata = recv_packet(fd)
# decrypt client command
apply_keystream_challenge_manifest_inplace(
(rtype, rdata),
manifest,
manifest_pos,
challenge
)
if rtype != COMMAND:
send_error(fd, "BAD TYPE")
continue
if rdata == "LIST":
list_files_encrypted(
fd,
manifest,
manifest_pos,
challenge
)
elif rdata.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 binary
def transform_challenge(challenge):
result = []
for i in range(8):
x = SECRET_KEY[i] ^ challenge[i]
x = rotate_left(x, i % 3)
if i % 2:
x = bitwise_not(x)
result.append(x & 0xFF)
return resultWe 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 binary
def apply_keystream_challenge_manifest_inplace(
data,
manifest,
manifest_pos,
challenge
):
for i in range(len(data)):
if manifest:
m = manifest[(i + manifest_pos) % len(manifest)]
else:
m = 0
data[i] ^= m
data[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 binary
def reconstruct_manifest():
manifest = []
for i in range(0x1250):
value = MANIFEST_OBF[i]
value ^= i * 0x1F
value = rotate_right(value, i % 7)
value ^= 0xA5
manifest.append(value)
return manifestThe 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.
$ python manifest_deepthingy.py
[+] Opening connection to undutmaning-manifest.chals.io on port 443: Done
> Files:
Förare miniubåt (checklista).md
Kommunikationssystem (felsökning).md
Räddningskapslar (handhavande).md
Städregelemente (akvarietankar).md
Städregelemente (dragskåp).md
Städregelemente (sanitetsutrymmen).md
> File: Förare miniubåt (checklista).md
# CHECKLISTA — FÖRARE AV MINIUBÅTAR
## Före dyk (pre-departure)
- Förarbehörighet: giltigt certifikat.
- Varningsmöte: kort briefing (väder, mål, risker).
- Dräkt & utrustning: syresensor, ljus, rep, lina, verktyg.
- Systemcheck: motor, styrservo, ballast, kommunikationslänk.
[...]
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
Grundare#
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.
Biologisk mångfald#
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:
$ python biologisk_mangfald.py
A:
BlueShark
Goldfish
Swordtail
Tetra1
Tetra2
Tetra3
B:
Angelfish1
Angelfish2
Goby
Guppy1
Guppy2
Guppy3
Manta
C:
Coralfish
Koi
Lionfish
Minnow
Surgeon
D:
Betta1
Betta2
Catfish
Clown
Oscar
Pleco
Puffer
Submitting the fish in this arrangement presents the flag
undut{Använde du AI är du inte sigma!}.
Deep Blue Sea#

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!}.

