My first and only CTF post

2025-04-26
#ctf

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 by abort(). 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:

  1. Using the UAF vulnerability to overwrite the global variable that keeps track of the chunks, redirecting a chunk pointer to the GOT
  2. 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 :)



[1]: Most likely because one doesn’t have to solve all of them to get all possible points in this class.