ASP.NET Security Consultant

I'm an author, speaker, and generally a the-way-we've-always-done-it-sucks security guy who specializes in web technologies and ASP.NET.

I'm a bit of a health nut too, having lost 50 pounds in 2019 and 2020. Check out my blog for weight loss tips!

Hacking the Default ASP.NET Login Pages

Published on: 2019-10-21

Image by Gino Crescoli from Pixabay

If you are developing an ASP.NET website (it doesn't matter which version — Framework/WebForms, Framework/MVC, Core/MVC, and Core/Razor Pages are all affected), I strongly recommend not using the default user storage that comes if you choose "Individual User Accounts" and "Store user accounts in-app" when you first set up the website. There are several mistakes that Microsoft made in setting up this functionality that can allow hackers to steal usernames and passwords from websites with this configuration. I'll show you how to fix these issues, but first, let's examine why they exist in the first place.

Stealing Usernames

If you try to log into a default ASP.NET site using a bad login name vs. a good login name with a bad password, you get the same error message: "Invalid login attempt". While annoying for your users who can't remember which username they may have used for your website, this is put in place to help stop hackers from putting in random usernames to find ones that are valid. However, they made a mistake (at least from a security perspective) in their implementation — once the framework determines that a username doesn't exist in the system, it stops processing. Here is the implementation in the SignInManager in .NET Core (emphasis mine):

public virtual async Task<SignInResult> PasswordSignInAsync(string userName, string password, bool isPersistent, bool lockoutOnFailure)
{
  var user = await UserManager.FindByNameAsync(userName);

  if (user == null)
  {
    return SignInResult.Failed;
  }

  return await PasswordSignInAsync(user, password, isPersistent, lockoutOnFailure);
}

This probably doesn't seem much like a problem to most of you, since most developers think it is obvious that you should do as little processing as possible to save on time and computing as much as possible. This mindset, however, often leads to information leakage. Information leakage is the term for a hacker's ability to glean information indirectly by observing how the system behaves. The best example of information leakage that I can think of came from The Web Application Hacker's Handbook. In a nutshell, one of the authors was testing a system that allowed users to search for documents, but if a document was found the user would need to pay to see it. To get around the payments, a user could simply search for more and more detailed search terms with guesses on document content. If results were returned, then the guess was a good one and no need to actually purchase the document to verify.

In this case, information leakage within ASP.NET directly leads to the ability for hackers to pull usernames out of your system, probably without you noticing. All they have to do is try logging in with a few known-good accounts, compare the processing time to accounts they suspect are good, and check for the differences in processing time. Here is a chart of processing time I created logging in against an ASP.NET site with 1,000 known-good usernames (but bad passwords) and 1,000 known-bad usernames:

You can clearly see that the vast majority of bad usernames resulted in login processing times of 5-12 milliseconds and good usernames resulted in login processing times of 15+ milliseconds. Hackers might need to adjust the ranges they look for to determine what a good username is, but this is pretty clearly an effective method to pull usernames from your system.

I did ask Microsoft to fix this problem, but they told me that they wouldn't do so, largely because hackers could pull the same information out of your system by attempting to register users. If the user registration fails, hackers can be reasonably sure that that username already exists with a valid account. I think that this is a load of hooey for two reasons:

  1. There can be legitimate reasons not to fix security vulnerabilities, from the cost of fixing the issue is greater than the cost of the risk of exploitation to greater benefit can be made for the app if other work is done first. Using "the vulnerability exists elsewhere too" as your reason is NOT one of them.
  2. If I'm a hacker trying to get into your website, I would MUCH rather use the timing method as compared to the registration method to pull usernames from your system. Why? Hackers (including ethical ones) don't want to be noticed. Having one failed login attempt on a large chunk of usernames probably won't be noticed on most systems. Having a large number of new registrations probably will. If I could fix only one of these issues, I'd fix the timing issue.

The best fix (at least from a security perspective) for this issue would be to continue processing the login after determining the user doesn't exist. Unfortunately, in this particular case making those changes would require making changes in the SignInManager and UserManager (since subsequent calls don't check if the user is null, so it's not just a matter of overriding this one method), making upgrades to future versions of .NET harder. There is another fix that is easier to maintain, but since it is applicable to fixing the password issues the default implementation has, let's look at the password protection issues first.

Brute Forcing Passwords

The default password mechanism has a method to track bad login attempts and will lock out a user if too many failed attempts occur. (The defaults are 5 failed attempts result in a 5-minute lockout.) That is great functionality to have. The problems become apparent when you ask two questions:

  1. What happens after those five minutes are done? Is there any penalty for being locked out once?
  2. What happens if a user consistently has fewer failed attempts than the maximum?

To answer question #1, the system increments the "AccessFailedCount" in the "AspNetUsers" table after every failed attempt until it reaches the maximum. When the maximum is hit, it sets the "AccessFailedCount" back to 0 and sets the "LockoutEnd" date to a few minutes from that moment to signify that the user is locked out. So, while this system prevents a hacker from submitting thousands of password attempts in a short period of time, the system does not prevent a hacker from submitting 5 passwords every 5 minutes for each user in your system. If your website has predictable usage (i.e. is only used during normal business hours), then a hacker can likely try pulling credentials for weeks off-hours without being noticed. This likely gives a hacker more passwords than you might think, since in 2017 about 10% of users used one of the 25 most common passwords, meaning a hacker could get the passwords for 10% of your users in a half hour by trying 5 new passwords from the top 25 every 5 minutes. (In reality it would take longer than a half hour for a hacker to do this on your site. Luckily the ASP.NET framework has more stringent password requirements that would disallow the most common passwords on this list, making it harder, but certainly not impossible, for hackers to guess passwords.)

To answer question #2, if a user's account is never locked out, the "LockoutEnd" date is never set. And since the "AccessFailedCount" gets reset after every successful login, attempting a small number of logins then waiting for the user to reset the "AccessFailedCount" before the next attempt, would effectively erase any evidence of an attempt to hack a user's password.

Therefore, a hacker wishing to steal passwords from an ASP.NET system should attempt only a few logins for each user, wait at least a day, then try again. It would not be too long before the hacker would have passwords for several users.

Solving These Issues

Unfortunately, there doesn’t seem to be an out-of-the-box way to fix these issues. Fortunately, there is code you can add that will mitigate these issues. Hackers will still be able to exploit these vulnerabilities, but they will NOT be able to do so without you knowing.

The mitigation: log any and all failed login attempts and account lockouts, noting the time, source IP, and user ID (if username matched a user). Then, any time a user attempts a login, check to see if either that username or source IP has had an unacceptably large number of failed login attempts in the last day, week, month, etc. If so, send them to the lockout page without processing any of the .NET functionality. Such an approach has two advantages:

  1. Logging each failed attempt individually, rather than keeping a running count, makes it easier for you to see if hackers are trying to get in over a longer period of time.
  2. You have more flexibility about the criteria that you choose to lock out a user or source IP. For instance, you can choose to lock out a source IP if they have 10 failed attempts with 10 different usernames, helping stop leaking usernames.

Yes, this approach is more work, but it took less than a day to implement on our own site, so we don't think that it is too onerous for yours.