ångstromCTF 2023: leek
100 points, 145 solves
TL;DR
Overflow all null-bytes between 2 heap chunks, causing
putsto leak the random generated bytes, then overflow the heap again to repair the chunk header.
Basic File Checks
The program is echoing our first input back to us, and prompting us to provide a presumably randomly-generated secret.
┌──(kali💀JesusCries)-[~/Desktop]
└─$ ./leek
I dare you to leek my secret.
Your input (NO STACK BUFFER OVERFLOWS!!): test
:skull::skull::skull: bro really said: test
So? What's my secret? idk
Wrong!In most cases, a binary with canary and NX enabled simply means we have to perform a canary leak and ROP attack to bypass the protections. As both of these protection mechanisms are enforced on the stack, this could also mean that the intended goal of this challenge is not about buffer overflow, but instead an exploitation on the heap.
┌──(kali💀JesusCries)-[~/Desktop]
└─$ checksec --file=leek
[*] '/home/kali/Desktop/leek'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)Static Code Analysis
Our goal is to GUESS the random bytes 100 times and invoke win() function to receive the flag. At first, it seemed like a Use-After-Free (UAF) challenge, but a vulnerable input function is used in line 44, combined with two heap chunks allocated adjacently in line 34-35, we have a Heap Overflow.
The chunk __s holds our input, and __s1 is the random bytes generated at runtime. Since both of them are allocated adjacently, we can overflow __s during input and overwrite the random bytes. This method is feasible, but I went for another method presented by CryptoCat to leak the random bytes.
Attack Strategy
Theoretically speaking, the puts function on line 46 will print out characters repeatedly until a null byte \0 is reached because there is no boundary check. Our attack strategy is to overwrite any null bytes that exists between our __s and __s1 chunks (to merge both chunks together), thereby allowing us to leak the random bytes when puts(__s) is called.
NOTE: We are not going to overwrite any data after the null byte.

Proof of Concept
Fuzzing user input with cyclic patterns show that the random bytes are leaked at offset 32.
Now we can use pwntools to receive the random bytes and re-submit them to pass the verification check. Based on the verbose debug info, we can see that the size of random bytes is 32, which is the exact same size as __s1 = (char *)malloc(0x20)

Debugging
Moving on to the 2nd part of the challenge, we have a memory corruption when free() is called because we have messed up the heap layout by overwriting it's header during the overflow. To continue program execution without any corruption, we need to repair the chunk header back to how it was.
The problem is, we have no idea how the heap looks like, so some debugging will be required. To visualize the layout of the heap, we can set 2 breakpoints: BEFORE OVERFLOW & AFTER OVERFLOW.

Since PIE is disabled, we can take the addresses directly from Ghidra and insert the breakpoints in our script.
On the first breakpoint, we can see how the heap looks like BEFORE OVERFLOWING with the following summary:
PURPLE: User input chunk with
0x21trailing bytes as the header.GREEN: Random bytes chunk with
0x31trailing bytes as the header, followed by the data.

On the second breakpoint, the header of GREEN chunk is overwritten by our cyclic pattern, which is why GDB interprets the size of the chunk as 0x6161616861616167. Decoding this hex value results in aaahaaag. This is the exact reason why the memory is corrupted.

Fixing Chunk Header
To continue program execution, we just need to utilize gets(__s) on line 53 to fill up our user input chunk, followed by a 0x31 byte, for the heap header to be valid.
Using the previous heap layout as reference, we need to fill up the entire PURPLE chunk with 24 bytes, followed by a 0x31 byte, then pad out the remaining bytes, so that it will appear 0x0000000000000031 and not 0x3100000000000000.

Solution
Flag: actf{very_l33k_of_y0u_777522a2c32b7dd6}
Last updated