In this post I’m walking through the “UAF” challenge from pwnable.kr, which combines a classic use-after-free vunlerability with a twist of C++ virtual functions.
Background: UAF and C++ VTables
Use-After-Free (UAF) is a memory corruption vulnerability that happens when a program continues to use a pointer after the memory it points to has already been freed. Since freeing manually allocated memory does not actually null the pointer or delete the allocated content, the old pointer may still refernece the region. If and when the allocator reuses the same chunk for a different object, the original code can end up interacting with attacker controlled data instead of the intended structure.
In C++, classes with virtual functions rely on a mechanism called a vtable (virtual table) to support dynamic dispatch. Eyach object of a polymorphic class contains a hidden pointer to its vtable, which is essentially an array of function pointers. When a virtual function is called, the program does not call the function directly but instead, it looks up the correct function address through the vtable and jumps to it.
This means that if an attacker can overwrite an object’s vtable pointer (for example through a UAF vuln), they can potentially redirect execution flow by pointing it to a fake vtable containing chosen function addresses.
Reconstructing the Source
Loading the binary into IDA and performing a decompilation of main(), we can mostly recover the relevant class behavior even without the original source code. The binary implements a simple C++ inheritance hierarchy where both Man and Woman derive from a base Human class. Because the classes use virtual functions every object begins with a vtable pointer making them interesting targets for heap corruption.
v1 = std::operator<<<std::char_traits<char>>(&std::cout, "My name is "); v2 = std::operator<<<char>(v1, (char *)this + 16); std::ostream::operator<<(v2, &std::endl<char,std::char_traits<char>>); v3 = std::operator<<<std::char_traits<char>>(&std::cout, "I am "); v4 = std::ostream::operator<<(v3, *((unsignedint *)this + 2)); v5 = std::operator<<<std::char_traits<char>>(v4, " years old"); return std::ostream::operator<<(v5, &std::endl<char,std::char_traits<char>>); }
Option 2 allocates a user controlled heap chunk using new[] and fills it with data read from a file and a size passed through a command line argument.
Finally, option 3 frees both objects but never nulls the pointers afterward. The dangling references remain accessible and can later be reused by option 1, creating a classic use-after-free vulnerability. If we reclaim one of the freed chunks with controlled data, we can overwrite the original object’s vtable pointer and redirect execution during the next virtual function call.
Our end goal is eventually to redirect control flow into the give_shell win function.
Inspecting the object at rbp-0x60 reveals its memory layout. The first 8 bytes contain the object’s vptr (pointer to its vtable), followed by the object’s data members such as the age (0x19, or 25d) and the string “Jack”.
Inspecting the vtable shows pointers to the class’s virtual functions.
1 2 3 4
gef➤ x 0x000000000040295e 0x40295e <Human::give_shell()>: 0xe5894855fa1e0ff3 gef➤ x 0x0000000000402b20 0x402b20 <Man::introduce()>: 0xe5894855fa1e0ff3
Virtual function calls work by indexing into the vtable like an array of function pointers. Since each entry is 8 bytes on x86-64, calling m->introduce() accesses the second entry, equivalent to vtable[1].
And one last check, we’ll inspect the heap chunk of the allocated object and note it down for later use.
So as we’ve inferred from the previous section, what entering 1 in the menu practically does is call vtable[1] so our goal here is to shift the vtable pointer down by 8 bytes resulting in give_shell being executed when m->introduce() is called.
Our vtable base address is 0x0000000000404d80, subtracting 0x8 from it results in the desired address of 0x0000000000404d78.
Firstly we’ll need to prove that we can indeed overwrite the object using the vulnerable read() function with a basic payload that can be easily traced through memory. Lets try matching the chunk size of 0x40 that we’ve seen earlier.
Due to the way glibc’s allocator works freed chunks are sorted by size and reused for same sized allocations, so our payload must produce a chunk of the same size as the original objects. The original objects were allocated with operator new(0x30), which with the 16-byte chunk header rounds up to 0x40. Requesting 46 bytes gives us 46 + 16 = 62, which rounds up to the same 0x40, ensuring we reclaim the exact freed chunk.
Take a close look at the resulting addresses, it is the exact same region that was allocated to m and w at the beginning which means that with a proper payload we are able to override the vtable and redirect execution flow.
The order of bytes is flipped due to the endianness of the binary, reversing the least significant bits to the front followed by a remainder of padding.
uaf@ubuntu:~$ ./uaf 46 /tmp/payload3 1. use 2. after 3. free 3 1. use 2. after 3. free 2 your data is allocated 1. use 2. after 3. free 2 your data is allocated 1. use 2. after 3. free 1 $ cat flag [flag]
Overall it was an enjoyable challenge that is simple yet intricate enough to cause my to scratch my head at times, and most importantly strengthening my familiarity with common C++ vulnerabilities.