In this task we have to win a lottery game:
Basically each coupon costs $5 and we have $100 to spend. If we try to withdraw our money we get the amount of money we need to get our flag:
To show they are playing fairly, the give you a verification id that its the value you have to guess concatenated with a random salt to reach the AES 16 bytes block that is used to encrypt the string. So we get:
AES(<number to guess>#random_salt, ECB_MODE)
We are also given the source code where we can verify this:
from Crypto.Cipher import AES
from Crypto import Random
from datetime import datetime
import random
import os
import time
import sys
flag = open('flag.txt').read()
# config
start_money = 100
cost = 5 # coupon price
reward = 100 # reward for winning
maxNumber = 1000 # we're drawing from 1 to maxNumber
screenWidth = 79
intro = [
'',
'Welcome to our Lotto!',
'Bid for $%d, win $%d!' % (cost, reward),
'Our system is provably fair:',
' Before each bid you\'ll receive encrypted result',
' After the whole game we will reveal the key to you',
' Then, you can decrypt results and verify that we haven\'t cheated on you!',
' (e.g. by drawing based on your input)',
''
]
# expand to AES block with random numeric salt
def randomExtend(block):
limit = 10**(16-len(block))
# salt
rnd = random.randrange(0, limit)
# mix it even more
rnd = (rnd ** random.randrange(10, 100)) % limit
# append it to the block
return block + ('%0'+str(16-len(block))+'x')%rnd
def play():
# print intro
print '#' * screenWidth
for line in intro:
print ('# %-' + str(screenWidth-4) + 's #') % line
print '#' * screenWidth
print ''
# prepare everything
money = start_money
key = Random.new().read(16) # slow, but secure
aes = AES.new(key, AES.MODE_ECB)
# main loop
quit = False
while money > 0:
luckyNumber = random.randrange(maxNumber + 1) # fast random should be enough
salted = str(luckyNumber) + '#'
salted = randomExtend(salted)
print 'Your money: $%d' % money
print 'Round verification: %s' % aes.encrypt(salted).encode('hex')
print ''
print 'Your choice:'
print '\t1. Buy a coupon for $%d' % cost
print '\t2. Withdraw your money'
print '\t3. Quit'
# read user input
while True:
input = raw_input().strip()
if input == '1':
# play!
money -= cost
sys.stdout.write('Your guess (0-%d): ' % maxNumber)
guess = int(raw_input().strip())
if guess == luckyNumber:
print 'You won $%d!' % reward
money += reward
else:
print 'You lost!'
break
elif input == '2':
# withdraw
if money > 1337:
print 'You won! Here\'s your reward:', flag
else:
print 'You cannot withdraw your money until you get $1337!'
break
elif input == '3':
quit = True
break
else:
print 'Unknown command!'
print 'The lucky number was: %d' % luckyNumber
if quit:
break
print '[enter] to continue...'
raw_input()
print 'Verification key:', key.encode('hex')
if money <= 0:
print 'You\'ve lost all your money! get out!'
if __name__ == '__main__':
play()
The problem is that we cannot break AES, so we have to outsmart the system in a different way. There are two factors here that can help us with that:
First, the random salt appended to the value to guess is supposed to prevent us from creating a dictionary from Encrypted values to decrypted ones. Since the same value to guess will have many encrypted representations because of the salt appended. So here is the first mistake of the developers. The salt appended to the value to guess is not that random and turns out to be 000000000
many times because of the way the salt is calculated:
# expand to AES block with random numeric salt
def randomExtend(block):
limit = 10**(16-len(block))
# salt
rnd = random.randrange(0, limit)
# mix it even more
rnd = (rnd ** random.randrange(10, 100)) % limit
# append it to the block
return block + ('%0'+str(16-len(block))+'x')%rnd
Gynvael explained after the CTF was over than the reason for this was that:
Any number with a 0 as the last digit (i.e. 10% of numbers) rised to a high power will have all 000000000 at end and it gets truncated to % limit characters basically
The second factor is to find the way to play for free and I already showed you how to do it in the second screenshot. For each round we are presented with a verification value and after we choose an option, the value chosen is presented so we can verify that they were not cheating (although they dont give you the key so they could be cheating :D). Anyway, the second option, the one that lets us withdraw money works in the same way and so we can use it to know the number associated to an encrypted value.
So with that, we should be able to play and if we dont know the value associated to the encrypted value presented, we can ask for the withdraw process to get the lucky number associated to the crypto value and add them to a Encrypted-number map. If the encrypted value presented is in our map, then we can bet and win $100. Repeating the process can get us more than $1337 in less than 20 minutes
import socket
def read_until(s, text):
buffer = ""
while text not in buffer:
buffer = buffer + s.recv(1)
return buffer
host = "23.253.207.179"
port = 10001
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((host, port))
def play_round(mydict):
store = False
read_until(s,"Your money: $")
money = int(read_until(s,"\n")[:-1])
read_until(s,"Round verification: ")
encrypted = read_until(s,"\n")[:-1]
if encrypted in mydict:
guess = mydict[encrypted]
else:
guess = None
store = True
menu = read_until(s,"Quit\n")
if guess is not None:
# Bet
s.send("1\n")
read_until(s,"0-1000): ")
try:
guess = int(guess)
except:
guess = 1
s.send("{0}\n".format(guess))
else:
# Pass
s.send("2\n")
response = read_until(s,"The lucky number was: ")
num = read_until(s,"\n")[:-1]
if store:
mydict[encrypted] = num
if "won" in response:
print "win, money %d" % money
print "Guess %s Verification %s LuckyNum %s" % (str(guess), encrypted, num,)
if money > 1337:
print money
num = read_until(s,"\n")[:-1]
print num
s.send("\n")
print read_until(s,"Quit\n")
s.send("2\n")
print read_until(s,"\n")
print read_until(s,"\n")
print read_until(s,"\n")
exit()
if "lost" in response:
print "WTF"
exit()
num = read_until(s,"\n")[:-1]
s.send("\n")
mydict = {}
while True:
play_round(mydict)
The result: