Intro
Hello again and welcome to the third of our series. On today’s blog post we are going to see what is and how can we abuse a double free(). We are also going to take advantage of leaks that happen when doing double free()‘s and see some examples of code execution using said leaks – we are making our execution ride on frees!
As a last note, we are going to step things up a notch in this blog post and we are going to be using gdb as it will be crucial from now on. Sadly, ascii art doesn’t cut it anymore.
The Vulnerability
Preface
Well, this one is a, maybe, unpopular one because first of all, finding one is so hard, especially with code inspections (fuzzing comes in handy here). Second reason why it is not so common is because there needs to be some kind of leak, or the double-free itself should leak to be able to be exploited. Keep in mind we are talking about userland heap, NOT kernel land where the heap is a different story. As a third reason that hinders exploitation is that very specific scenarios have to happen for it in order to be exploitable.
I couldn’t find too much recent stuff on this but here’s one cool paper where double-free vulnerabilities were found on OpenCV among many others which are also exploitable. Also this vulnerability on Dropbear SSH or this one on Openssl when parsing DSA keys.
What
A double-free vulnerability occurs when, as the name says, a variable is free()‘d twice. It is a solid memory corruption because regarding the code, the variable is still usable but the memory pointed to that variable can be free. This could happen when using structs and then freeing just one of the components of the struct itself. Then, some functions do cleanup and finally the whole struct used and its component get free()‘d again, effectively double-freeing the struct’s internal component. However, thanks to ptmalloc2 and its memory checks doing just:
... chunkA = malloc(0x100); free(chunkA); free(chunkA); ...
Won’t work and will cause a SIGABRT.
The implications of a double-free vulnerability are often memory leaks and write-what-where but again, the possibilities are endless… cliché. Remember how a chunk looks in memory when it’s free and which pointers they set when doing so?
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ <-- Chunk start | PREV_SIZE OR USER DATA | +---------------------------------+-+-+-+ | CHUNK SIZE |A|M|P| +---------------------------------+-+-+-+ | FORWARD POINTER(FD) | <-- All freechunks | BACK POINTER(BK) | <-- normalchunk or larger | NEXT BIGGER POINTER (fd_nextsize) | <-- Only if largechunk | PREVIOUS SMALLER PTR(bk_nextsize) | <-- Only if largechunk | - - - - - - - - - - - - - | <-- End of this chunk. | PREV_SIZE | +---------------------------------------+
As you can see, after free()‘ing a chunk there are many pointers written to it if the chunk is large enough. Those pointers will be used by the implementation to keep track of free chunks to be merged and such.
When stars go free
A very simple scenario that illustrates the start of a double-free vulnerability is the following code:
... char *A, *B; A = malloc(SIZEOFNORMALCHUNK); // (1) free(A); B = malloc(SIZEOFNORMALCHUNK); // (2) free(A); // Double free (3) ...
As we can see here, there is a double-free happening at (3) but, why is this a vulnerability if it just looks like a simple free() on the chunk A? Well, you will see that (1) and (2) do actually get assigned the same position in the heap so, when the double-free happens, we are actually free()‘ing the recently malloc‘d region assigned to B. Now if someone were to use B we are actually using memory that for the implementation is free but not in the code.
The playground
We arrived fast here! Whoa! For this type of vulnerability I realised I needed to construct as many proof of concepts as possible starting from the most basic one to really get a good grasp of it. As always, you can download the playground code in the following link.
Proof of Concepts
If you don’t know anything about gdb, don’t worry but please grab a copy of this reference card: GDB reference card.
Up to the top
As a first PoC, we have a program that will take the basic scenario and primitive described previously (When stars go free). Our goal is to end up writing our contents into the TOP chunk accessing a still valid variable, in our case, B. There is no need to explain the code as it was explained before but let’s see this in gdb:
Thanks to Symeon for pointing out that I am not specifying one key thing: double-free works here because the second allocation is of the same size as the free space. Remember that chunks are allocated by size so, the free space left by chunk A
in (1) is a perfect fit for the new allocated chunk B at (2) hence B taking the same memory position as A.
Up to the top the fast way
In this second instance, instead of using normal sized chunks, we are going to use fastchunks. Remember that these behave differently in the heap. For example these keep a single-linked list when free()‘d and, even when free, they keep their INUSE bit set. The reason to show this, is to show how, when a fastchunk is free()‘d, it sets its FD pointer to NULL since it is the last fastchunk to be free. For normal and larger chunks this won’t happen.
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ <-- Free fastchunk start | PREV_SIZE OR USER DATA | +---------------------------------+-+-+-+ | CHUNK SIZE |A|M|P| +---------------------------------+-+-+-+ | FORWARD POINTER(FD) | <-- Set when free() | | | | | | | - - - - - - - - - - - - - | <-- End of this chunk. | PREV_SIZE | +---------------------------------------+
We added some “printf” lines to our previous PoC code for readability and another two sections which are worth explaining:
... snprintf(A, SIZEOFFASTCHUNK, "This is A's contents. I would like " "to say something but there's not much space in 0x60 bytes..."); ...
This line will set the contents of the allocated chunk A by copying the amount of bytes specified in the second argument (SIZEOFFASTCHUNK) from the string in the third argument.
... printf("B is still valid in the code but under the hood\n\ it is free for the allocator... B contents [%s]\n", B); printf("[+] Contents of B in hex: \n"); print_bytes(B, SIZEOFFASTCHUNK); ...
Afterwards, we will print again the contents that B is now pointing to. Since these are fastchunks, in the moment of free()‘ing chunk A, its FD pointer was set to NULL which is why, when printing the contents of chunk B it seems to be empty but its false. Due to printf and the ‘%s’ operator taking the null byte ‘\x00’ as the end of the string, it encounters the FD pointer to NULL (actually 8 bytes to ‘\x00’) it just stops reading.
For this task I created a helper function that will print the hexadecimal contents of the specified chunk – print_bytes(chunk, size) function. See it in action + gdb!
In the previous asciinema we have seen how FD
from the last allocation gets set to NULL
. If there was any other pointer in FD
and a new allocation to be done, the allocation will try to go and allocate into that new position.
Now, how can we change that value? If changed, how can we abuse it without falling into security checks? Wait for it ;) we still “don’t know” where to write, right?
This TOP has leaks…
What if there were more chunks allocated and the TOP
chunk wasn’t just besides our allocations? No problem, actually, better!
Keeping in mind the previous PoCs, if there are more free()‘d chunks and a double-free and read primitives, we will be able to read the pointers to these free chunks which effectively leaks memory addresses and helps us bypassing security mitigations such as ASLR
. For this section we are just going to allocate one new chunk right after allocating chunk A
, but the game will totally change now :)
The chunk C
variable was added as well as the following lines:
... C = malloc(SIZEOFNORMALCHUNK); ... snprintf(B, SIZEOFNORMALCHUNK, "This is B's contents. I would like" " to say something but there's not much space in 0xf8 bytes..."); ...
Onto our next gdb session!
…and it’s leaking fast!
In the fastchunks’ case, despite free()‘ing the new chunk (C) right after allocating it, it will keep the pointer to the next free chunk set even when merged with the TOP
chunk. This gdb session is left as an exercise to the reader as it is similar (if not equal) as the previous one. Here we will see and explain the execution.
In red, we have the evidence of what causes the double-free success. Since A
and B
are of the same size, B
will take old A
‘s free space (I am repeating this a lot on purpose). In orange we have the consequence of the double-free and read primitives: the pointers leaked.
TOP means JACKPOT!
For the last proof of concept we are going to glue it all together: We are abusing normal and fastchunks.
Normal chunks double-free primitives will help us leaking the main_arena->top
chunk. This chunk is always to an offset of a couple of functions, one of them will catch some readers attention because of it’s state:
As you can see, __malloc_hook
is not set here. This hook is there so that, in case of us wanting to debug what happens on the heap when allocating new chunks, we would set that pointer to our debugging function. For us, what’s important is that, every time malloc() gets called, the function pointed by __malloc_hook
is called as well.
Let’s put it all together and let’s explain the code. The code of this PoC is based on this github gist I found while trying to find more double-free examples. I gave it a tweak so, instead of using buffer overflows to change the next allocation behaviour, another double-free on fastchunks is used.
... puts("\n[+] free p1, p2"); free(p1); free(p2); puts("\n[+] allocate p3"); char *p3 = malloc(0x100); printf("p3 = %p\n", p3); puts("\n[+] p1 double free"); free(p1); // Double free ...
This is where we get our first double-free primitive, this results in a memory leak of main_arena->top
that in the original program is saved through:
... void *arena_top = *(void **)p3; void *malloc_hook = arena_top - 0x68; ...
Why p3 leaks main_arena->top
should have stick in your brain by now. I repeated it enough, didn’t I? But, there can be something itching your brain now, which is the number 0x68. Let our renewed friend Figure 3 explain it to you:
Easier seen on the debugger, right? Who would have thought… an ugly old debugger like gdb making things clear! O_o
Prior to any more calculations, let’s introduce a security check. The following code in glibc’s malloc.c file:
... if (__builtin_expect (fastbin_index (chunksize (victim)) != idx, 0)) ...
What this code does is, checking if the position pointed by, in our case, our rogue FD
pointer, is really the position of a fastchunk by comparing if, the header in that (fake) free fastchunk region, corresponds to a legit size in the array of fastbins (remember, free fastchunks are saved in an array of single linked lists containing pointers free fastchunks of the same size, namely fastbinsY).
... char *p5 = malloc(0x60); free(p5); char *p6 = malloc(0x60); // Takes the old address of p5 free(p5); // double free p5 void *offset_byte_7f = (void *)malloc_hook-0x20-3; // (1) memcpy(p6, &offset_byte_7f, 0x8); // (2) ...
Ok, this is quite tricky. We need to overwrite the last double-free()‘d chunk’s FD
(p5->FD
) pointer to point somewhere near __malloc_hook
so that, when the next fastchunk gets allocated, the allocator reads our rogue FD
(p5->FD
set by memcpy
afterwards at (2)) pointer and allocates the new chunk at our desired, malicious and hackish memory address.
To bypass the check mentioned previously, at (1), a negative offset from __malloc_hook
was chosen because it points exactly to the long int
0x000000000000007f. This means that the next time we allocate two fastchunks, the allocator will first take the current free fastchunk whose FD
pointer has been mangled with and, the second time it will go to the last FD
pointer set (read, rogue FD
), read the size value at said pointer and think this is a valid size header since it’s small enough to be a fastchunk. Plus! the trick here is that the allocator thinks its size is near 0x70
like all other fastchunks:
0x60 + headers + INUSE bit = 0x71 ~= 0x7f
... char *p7 = malloc(0x60); // (1) char *p8 = malloc(0x60); // (2) memset(p8, 'A', 0x13); *(void **)(p8+0x13) = jackpot; ...
As explained, chunk p7
(1) takes the old address with our rogue FD
and now, the allocator thinks the next free fastchunk is at that FD
pointer which in turn is:
p5->FD == (void *)malloc_hook-0x20-3 == offset_byte_7f; // true
p8
gets allocated at p5->FD (malloc_hook-0x20-3). What’s left for us to do is see how many bytes away from our recent allocation is the __malloc_hook
function pointer and write our function pointer to the jackpot! It is also left for the reader as an exercise to see why the following values take place. Just one hint: Chunk headers are 0x10
bytes in 64bit
.
*(void **)(p8+0x13) = jackpot; // (1)
That’s it! We have overwritten __malloc_hook
with the pointer to the jackpot
function (1). All that is left to do is call malloc()
once again and we will have a nice shell going on :)