This is a medium binary exploitation challenge from the Hack The Box University CTF 2024. It involves some UAF primitive that leads to an arbitrary write.
Prison Break
Day 1077: In this cell, the days blur together. Journaling is the only thing keeping me sane. They are not aware that between the lines, I am planning my great escape.
For some context we’ll check the protections, the type of binary, and libc version we are working with:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
anthony@frosty:~/pbreak$ checksec --file prison_break [*] '/home/anthony/pbreak/prison_break' Arch: amd64-64-little RELRO: Full RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled RUNPATH: b'./glibc'
anthony@frosty:~/pbreak$ file prison_break prison_break: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter glibc/ld-2.27.so, BuildID[sha1]=79a5f3850fc483a70593f809c226e4db3644f915, for GNU/Linux 3.2.0, not stripped
anthony@frosty:~/pbreak$ ls glibc/ ld-2.27.so libc.so.6
{ int iVar1; setup(); banner(); LAB_00101b1d: while (iVar1 = menu(), iVar1 == 4) { copy_paste(); } if (iVar1 < 5) { if (iVar1 == 3) { view(); goto LAB_00101b1d; } if (iVar1 < 4) { if (iVar1 == 1) { create(); } else { if (iVar1 != 2) goto LAB_00101b5e; delete(); } goto LAB_00101b1d; } } LAB_00101b5e: error("Invalid option"); goto LAB_00101b1d; }
Opening up this binary, in the main function it looks to be a standard ctf menu challenge. The only interesting function that looks to be different than others is copy_paste. Let’s check out these implementations, starting with create.
{ int iVar1; void *pvVar2; long in_FS_OFFSET; int local_1c; void **local_18; long local_10; local_10 = *(long *)(in_FS_OFFSET + 0x28); puts("Journal index:"); local_1c = 0; __isoc99_scanf("%d",&local_1c); if ((local_1c < 0) || (9 < local_1c)) { error("Journal index out of range"); } elseif ((*(long *)(Chunks + (long)local_1c * 8) == 0) || (*(char *)(*(long *)(Chunks + (long)local_1c * 8) + 0x10) == '\0')) { local_18 = (void **)malloc(0x18); iVar1 = day + 1; *(int *)((long)local_18 + 0x14) = day; day = iVar1; puts("Journal size:"); __isoc99_scanf("%lu",local_18 + 1); pvVar2 = malloc((size_t)local_18[1]); *local_18 = pvVar2; *(undefined *)(local_18 + 2) = 1; if (*local_18 == (void *)0x0) { error("Could not allocate space for journal"); /* WARNING: Subroutine does not return */ exit(-1); } puts("Enter your data:"); read(0,*local_18,(size_t)local_18[1]); *(void ***)(Chunks + (long)local_1c * 8) = local_18; putchar(10); } else { error("Journal index occupied"); } if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) { /* WARNING: Subroutine does not return */ __stack_chk_fail(); } return; }
Picking this apart, we are able to allocate up to the 9th “Journal index”. We can also see the function checking if that index is zero’d out (checking that its not in use) and that other check we will learn later is an additional check to see if a chunk is in use. The other big thing to pickup here is that we supply the size to be passed to the second malloc for “Journal size”. Other than that everything else looks pretty standard and fine. Let’s check out delete.
{ long in_FS_OFFSET; int local_14; long local_10; local_10 = *(long *)(in_FS_OFFSET + 0x28); puts("Journal index:"); local_14 = 0; __isoc99_scanf("%d",&local_14); if ((local_14 < 0) || (9 < local_14)) { error("Journal index out of range"); } elseif ((*(long *)(Chunks + (long)local_14 * 8) == 0) || (*(char *)(*(long *)(Chunks + (long)local_14 * 8) + 0x10) == '\0')) { error("Journal is not inuse"); } else { *(undefined *)(*(long *)(Chunks + (long)local_14 * 8) + 0x10) = 0; free(**(void ***)(Chunks + (long)local_14 * 8)); } if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) { /* WARNING: Subroutine does not return */ __stack_chk_fail(); } return; }
And here all looks good. There is an index check to keep us within bounds. Chunks are checked before being freed, if they are already marked free, we don’t double free. Nothing crazy going on here. How about view.
These checks do not cover all cases. We get past these checks in the case that both of the chunks have non zero values at the first eight bytes and atleast ONE has the value of zero at [chunk+0x10]. As we saw before, when a chunk is freed, this occurs:
So when you free a chunk, the [chunk+0x10] will be set to zero, and the [chunk] should be set to metadata depending on the size and where it ends up (I.E. tcache, unsorted bins, etc). Knowing that we can use this to leak libc by freeing a chunk into the unsorted bins and then copying its contents into a already allocated chunk.
Exploitation
For simplicity, I allocated two chunks of size 0x500. From there I free’d one into the unsorted bins, where the first eight bytes of the chunk will be overwritten with the libc address of main_arena. Here is that code snippet:
So after doing so, we have successfully leaked a libc ptr. Now we want to focus on getting a write. Because of this copy function and the fact that we are interfacing with libc-2.27, we can abuse the tcache to get an arbitrary allocation to a address of our choice.
To do this we will abuse the faulty copy_paste checks again to overwrite the next ptr of a chunk that was freed into tcache. This is possible because safe-linking isn’t implemented in this libc. We will target the malloc free_hook so we can write the address of libc system to it and call free on a chunk containing /bin/bash.
Here is the code to do so, and I will walk through why it works:
# will get both those into tcache delete(3) delete(2) cp(1,2)
# now we have the ptr for free_hook in tcache as the second 24 allocation create(9, 24, p64(libc.sym['system'])) delete(0)
So in our current state of tcache we have at most 1 chunk of size 0x18 in the bin, which will get used when we started using create(). To account for this I generated two extra chunks (which is actually four because the sub-chunk is also of size 0x18). So when we free chunk 3 and 2 into tcache, we have more than enough entries to keep allocating. Now after we deleted the second chunk, we copied the first chunks contents into it, this means that the most recent tcache entry’s next ptr will be overwritten with the target address. So when we allocate a chunk of size 0x18, the next malloc of size 0x18 will return the address of the target.
So our create(9, 24, p64(libc.sym['system'])) here is allocating two chunks of size 0x18, one for the initial malloc and the second for the one we specify the size for. And that second one, as we discussed, will be pointing to the target address, where we will write the libc symbol for system.
Now that we have written that to the free_hook, all we need to do is call free with the argument we want to pass to system. And we do that by calling free on the chunk that has /bin/sh within, returning us a shell.