using Programming;

A Blog about some of the intrinsics related to programming and how one can get the best out of various languages.

Doing Bad Cookie Authentication, but for the Right Reasons

Poor Cookie-Based Authentication with ASP.NET

Greetings everyone. Once again, it's been a while since I've posted anything. I've been swamped with work, personal issues, and then some. I got a new dog (hi Max!), and so on. Fortunately, I have a topic I want to talk about (you can probably guess based on the title what it is), and thanks to a friend of mine, who we'll call "Jim" becasue, well, that's his name, who asked about this on Twitter, I figured we could go all-in.

Disclaimer: I'm a rambler, and this was hastily written.

Jim was asking about cookie-based authentication in ASP.NET, which is a great topic because it's something you should absolutely never do, but we are going to do to help him demonstrate how such a thing can be used for test automation. (Jim is a VERY smart person, and is working up a demo on how we can use cookie authentication hand-in-hand with test automation to do a wide-variety of things. The idea is to allow a test client to authenticate itself to quickly and easily get into the application.) We're going to spend this whole blog-post going over a worst-practice, vs. a best-practice. We're going to do all the things I always say never do, and learn why. (There's a lot of reasons we should not do any of these things, I'll try to cover a few with some examples.)

Then, at the end, I'm going to show you how we could do this type of authentication easily and via some sort of API call, so that we could stick with the core authentication principles that we value, and also satisfy our testers. This will demonstrate where the testers and the developers should be able to work together, to develop a flow that works for both.

We're going to build this out in Visual Studio, as usual, and we'll go over how we can take a blank ASP.NET website and add cookie-based authentication. Typically, we would use Forms-based authentication, or Active Directory / Windows Authentication.

What is a cookie?

A cookie is a small (usually) piece of information that a users browser stores which allows them to carry and pass information to and from a website. This cookie is a piece of data that is sent client-to-server, and server-to-client. We use cookies to transfer non-confidential data, because cookies are incredibly insecure. Anyone can spoof a cookie, and we'll look at doing exactly that in this blog post as well.

Cookie-Based Authentication in ASP.NET

Alright, so let's go ahread and create some cookie-based authentication in our ASP.NET application. For this, we'll have three pages:

  • Default.aspx: the main landing page, visible to authenticated and unauthenticated users;
  • Login.aspx: the login page, this will test the user login and create an appropriate cookie;
  • Authenticated.aspx: a page only available to authenticated users;

Step 1: Create the Authenticated.aspx

This page is pretty basic markup-wise (even code-wise):

<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="Authenticated.aspx.cs" Inherits="Poor_Cookie_Authentication__17_4_2018_.Authenticated" %>

<!DOCTYPE html>

<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
    <title></title>
</head>
<body>
    <form id="form1" runat="server">
        <div>
            Hello <asp:Literal runat="server" ID="litUserName"></asp:Literal>!
        </div>
        <div>
            This page is only available to authenticated users.
        </div>
    </form>
</body>
</html>

Basically, we throw a single literal which will be the name of the authenticated user. We'll populate this from the code-behind file, which is also really simple. But, before we do the code-behind, let's build a user object.

I'm going to do extremely basic authentication: our User class will have a Username (email) and Password. It will also contain a static array of all eligible users, so as to allow us to "pretend" to log someone in. We'll create two sample users, with different passwords:

public class User
{
    public static User[] Users { get; } =
        new[]
        {
            new User() { Username = "ebrown@example.com", Password = "1234" },
            new User() { Username = "johndoe@example.com", Password = "5678" }
        };

    public string Username { get; private set; }
    public string Password { get; private set; }
}

This can be replaced with any type of user loading, but I kept it simple to allow easy demonstration.

Now, let's load the user:

public partial class Authenticated : System.Web.UI.Page
{
    protected void Page_Load(object sender, EventArgs e)
    {
        if (Request.Cookies["UserId"]?.Value == null)
        {
            Response.Redirect("Login.aspx");
        }

        var userId = int.Parse(Request.Cookies["UserId"].Value);
        var user = Models.User.Users[userId];
        litUserName.Text = user.Username;
    }
}

So, if it's not apparant, cookie reading is super simple in ASP.NET: simply call Request.Cookies[name].Value, I use ?.Value to avoid the need for an additional null-check. (If the cookie doesn't exist, Request.Cookies[name] returns null instead of throwing a KeyNotFoundException like a normal dictionary would.)

Step 2: Login.aspx

Alright, so the next step is to allow the user to login. This is really simple, and we'll do it with a small HTML page and code-behind. The login will take a username and password, and match that to a user in the User.Users array. If we find one, set a cookie with the ID.

Markup:

<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="Login.aspx.cs" Inherits="Poor_Cookie_Authentication__17_4_2018_.Login" %>

<!DOCTYPE html>

<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
    <title></title>
</head>
<body>
    <form id="form1" runat="server">
        <div>
            <asp:Literal runat="server" ID="litError"></asp:Literal><br />
            Username: <asp:TextBox runat="server" ID="txtUsername" TextMode="Email"></asp:TextBox><br />
            Password: <asp:TextBox runat="server" ID="txtPassword" TextMode="Password"></asp:TextBox><br />
            <asp:Button runat="server" ID="btnSubmit" OnClick="btnSubmit_Click" Text="Login" />
        </div>
    </form>
</body>
</html>

Code:

public partial class Login : System.Web.UI.Page
{
    protected void Page_Load(object sender, EventArgs e)
    {
        if (Request.Cookies["UserId"]?.Value != null)
        {
            Response.Redirect("Authenticated.aspx");
        }
    }

    protected void btnSubmit_Click(object sender, EventArgs e)
    {
        var username = txtUsername.Text;
        var password = txtPassword.Text;

        var user = Models.User.Users.FirstOrDefault(x => x.Username == username && x.Password == password);
        if (user != null)
        {
            Response.Cookies.Add(new HttpCookie("UserId", Models.User.Users.ToList().IndexOf(user).ToString()) { Expires = DateTime.Now.AddHours(8) });
            Response.Redirect("Authenticated.aspx");
        }

        litError.Text = "The username/password combination you entered does not exist.";
    }
}

There are two ways to set a cookie:

  • Use Response.Cookies.Add;
  • Directly call to Response.Cookies[name], which will create the cookie if it does not exist;

Here, I chose the former as it's clearer. We add a new response cookie, set the expiration for 8 hours from now, and then redirect the user to the Authenticated.aspx page.

Step 3: Our Default.aspx with logout

The last step here is to make our Default.aspx page, which is again simple, and perform our logout on this page.

Markup:

<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="Default.aspx.cs" Inherits="Poor_Cookie_Authentication__17_4_2018_.Default" %>

<!DOCTYPE html>

<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
    <title></title>
</head>
<body>
    <form id="form1" runat="server">
        <div>
            <asp:HyperLink runat="server" ID="hlLogin" Text="Login" NavigateUrl="~/Login.aspx"></asp:HyperLink>
            <asp:LinkButton runat="server" ID="lbLogout" Text="Logout" OnClick="lbLogout_Click"></asp:LinkButton>
        </div>
    </form>
</body>
</html>

Code:

public partial class Default : System.Web.UI.Page
{
    protected void Page_Load(object sender, EventArgs e)
    {
        if (Request.Cookies["UserId"]?.Value == null)
        {
            lbLogout.Visible = false;
        }
        else
        {
            hlLogin.Visible = false;
        }
    }

    protected void lbLogout_Click(object sender, EventArgs e)
    {
        Response.Cookies["UserId"].Expires = DateTime.Now.AddDays(-1);
        Response.Redirect("Default.aspx");
    }
}

We literally have a "Login" and "Logout", which one is shown depends on whether or not the cookie is set. On logout, we set the cookie expiration to the past so that the browser will delete it, and redirect the user.

Authentication complete!

Alright, so all-in-all we're basicallly done with designing the cookie-based authentication of the application, now let's move on to the insecurities of it.

Cookie-Based Authentication Insecurities

There are literally hundreds of things we could list that are reasons to not use cookie-based authentication, but let's just go over some of the basics:

  • Cookies are handled entirely client-side. That means, the client must be trusted to track the entire lifetime of the cookie: value, expiration, etc., all of it is in the client's hand.
  • Cookies are always plain-data. That is to say, they are not secured in any manner. In a non-HTTPS environment, cookies are clearly visible on transmission from client-to-server, and server-to-client. A man-in-the-middle attack with cookies is so unbelievably easy. (Facebook used to be vulnerable to session-hijacking using this attack, and I'll pull references on that later.)
  • Cookies carry small amounts of data. There are limits to the size of a cookie, due to the nature of it. Because cookies are passed in the headers of a web request or response, they are limited to the maximum size of a header.

Alright, so let's exploit some cookie insecurity. We're going to do four things:

  1. Change the expiration to keep ourselves logged in longer;
  2. Change the user ID to log in as someone else;
  3. Change the user ID to cause the server to error (if you place cookies into SQL this can do really bad stuff);
  4. Create a cookie with a user ID to login without credentials;

So the first task on our to-do list is to change a cookie expiration. We set it for 8 hours, but any sufficiently skilled user (and you really don't have to be all that skilled) can alter it. In fact, if you download the EditThisCookie plugin for Opera, you can do so with a two clicks. I'm going to use this to do the rest, and I'm just going to throw all the pictures with basic captions.

Step 1: We'll Login via Opera. That "Cookie" icon is our editor.

Opera Login

Step 2: Open the cookie. We click the "Cookie" Icon and expand the cookie we want ("UserId").

Opening the Cookie

Step 3: Edit the Expiration and click the "Checkmark" to save.

Edit Expiration

As you can see, pretty easy.

Next, we'll edit the value to be someone else:

Edit Value

New Page

Now I did not relogin, I used the original login and edited who I was. That's important to remember because it demonstrates why this is insecure.

We can error the server:

Bad Cookie

And even make a new cookie without logging in:

New Cookie

Automating our Tests

Ok, on to the fun part: let's automate a login, but without ever hitting the login page or function.

This is almost too easy with .NET, we'll use the WebClient and manually send the Cookie header. To do this, I created a TestAutomation.aspx page:

<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="TestAutomation.aspx.cs" Inherits="Poor_Cookie_Authentication__17_4_2018_.TestAutomation" %>

<!DOCTYPE html>

<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
    <title></title>
</head>
<body>
    <form id="form1" runat="server">
        <div>
            <asp:Literal runat="server" ID="litResponseData"></asp:Literal>
        </div>
    </form>
</body>
</html>

The code is trivial:

public partial class TestAutomation : System.Web.UI.Page
{
    protected void Page_Load(object sender, EventArgs e)
    {
        using (var wc = new WebClient())
        {
            wc.Headers.Add("Cookie", "UserId=1");
            litResponseData.Text = wc.DownloadString("http://localhost:60078/Authenticated.aspx");
        }
    }
}

Yes, we logged in and downloaded the Authenticated.aspx text with 5 lines of code, 2 of which were braces. By manualy sending the header, we made it too easy for us to work with.

If you comment out the wc.Headers.Add line, you'll see that it returns the login form. This is because the WebClient follows the redirects. With that line in, we get the Hello ...! message.

An Ideal World

Alright, so all of this is done to demonstrate the point of how easy it is to use cookie-based authentication, and how we can exploit it, but also the ease of which it does what we want. One of the things Jim had mentioned to me was that he wanted the ability to either turn authentication off, or some other way to allow the user to login, but without needing to do too much complex work.

Typically, in this scenario, this is where the developer and tester would work together on a solution, one of which might be a very simple API call to do login: pass a username and password, then it logs that session in. We can simulate this by accepting Username and Password query-string parameters in our Login.aspx:

public partial class Login : System.Web.UI.Page
{
    protected void Page_Load(object sender, EventArgs e)
    {
        if (Request.QueryString["Username"] != null && Request.QueryString["Password"] != null)
        {
            var username = Request.QueryString["Username"];
            var password = Request.QueryString["Password"];
            doLogin(username, password);
        }

        if (Request.Cookies["UserId"]?.Value != null)
        {
            Response.Redirect("Authenticated.aspx");
        }
    }

    protected void btnSubmit_Click(object sender, EventArgs e)
    {
        var username = txtUsername.Text;
        var password = txtPassword.Text;
        doLogin(username, password);
    }

    private void doLogin(string username, string password)
    {
        var user = Models.User.Users.FirstOrDefault(x => x.Username == username && x.Password == password);
        if (user != null)
        {
            Response.Cookies.Add(new HttpCookie("UserId", Models.User.Users.ToList().IndexOf(user).ToString()) { Expires = DateTime.Now.AddHours(8) });
            Response.Redirect("Authenticated.aspx");
        }

        litError.Text = "The username/password combination you entered does not exist.";
    }
}

So Login.aspx didn't change much, we just handle both cases now. By pushing login into a function, it made it easy to deal with the query-string based login, and the form-based login. We need to modify our TestAutomation.aspx page a little to accommodate:

<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="TestAutomation.aspx.cs" Inherits="Poor_Cookie_Authentication__17_4_2018_.TestAutomation" %>

<!DOCTYPE html>

<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
    <title></title>
</head>
<body>
    <form id="form1" runat="server">
        <div>
            <asp:Literal runat="server" ID="litResponseData1"></asp:Literal><br /><br />
            <asp:Literal runat="server" ID="litResponseData2"></asp:Literal>
        </div>
    </form>
</body>
</html>

The code is the big change:

public partial class TestAutomation : System.Web.UI.Page
{
    protected void Page_Load(object sender, EventArgs e)
    {
        // Method 1
        using (var wc = new WebClient())
        {
            wc.Headers.Add("Cookie", "UserId=1");
            litResponseData1.Text = wc.DownloadString("http://localhost:60078/Authenticated.aspx");
        }

        // Method 2
        var cookieContainer = new CookieContainer();
        var req = WebRequest.CreateHttp("http://localhost:60078/Login.aspx?Username=ebrown@example.com&Password=1234");
        req.CookieContainer = cookieContainer;
        req.GetResponse(); // We don't need to do anything with the response

        req = WebRequest.CreateHttp("http://localhost:60078/Authenticated.aspx");
        req.CookieContainer = cookieContainer;
        var response = (HttpWebResponse)req.GetResponse();
        using (var sr = new StreamReader(response.GetResponseStream()))
        {
            litResponseData2.Text = sr.ReadToEnd();
        }
    }
}

You see the // Method 2 comment? That is the part that uses the query-string to login. It can also use a POST to a form to login, if we wanted, though that is far more complex. Due to the ASP.NET event validation, it is far easier to virtually create the UI form in that case, and submit it. Instead, what we did is query-string based to support the testers use-case, and make it easy to do the testing, while also retaining most of our security.


This project is available on GitHub and I am allowing anyone to use it to any purpose, I simply ask that if you use the project directly, throw some sort of nice message on where you found it.