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
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
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
Flag obtained: flag{r3411y_R4nd0m_15_R3ally_iMp0r7ant}