Post

L3akCTF 2025 - Notes++

Negative Indexing bug to Per-Thread Struct Control

L3akCTF 2025 - Notes++

Introduction

This binary features a classic heap notes challenge written in C++. My first thoughts on analysing this challenge were that this would probably involve some form of type confusion between the different note classes. I haven’t looked into what the author’s solution was yet; however, I believe my solve took an unintended approach.

Challenge description

Yet another notes app. Now written in C++ for more memory safety.

Handout Files:

  • chall (Challenge Binary)
  • Dockerfile
  • run (script that executes chall on Docker)

Initial Analysis

checksec reveals that the binary has all protections enabled and the libc version is 2.39.

1
2
3
4
5
6
7
Arch:     amd64-64-little
RELRO:      Full RELRO
Stack:      Canary found
NX:         NX enabled
PIE:        PIE enabled
RUNPATH:    b'././libs'
Stripped:   No
1
2
GNU C Library (Ubuntu GLIBC 2.39-0ubuntu8.4) stable release version 2.39.
Compiled by GNU CC version 13.3.0.

We can see that the program is a simple notes challenge with options to create, list, delete, display and set note contents.

It allows us to create 3 types of notes: Random, Fixed and Dynamic.

  • Random Notes have a size of 0x18 and can be set with a randomly selected phrase from the allowed strings.

  • Fixed Notes have a size of 0x30 and can be filled with data up to the end of the chunk.

  • Dynamic Notes have a size of 0x28 and can be filled with more data than initially allocated, as it allocates a new chunk for data alone.

You can skip the next section if you don’t care about the finer details of how the challenge works.

Reversing

We will start with the main function.

The Note class structure consists of a pointer to its vtable along with other pointers and data that is specific to each of the note types.

We have a vector that tracks the notes that are currently in use; the notes are pushed into the vector as they are created.

1
2
3
chVt_ptr = **std::vector<Note *>::operator[](note_array, i);
                if ( (((chVt_ptr - (&`vtable for'RandomNote + 2)) << 58) | ((chVt_ptr - (&`vtable for'RandomNote + 2)) >> 6)) > 3 )
                  __asm { ud1     eax, dword ptr [eax+2] }

Every chunk operation has a check implemented on its vtable pointer which, upon failure, triggers a SIGILL. This is meant to prevent changing the chunk vtable pointers with any other address.

The delete chunk operation is interesting; it deletes the chunk, and due to the vector implementation, the elements are shifted back to account for the chunk that is missing in the vector.

Random Note

The Random Note class constructor initialises the fd to the Random Note vtable pointer and the bk and bk+8 values to null.

The RandomNote::setContent() function calls the RandomNote::getRandomPhrase() function to get a random string from the rodata section and sets bk of the Random Note chunk to the length of the string being printed while bk+8 is the string pointer.

The RandomNote::displayContent() function prints out the data at the bk pointer.

Fixed Note

The Fixed Note class constructor initialises the fd to the Fixed Note vtable pointer.

The FixedNote::setContent() function stores data as a char array of 40 bytes as a part of the Fixed Note chunk itself.

The FixedNote::displayContent() function prints out 40 bytes of data starting from the chunk bk.

Dynamic Note

The Dynamic Note class constructor initialises the fd to its vtable pointer and the bk to the data chunk pointer.

The DynamicNote::setContent() function takes input using getline; if the data can fit within the note chunk, then no new chunk is allocated, and the data is written to itself. If the data read is larger than the existing chunk, then a new chunk is allocated according to the size of the data.

The DynamicNote::displayContent() function prints out the data at the bk pointer until it encounters a newline character.

Approach

Obtaining Leaks

We can simply free a chunk large enough to be put into the unsorted bin to get libc pointers on the heap. This can be done by allocating a Dynamic Note and writing 0x5f8 no. of bytes into it which will cause the program to allocate an unsorted bin-sized chunk. We can then free this chunk and create a Fixed Note which will now be allocated over the libc pointers in the unsorted-bin chunk.

img_description Fixed note chunk with libc and heap pointers

The reason I created a fixed note rather than a Dynamic or Random note is that we can simply display the contents of a Fixed Note chunk while the other chunk types take pointers to the data they read. The Fixed note class also writes data without adding a null byte at the end of the input. This is useful later, as we need to overwrite the libc pointer with junk data so that we can leak the heap pointer next to it. This gets us the heap leak.

img_description Chunk after padding data is set

Now we’ll go ahead with getting a binary base leak so that we can obtain the note class vtable pointers. To get the binary base leak, I decided to leak the address of one of the random phrases set by a Random Note chunk. To do this, I decided to allocate a bunch of Random Note chunks and set their content to create binary base pointers within the chunks. We free these chunks to add them to the fastbins. Once this is done, we can allocate a chunk that is larger than the size of the fastbin chunks combined, such that it triggers fastbin consolidation and we get a large unsorted bin chunk with binary base pointers in it.

img_description Smallbin chunk obtained after consolidation

We can once again allocate a Fixed Note chunk to leak the binary base pointer in the same fashion as the libc leak.

Now that we have a binary base leak we can create fake chunks with any vtable pointer we want. I used this method to create a fake Random Note chunk with a string pointer that was set to the libc environ symbol pointer. I used a Random Note chunk instead of a Dynamic Note as using a Dynamic Note read out a large amount of data resulting in lack of further messages being printed to stdout. Faking a Random Note provided a way to set the length of the data being printed out, causing no issues with buffering.

img_description Fake chunk created for stack leak

Now we can simply display the contents of the Random Note to get the stack leak with no buffering issues.

Per-Thread Struct Overwrite

Now we need a way to overwrite the return address on the stack; we need a way to allocate a chunk onto the stack. We can do this by abusing the per-thread struct.

img_description Bin counts - Red, Bin heads - Green

The per-thread struct is used to store information for the different Tcache bins, such as bin count and bin head pointers. We’d need to overwrite the position in the per-thread struct chunk that corresponds to the size we want to allocate on the stack. If the bin size we’re using is empty, then we’d also have to set the bin count to 1 so that we can allocate a chunk from the bin we want.

I chose to overwrite the head pointer of one of the bins with chunks already in it so that I don’t have to perform another write to set the bin count to 1.

img_description Stack pointer in per-thread struct

Now we can simply allocate a chunk that is about the size of our bin chunk to allocate it on the stack and perform a simple ret2system to get a shell.

Flag: L3AK{cl4n6_cf1_15_m057ly_600d}

Conclusion

Once the CTF ended, I learnt that the delete operation did have a negative indexing bug like the setContent and displayContent operations. I initially thought this wouldn’t be the case since the decomp showed that delete was the only operation that specifically used unsigned int for storing the index. It would appear I need to learn to trust decomp less and dynamically analyse more.

I also noticed another interesting solution by @s41nt0l3xus, which involved using the negative delete to shift the elements in the vector backward, including the header of the notes array, thus moving the heap addresses into previous chunks, allowing for much easier leaks. I enjoyed solving the challenge and will have to look into this vector quirk later. Thank you for the challenge @White.

Final Exploit

You can find the challenge handout and my exploit here.

This post is licensed under CC BY 4.0 by the author.