DEV Community

Cover image for Understanding timing attacks with code examples
propelauthblog for PropelAuth

Posted on • Originally published at blog.propelauth.com

Understanding timing attacks with code examples

Vulnerable login example

The following code snippet has a subtle security issue with it. Can you tell what's wrong?

// Returns true if the email/password pair is valid
async function isValidCredentials(emailAddress, password) {
    // Fetch the password hash from the DB by email address
    const passwordHashOrNull = await fetchPasswordHash(emailAddress);

    // If there was no match, return false
    if (!passwordHashOrNull) {
        return false;
    }

    // Bcrypt is "a library to help you hash passwords"
    // Here we use the compare function to check that the
    //   provided password matches the hashed password in the DB
    const doesPasswordMatch = await bcrypt.compare(password, passwordHashOrNull);
    return doesPasswordMatch;
}

// Fetches the password hash from the DB
async function fetchPasswordHash(emailAddress) {
    // impl not important
}
Enter fullscreen mode Exit fullscreen mode

As a hint, let's look at how long a few calls to isValidCredentials takes:

async function timeIsValidCredentials(emailAddress, password) {
    console.time("Checking " + emailAddress);
    await isValidCredentials(emailAddress, password);
    console.timeEnd("Checking " + emailAddress);
}

await timeIsValidCredentials("test@test.com", "password");
// Checking test@test.com: 63.813ms
await timeIsValidCredentials("test@test.com", "password2");
// Checking test@test.com: 62.867ms
await timeIsValidCredentials("test2@test.com", "password");
// Checking test2@test.com: 4.017ms
await timeIsValidCredentials("test3@test.com", "password");
// Checking test3@test.com: 4.008ms
Enter fullscreen mode Exit fullscreen mode

There's a noticeable difference between how long test@test.com emails take and test2@test.com or test3@test.com.

It turns out that the issue is these lines:

  // If there was no match, return false
if (!passwordHashOrNull) {
    return false;
}
Enter fullscreen mode Exit fullscreen mode

By returning early if there was no match, an attacker can easily tell that test@test.com has an account, but test2@test.com and test3@test.com don't.

Timing attacks

This is a common example of a timing attack. They are a class of attacks where the length of time that your application takes to perform a task leaks some information.

In the login case, the difference in times made it pretty obvious from even one request. If the difference was more subtle, an attacker can make many requests over a long time and average them together to distinguish different cases.

Is it a big deal?

This might not seem like a big deal, but let's say I'm trying to find someone's personal email. I only have their name, and I know they have signed up for your site.

I can try a bunch of variations of firstname.lastname@gmail.com or lastname{3digitnumber}@gmail.com and so on until I find a valid one.

Additionally, there are other timing attacks that leak even more sensitive information, which we'll see in a bit.

How can we fix it?

There are a few strategies, but the simplest answer is "make sure all codepaths take the same amount of time". You don't have to do this everywhere, just in sensitive parts of the codebase.

Instead of returning early, we could have checked the password against some hash and then returned false:

// If there was no match, waste time and then return false
if (!passwordHashOrNull) {
    await bcrypt.compare(password, RANDOM_PASSWORD_HASH);
    return false;
}
Enter fullscreen mode Exit fullscreen mode

It is also useful to add rate limiting whenever possible. If an attacker needs a lot of requests to distinguish different cases, rate limiting them could make the attack impractical.

Timing attacks in practice

Recently, a clever timing attack was found in Lobste.rs' password reset. It exploited the fact that databases when comparing two strings will return early if the strings don't match.

So checking

"a".repeat(10000) === "b".repeat(10000)
Enter fullscreen mode Exit fullscreen mode

should take less time than

"a".repeat(10000) === "a".repeat(9999) + "b"
Enter fullscreen mode Exit fullscreen mode

This means that the more characters you have correct, the longer the call will take. An attacker could try different prefixes and see which one takes the longest to slowly determine a valid password reset token.

This same vulnerability exists anywhere where someone is checking a secret value directly against a database, so while it may seem pretty theoretical, there are definitely real world cases that have been reported and fixed.

Discussion (0)