Skip to main content
  1. Posts/

Undutmaning 2026 Writeup

Table of Contents

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
#

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.

Knappast lätt numpad and note

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.

WordPIN
sharkysharky19887427597427591988
greatwhite88473289448388
sharkattack887427528822588
bullshark19882855742751988
greatwhite198847328944831988
cascadashark8822722327427588
whalesharkcascada1988942537427522722321988
hammercascada198842663722722321988
cascada198822722321988

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 result

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 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 manifest

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.

$ 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?

Grundaren photography clue

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:

Reverse image search results of full image

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.

Reverse image search results of cropped image

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.

Interesting buildings

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!

Google Earth view of interesting buildings

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
#

Deep blue sea lab map

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