These two challenges from Nahamcon CTF 2025 will abuse tcache with Use-After-Free primitives to hijack control flow of a program without PIE and a program with PIE.
Lost Memory
1 2 3 4 5 6 7 8 9 10 11
anthonyjs@frosty:~$ checksec --file lost_memory [*] '/home/anthonyjs/lost_memory' Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x3fe000) RUNPATH: b'./' SHSTK: Enabled IBT: Enabled Stripped: No
Quick look at protections here reveals that the GOT is writable and there is neither PIE or canaries. A quick peek at the libc that the binary was built with also reveals that we are looking at libc 2.31.
Reverse Engineering
Very quickly we can scout that this is a standard CRUD application:
1 2 3 4 5 6 7 8 9 10 11 12
voidmenu(void)
{ puts("1. Allocate Memory"); puts("2. Write to Memory"); puts("3. Select Index"); puts("4. Free Memory"); puts("5. Store Flag Return Value"); puts("6. Exit"); puts("Enter your choice:"); return; }
We see that we can allocate up to 256 bytes maximum.
For selecting an index:
1 2 3 4 5 6 7 8
printf("Select an index to write to (0 - %d)\n ",9); fgets(&input,0x100,stdin); memIndex = atol(&input); memset(&input,0,0x100); if (9 < memIndex) { puts("Invalid index"); return; }
We see that we can only have up to 9 allocations.
Now knowing that 256 is our max size and we can only maintain 9 allocations at a time, its pretty obvious were going to be in tcache land. You’ll also notice here that no checks on if the index is null (unallocated) are taken.
if (choice == 2) { puts("What would you like to write?"); fflush(stdin); fgets(&input,256,stdin); if (input == '\0') { puts("No input provided"); return; } puts("Writing to memory..."); memcpy(*(void **)(ptr + memIndex * 8),&input,(long)*(int *)(ptrSize + memIndex * 4)); printf("ptr[memIndex] = %s\n",*(undefined8 *)(ptr + memIndex * 8)); printf("input = %s\n",&input); memset(&input,0,0x100); } ... elseif (choice == 4) { if (*(long *)(ptr + memIndex * 8) == 0) { puts("No memory to free"); } else { puts("Freeing memory..."); free(*(void **)(ptr + memIndex * 8)); } }
We do see a check here when freeing to avoid freeing a null ptr, but after freeing a valid pointer it is never set to null so this case will only ever be hit when the index was never initialized.
We now know that we have a valid Use-After-Free primitive and we do not have PIE or Safe Linking.
We can turn this into an arbitrary allocation primitive very easily.
Now when you allocate the same size of said chunk, you will be returned the first item in the list (here it would be chunk_two). The next item in the list would then become the next pointed to chunk given by chunk_two. But we already established that we could change a chunk after it was freed. This will allow us to overwrite what that next pointer is and then when we call malloc(size) again, we will be returned the address that we fabricated.
This will be useful in leaking libc for us and for allocating somewhere for us to throw a rop chain later on.
The next challenge is figuring out where we want to write our rop chain in order to get control flow. We know we can allocate wherever we want, but where do we want to?
Luckily for us, this feature in the code leaks a stack address:
From here we can take this stack address and calculate the return address on the stack. From there we simply place our rop chain and get the function to return.