cristi075@home:~$

Mildly secure

TenableCTF 2023 - PseudoRandom writeup

PseudoRandom was a challenge at TeanbleCTF 2023 from the ‘Crypto’ category.
It involved a simple flaw in generating the key: instead of using a true random method of generating numbers, a pseudo-random method with a guessable seed was used.

The challenge

For this challenge, we got some code

import random
import time
import datetime  
import base64

from Crypto.Cipher import AES
flag = b"find_me"
iv = b"\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"

for i in range(0, 16-(len(flag) % 16)):
    flag += b"\0"

ts = time.time()

print("Flag Encrypted on %s" % datetime.datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M'))
seed = round(ts*1000)

random.seed(seed)

key = []
for i in range(0,16):
    key.append(random.randint(0,255))

key = bytearray(key)


cipher = AES.new(key, AES.MODE_CBC, iv) 
ciphertext = cipher.encrypt(flag)

print(base64.b64encode(ciphertext).decode('utf-8'))

And the output of that code

Flag Encrypted on 2023-08-02 10:27
lQbbaZbwTCzzy73Q+0sRVViU27WrwvGoOzPv66lpqOWQLSXF9M8n24PE5y4K2T6Y

Analyzing the code, finding the flaws

It looks like the key is randomly generated by using random.randint(0,255).
For seeding the random module, the author used the current timestamp (current, at runtime).

Then, that timestamp is printed but seconds and milliseconds are omitted.

First attempt at solving this

In order to solve this, we have to:

  • guess the seconds value (60 possible values)
  • guess the milliseconds value (1000 possible values)

This means that we have to search through 60.000 keys. This is doable.

First, I wrote a function to generate all possible keys based on a given ISO-formatted string.
This should return the 60.000 possible keys for any given string (in the format used by this challenge).

def generate_all_keys(partial_iso_string):
    result = []

    for seconds in range(60):
        # Add seconds to ISO-formatted string
        full_string = '%s:%02d' % (partial_iso_string, seconds)
        recovered_ts = datetime.datetime.fromisoformat(full_string)

        seed_without_millis = round(datetime.datetime.timestamp(recovered_ts) * 1000)
        # Add milliseconds values to timestamp
        for offset in range(1000):
            seed = int(seed_without_millis + offset)
            
            random.seed(seed)
            key = []
            for i in range(0,16):
                key.append(random.randint(0,255))

            key = bytearray(key)
            result.append(key)

    return result

Then, something to use a given key and try to decrypt our text:

def test_key(key):
    ciphertext_bytes = base64.b64decode(ciphertext)
    cipher = AES.new(key, AES.MODE_CBC, iv)
    try:
        cleartext_bytes = cipher.decrypt(ciphertext_bytes)
        cleartext = cleartext_bytes.decode('utf-8')
        print('Recovered plaintext bytes: %s' % cleartext_bytes)
        print('Recovered plaintext: %s' % cleartext)
    except:
        if b'flag' in cleartext_bytes:
            print('Partial cleartext?: %s' % cleartext_bytes)
        pass

Putting those together and adding a local test that would generate a ciphertext (of a fake flag) based on the current timestamp and the final script looks like this.

solve.py (click to expand)

import random
import time
import datetime  
import base64
from Crypto.Cipher import AES

seed_datetime = '2023-08-02 %02d:27'
ciphertext = 'lQbbaZbwTCzzy73Q+0sRVViU27WrwvGoOzPv66lpqOWQLSXF9M8n24PE5y4K2T6Y'
iv = b"\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"


def setup_test():
    global seed_datetime
    global ciphertext

    print('----Test Flag----')

    flag = b"test{this_is_a_test_flag123}"
    
    for i in range(0, 16-(len(flag) % 16)):
        flag += b"\0"

    ts = time.time()

    seed_datetime = datetime.datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M')
    seed = round(ts*1000)

    random.seed(seed)

    key = []
    for i in range(0,16):
        key.append(random.randint(0,255))

    key = bytearray(key)


    cipher = AES.new(key, AES.MODE_CBC, iv) 
    ciphertext = cipher.encrypt(flag)

    ciphertext = base64.b64encode(ciphertext).decode('utf-8')


def generate_all_keys(partial_iso_string):
    result = []

    for seconds in range(60):
        # Add seconds to ISO-formatted string
        full_string = '%s:%02d' % (partial_iso_string, seconds)
        recovered_ts = datetime.datetime.fromisoformat(full_string)

        seed_without_millis = round(datetime.datetime.timestamp(recovered_ts) * 1000)
        # Add milliseconds values to timestamp
        for offset in range(1000):
            seed = int(seed_without_millis + offset)
            
            random.seed(seed)
            key = []
            for i in range(0,16):
                key.append(random.randint(0,255))

            key = bytearray(key)
            result.append(key)

    return result


def test_key(key):
    ciphertext_bytes = base64.b64decode(ciphertext)
    cipher = AES.new(key, AES.MODE_CBC, iv)
    try:
        cleartext_bytes = cipher.decrypt(ciphertext_bytes)
        cleartext = cleartext_bytes.decode('utf-8')
        print('Recovered plaintext bytes: %s' % cleartext_bytes)
        print('Recovered plaintext: %s' % cleartext)
    except:
        if b'flag' in cleartext_bytes:
            print('Partial cleartext?: %s' % cleartext_bytes)
        pass


def main():
    setup_test()

    for hour in range(24):
        keys = generate_all_keys(seed_datetime)

        for key in keys:
            test_key(key)


if __name__=='__main__':
    main()

Trying to run it in test mode Test run

And I can confirm that it works: I could get back the flag that I put in.

Now, commenting out setup_test() and running it again on the real data

Failing to get the flag

And … failure. No valid flag was retrieved.

The workaround

Timezones shouldn’t matter for timestamps like this, but I thought that I should try altering the hour anyway.
So instead of leaving in the hour from the original output, I tried using all the possible values there (24 values).

This increased the search space to 1.440.000 values (24 * 60.000). But this still runs in a reasonable time even without trying to parallelize the program.

This is how the final code looks like:

import random
import time
import datetime  
import base64
from Crypto.Cipher import AES

seed_datetime = '2023-08-02 %02d:27'
ciphertext = 'lQbbaZbwTCzzy73Q+0sRVViU27WrwvGoOzPv66lpqOWQLSXF9M8n24PE5y4K2T6Y'
iv = b"\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"


def generate_all_keys(partial_iso_string):
    result = []

    for seconds in range(60):
        # Add seconds to ISO-formatted string
        full_string = '%s:%02d' % (partial_iso_string, seconds)
        recovered_ts = datetime.datetime.fromisoformat(full_string)

        seed_without_millis = round(datetime.datetime.timestamp(recovered_ts) * 1000)
        # Add milliseconds values to timestamp
        for offset in range(1000):
            seed = int(seed_without_millis + offset)
            
            random.seed(seed)
            key = []
            for i in range(0,16):
                key.append(random.randint(0,255))

            key = bytearray(key)
            result.append(key)

    return result


def test_key(key):
    ciphertext_bytes = base64.b64decode(ciphertext)
    cipher = AES.new(key, AES.MODE_CBC, iv)
    try:
        cleartext_bytes = cipher.decrypt(ciphertext_bytes)
        cleartext = cleartext_bytes.decode('utf-8')
        print('Recovered plaintext bytes: %s' % cleartext_bytes)
        print('Recovered plaintext: %s' % cleartext)
    except:
        if b'flag' in cleartext_bytes:
            print('Partial cleartext?: %s' % cleartext_bytes)
        pass


def main():
    for hour in range(24):
        keys = generate_all_keys(seed_datetime % hour)

        for key in keys:
            test_key(key)


if __name__=='__main__':
    main()

And the output that I got after running it

The main web page

Flag obtained: flag{r3411y_R4nd0m_15_R3ally_iMp0r7ant}