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
}
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
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;
}
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;
}
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)
should take less time than
"a".repeat(10000) === "a".repeat(9999) + "b"
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.