Kraken - TUCTF

Unleash the Kraken

Kraken - TUCTF

Kraken was a challenge under the Crypto section of TUCTF hosted in January 2025. It involved predictable seeding and a little bit of brute force.

Provided Server Code:

import os
import time
import datetime
import struct
import time
import random
import users

class KrakenGuard:
    username = None
    TIMEOUT = 60 * 3
    # Isn't python beautiful
    users = users.users

    help = """
==================================HELP==================================
list                                - Lists users in the system
getmessage <username> <password>    - Gets the user's secret message
setmessage <message>                - Set your secret message
time                                - What time is it?
exit                                - Close the console
========================================================================
"""

    def __init__(self):
        self.prep_generator()
        self.load_users()
    
    def prep_generator(self):
        debug_time = time.time()
        print(debug_time)
        random.seed(int.from_bytes(struct.pack('<d', debug_time), byteorder='little'))

    def load_users(self):
        for user in self.users.keys():
            self.users[user]['session_token'] = self.gen_session_token(user, self.users[user]['password'])
    
    def create_user(self):
        username = ""
        password = ""
        while username == "" or username in self.users.keys() or len(username) > 10:
            username = input("What is your username: ")
        while password == "" or len(password) > 10:
            password = input("What is your password: ")
        self.users[username] = {'session_token': self.gen_session_token(username, password), 'password':password, 'message': ''}
        self.username = username
        time.sleep(1)

    def gen_session_token(self, username:str, password:str):
        return hashlib.md5(username.encode('utf8') + b':' + password.encode('utf8') + b':' + random.randbytes(5)).hexdigest()
    
    def check_identity(self, identity:str):
        byte_array = bytearray.fromhex(identity)
        if byte_array == hashlib.md5(self.admin_key).digest():
            return True
        return False

    def print_time(self):
        print(datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"))

    def parse_command(self, input:str):
        command = input.split(' ')[0]
        args = input.split(' ')[1:]
        if len(input) > 100:
            print("Command is too large!")

        if command == 'help' and len(args) == 0:
            print(self.help)
            return
        if command == 'time' and len(args) == 0:
            self.print_time()
            return
        if command == 'exit' and len(args) == 0:
            print("Goodbye!")
            exit()
        if command == 'getmessage' and len(args) == 2:
            if args[0] not in self.users.keys():
                print('User does not exist!')
                return
            if self.users[args[0]]['password'] != args[1]:
                print("Incorrect password!")
                time.sleep(1)
                return
            print(self.users[args[0]]['message'])
            return
        if command == 'setmessage' and len(args) > 0:
            self.users[self.username]['message'] = input[len(command)+1:]
            print("Message set!")
            return

        if command == 'list' and len(args) == 0:
            print(f"{'USERNAME':<15} {'SESSION TOKEN'}")
            for user in self.users.keys():
                print(f"{user:<15} {self.users[user]['session_token']}")
            return
        print("Invalid command! Type 'help' for help.")

    def run(self):
        print("Kraken Server 1.0")
        self.create_user()

        start_time = time.time()
        while (time.time() < start_time + self.TIMEOUT):
            answer = input("Console $ ")
            self.parse_command(answer)

KrakenGuard().run()

provided server code for the challenge

The first thing that sticks out here is that random.seed() is getting seeded in a predictable manner (via time.time()). One thing to note is that time.time() in python returns a measurement up to the microsecond. Additionally, the exact time that the server establishes connection and initializes the time seed is unknown (so we know that there will be some element of brute force here).

calling time.time()

When connecting to the remote server, we are presented with three usernames and session tokens when we run the "list" command. Looking at the commands provided in the "help" section reveal that there is also a "getmessage" option, but that requires both the username and password. With these limited commands, it was evident that the flag was hidden in one or some of these messages.

USERNAME        SESSION TOKEN
davy-jones      41dc6d1042f74adaa2202499e5a39ec5 
bob             50cc954a1551a86c461015b3a5f24741 
blackbeard      aaa86f4a5a0a6801f3cb8cf07acb85f1

provided usernames and session tokens after running the "list" command on the server

The gen_session_token() function takes in a username and a password to generate the session token. Additionally, it appends random.randbytes(5) before taking the hash (hashlib.md5()).

def gen_session_token(self, username:str, password:str):
    return hashlib.md5(username.encode('utf8') + b':' + password.encode('utf8') + b':' + random.randbytes(5)).hexdigest()

function that generates the session tokens

As mentioned before, we know that the random package is seeded in a predictable manner. However, we don't know the exact time seed that was used. The server does not print out the time it was initialized. A slow, less feasible approach would be to brute force both the time seeds and password (the two unknowns) along with the provided usernames to find matching session tokens. But luckily there is another approach that does not take nearly as much time.

When connecting to the server, it immediately prompts for a username and password to be used. The only requirement is that the password be less than 10 characters. What's great about this is that we now have access to a generated session token that uses two knowns (reducing the search space). I used "test" and "test".

The python random package when seeded behaves in a deterministic manner. Why does this functionality exist? Well, during testing of an application it provides a level of reproducibility – setting a seed allows the same sequence of random numbers to be produced and allows devs to isolate the effects of other variables.

Python’s random module relies on the Mersenne Twister (MT19937) Pseudo-Random Number Generator (PRNG), which is optimized for statistical randomness and speed. However, it is not cryptographically secure because 1. Its output is deterministic once seeded and 2. If enough sequential outputs are seen (624 to be exact), the internal state of the PRNG can be reconstructed. For cryptographic purposes, one should use the secrets module, which sources entropy from the system’s secure randomness pool (e.g., /dev/urandom on Linux/macOS.)

predictability of the random seeding

To find the correct time.time() seed used in the server, I initialized a connection the remote server via netcat. In another terminal, I called time.time() to get a general sense of when the connection was established.

After connecting to the server and running the "list" command, the challenge presented the following:

USERNAME        SESSION TOKEN
davy-jones      41dc6d1042f74adaa2202499e5a39ec5 
bob             50cc954a1551a86c461015b3a5f24741 
blackbeard      aaa86f4a5a0a6801f3cb8cf07acb85f1 
test            1841052546cdb0b5e031fdad79e02be7

Knowing the methodology behind the gen_session_token() and given a known password, username, and session token, a brute force approach was established:

estimated_time = 1737867677.0612311

def generate_session_token(username, password, seed_time, offset):
    random.seed(int.from_bytes(struct.pack('<d', seed_time), byteorder='little'))

    # get to the right offset of rand() by calling it by num of offset times
    for i in range(offset):
        junk = random.randbytes(5)

    random_bytes = random.randbytes(5)
    session_token = hashlib.md5(
        username.encode('utf-8') + b":" + password.encode('utf-8') + b":" + random_bytes
    ).hexdigest()
    return session_token


the_edit_time = estimated_time-2
the_end_time = estimated_time+2

test_key = "1841052546cdb0b5e031fdad79e02be7"
while the_edit_time<the_end_time:
    print(the_edit_time)
    if generate_session_token("test", "test", the_edit_time, 3)==test_key:
        print("FOUND TIME FOR SEED.", the_edit_time)
        break
    the_edit_time+=0.000001

The generate_session_token function here emulates the session generating token found in the server code. One important thing to note here is the "offset". There are four usernames in the server after we log in. Looking at the code, this means that when the session token for the "test" user is established, random.randbytes(5) has already been called three times for the previous three usernames. This means to accurately find the session token of the "test" user, random.randbytes(5) needs to be called three times beforehand (and four times total) to obtain the fourth number in that predictable sequence.

This function provides the exact seed used for the program. In this case, that seed is 1737867676.5708604.

users = [
    {
        "username": "davy-jones",
        "hash": "41dc6d1042f74adaa2202499e5a39ec5",
        "offset": 0
    },
    {
        "username": "bob",
        "hash": "50cc954a1551a86c461015b3a5f24741",
        "offset": 1
    },
    {
        "username": "blackbeard",
        "hash": "aaa86f4a5a0a6801f3cb8cf07acb85f1",
        "offset": 2
    }
]

After the seed was found, the only unknowns left were the passwords of the three provided users in the program. I made a dictionary of these users along with their hashes and the offset of their users (which represents the number of times rand.randbytes(5) was called previously).

To crack these passwords, I read in a large password wordlist (~14 million passwords available on GitHub) and performed a dictionary attack:

password_file = "passwords.txt"
with open(password_file, 'r') as file:
    passwords = [line.strip() for line in file]

for user in users:
    for password in passwords:
        if generate_session_token(user['username'], password, final_time, user['offset'])==user['hash']:
            print(f"FOUND PASSWORD FOR USER {user['username']}")
            print(password)
            break

Here is the complete solve script:

import random
import struct
import hashlib

# ran time.time() right when I accessed the server
estimated_time = 1737867677.0612311

def generate_session_token(username, password, seed_time, offset):
    random.seed(int.from_bytes(struct.pack('<d', seed_time), byteorder='little'))

    # get to the right offset of rand() by calling it by num of offset times
    for i in range(offset):
        junk = random.randbytes(5)

    random_bytes = random.randbytes(5)
    session_token = hashlib.md5(
        username.encode('utf-8') + b":" + password.encode('utf-8') + b":" + random_bytes
    ).hexdigest()
    return session_token


the_edit_time = estimated_time-2
the_end_time = estimated_time+2

test_key = "1841052546cdb0b5e031fdad79e02be7"
while the_edit_time<the_end_time:
    print(the_edit_time)
    if generate_session_token("test", "test", the_edit_time, 3)==test_key:
        print("FOUND TIME FOR SEED.", the_edit_time)
        break
    the_edit_time+=0.000001


# for the fake user, we have the password, username and just need the exact time used for seed...
"""
USERNAME        SESSION TOKEN
davy-jones      41dc6d1042f74adaa2202499e5a39ec5 (offset 0)
bob             50cc954a1551a86c461015b3a5f24741 (offset 1)
blackbeard      aaa86f4a5a0a6801f3cb8cf07acb85f1 (offset 2)
test            1841052546cdb0b5e031fdad79e02be7 (offset 3)
"""
users = [
    {
        "username": "davy-jones",
        "hash": "41dc6d1042f74adaa2202499e5a39ec5",
        "offset": 0
    },
    {
        "username": "bob",
        "hash": "50cc954a1551a86c461015b3a5f24741",
        "offset": 1
    },
    {
        "username": "blackbeard",
        "hash": "aaa86f4a5a0a6801f3cb8cf07acb85f1",
        "offset": 2
    }
]

# discovered time for seed...
final_time = the_edit_time

# used rockyou password dictionary
password_file = "passwords.txt"
with open(password_file, 'r') as file:
    passwords = [line.strip() for line in file]

for user in users:
    for password in passwords:
        if generate_session_token(user['username'], password, final_time, user['offset'])==user['hash']:
            print(f"FOUND PASSWORD FOR USER {user['username']}")
            print(password)
            break


# davy jones pass: kingof7seas
# bob pass: password123
# blackbeard pass: lochnessmonster

And there we have it! To get the flag, I just needed to use the "getmessage" feature available on the server with the respective usernames and passwords.

FLAG: TUCTF{k4p714n_KR4K3N_Kn33lS_83f0r3_y0U_329481!}