ASP.NET Core 2.2 makes it easy to secure web pages with a user name and password. Simply click a checkbox and the sensitive pages are protected.
But just how secure are the pages with the out-of-box .NET Core behavior? Well, we confirmed two major vulnerabilities. The .NET Core uses highly reusable authentication cookies, and it is easy to probe whether a user exists without needing to know any passwords.
Cookie Theft
Like most web applications, .NET Core checks the user's login credentials and leaves a set of cookies upon successful authentication. All the cookies are hashes that can be quickly validated and yet difficult to forge. In addition, the cookies are "Http-Only" cookies, meaning that they cannot be read or created with JavaScript. This effectively prevents malicious JavaScript from transporting cookie data to a 3rd part website.
However, the Http-Only flag is only honored by web browsers as a convention. Every modern browser comes with a web developer tool. For someone with a physical access to a logged-on machine, stealing the cookies from Chrome, for example, is as simple as the following 3 steps:
With services like PasteBin.com, one can create an unlisted online document from a Private/Incognito browser, leaving no trace of sending the cookies from an infiltrated machine.
Unauthorized access on a physical machine is more often than one might think, but this is a topic for another article. Since any cookies can be revealed, copied and smuggled out, what makes .NET Core particularly vulnerable?
The key is cookie reusability. Once a set of cookies are obtained, can they be used from a different location (IP address)? At a different Time?
The answer is Yes, and almost Yes. Although the stolen cookies will eventually become useless, they are "alive" for a considerable amount of time. And while they are valid on the source machine, they can also be used from anywhere else in the world.
The three cookies that are responsible for .NET Core authentication are:
The Antiforgery cookie takes on other random forms, but it can be copied regardless. ARRAffinity has the shortest string. .AspNetCore.Identity.Application has the longest.
Using the same browser's Developer Tools interface, we manually created the cookies and managed to load a password-protected page without being redirected to the login screen.
User Enumeration
The other vulnerability we discovered requires no local access at all. We wrote an automated script to load the login page and simulates the sign-on process. Based on the time the server takes to respond, we could determine whether the tried user exists in the system.
At first we compared invalid username/password time against a known valid account. Then we realized it was not at all necessary. It turns out, that not only .NET Core says Yes and No in different speeds, it also says No with varying delays.
Nearly all modern frameworks check passwords in a way that's not giving away how "close" the guessed password is in comparison to a valid target. Using a login we know exists, we compared the response time for hundreds of bad guesses against one good one. Not surprisingly there was no skew.
The resistance to time attack in the password checking part does not, however, translate to the login process overall. It is very easy to miss the user existence check. The process of verifying user name and password usually takes two steps. Each step has different performance requirements. To check whether one user exists among a large of pool of users, an efficient database query is used. This step has to be done fast.
The second step is to compare the password. This step is slow and intentionally so. If password comparison can be done quickly, the system is susceptible to brute force attack.
When a non-existing user is checked by the login form, there is no need to follow up with the second step. This is the basis of our probe. On a fast server, the time difference is not obvious but still measurable. When enough repeated requests, the sampling error can be averaged out. It turns out that this sample size can be as small as 30 iterations per pair. Each pair contains a login with a valid user name, and one with a user name in question. We never needed any password.
To make the experiment more interesting, we split the team to two. One side created 3 valid users; the other side, the blind team, was given a list of 10 suspects. The goal for the blind team was to figure out which were the 3 users.
The target machine has a timing skew of about 1.2, meaning the existing logins take on average 1.2 times longer to reject than the non-existing logins. To further eliminate statistical error, instead of using a fixed threshold, the blind team ranked all the averaged response time to reveal any patterns across the accounts:
To plot the time skew:
The logins with the least skew were successfully identified as the valid accounts.
What can we do with just a user name without password? Well, sometimes being able to verify the mere affiliation of an account is enough meta intelligence. For example, given a list of suspected emails, one can check if anyone has signed up for illicit services. The Panama Paper scandals and the Ashley Madison data breach are good examples.
Having a known user name also opens the door to further cracking one's password. Since most logins are also emails, the target can easily become a victim of a phishing attack.
In conclusion, do not "protect" your web content with that magical checkbox from .NET Core. Its default behavior uses cookies that are worthy of stealing, and verifying the existence of users is next to trivial.