Repeated Failed Log-Ins: What’s Your Strategy?

11 commentsWritten on September 10th, 2011 by
Categories: ASP.NET MVC, express.js, node.js, Patterns, Performance, security

I've only been using the server that's hosting this blog for a week or two, so I'm still keeping a close eye on it. I check usage graphs (cpu, disk I/O and network) a couple of times a day to verify whether things are still running smoothly. This morning, I saw a noticeable increase in CPU usage and network activity that lasted for about 11 hours. I logged into the machine, checked some logs and found out that someone had conducted an 11 hour lasting brute-force SSH attack. It doesn't make much sense to try that on my server since my SSH daemon doesn't allow password authentication, and indeed there was no successful login during the attack so no harm done, right?

Even if such an attack is not successful, it does consume resources on the targeted server(s). And wasteful, unnecessary resource usage has always been a bit of a pet peeve of mine so I wanted to prevent this from happening again. For this particular scenario, it's pretty easy. I installed DenyHosts which routinely checks for repeated (configured at 5) failed log-in attempts, and adds the offending IP addresses to /etc/hosts.deny so every other attempted SSH connection from those IP addresses will be denied immediately. Each offending IP address will be purged from /etc/hosts.deny after 1 week. Then I added a firewall rule that prevents you from connecting through SSH more than 5 times in 60 seconds. If you go over 5 connections, it just starts dropping packets, and by the time the drop behavior for your IP address expires, you'll have been added to /etc/hosts.deny already. As I said, pretty easy in this scenario because there are great tools I can rely on.

But what would you do if you had to implement a strategy to deal with this yourself? The most interesting approach I've heard of is to add an incremental delay on each failed authentication attempt. If the user fails the authentication check, delay the response with 1 second. If the user fails the second time, delay the response with 2 seconds. Third failure means a delay of 3 seconds, and so on. This pretty much makes a brute-force or dictionary attack impossible. The key is though, that you can't block any of your request-handling threads because then you open yourself up to an easy DoS attack.

Implementing this for a web application built on Node.js and Express.js is incredibly easy (there's an ASP.NET MVC example later in this post btw). I took the authorization example of Express.js and made just a few minor changes. First of all, I added the delayAuthenticationResponse function:

function delayAuthenticationResponse(session, callback) {
  if (!session.attempts) {
    session.attempts = 1; 
  } else {
    session.attempts++;
  }

  setTimeout(callback, session.attempts * 1000);
}

This is the most important part of the implementation. Every time we get here, we increment the number of attempts for this user by one and store the number in the user's session. Side note: this is one of the few things you'd actually want to use a session for: session-related data. Then we schedule the callback to be executed after the number of attempts * 1000 milliseconds have passed. The important part to remember here is that Node's event loop is not blocked by this, so our ability to handle other requests is not impaired in any way. The only one who suffers here is the attacker. Note that in a real world implementation, you'd probably only want to start increasing the delay after 5 attempts or so, in order to not piss off users who're just having problems remembering their password.

Then I changed the authenticate function so that it receives a session as the first parameter, and uses our delayAuthenticationResponse function whenever something goes wrong:

function authenticate(session, name, pass, callback) {
  var user = users[name];

  if (!user) {
    return delayAuthenticationResponse(session, function() {
      callback(new Error('cannot find user'));
    });
  }

  if (user.pass == hash(pass, user.salt)) {
    delete session.attempts;
    return callback(null, user);
  }

  delayAuthenticationResponse(session, function() {
    callback(new Error('invalid password'));
  });
}

After that, it's just a matter of changing the function that is assigned to the login route:

app.post('/login', function(req, res){
  authenticate(req.session, req.body.username, req.body.password, function(err, user){
    if (user) {
      req.session.regenerate(function(){
        req.session.user = user;
        res.redirect('back');
      });
    } else {
      req.session.error = 'Authentication failed, please check your '
        + ' username and password.'
        + ' (use "tj" and "foobar")';
      res.redirect('back');
    }
  });
});

And there we go. This effectively makes it impossible to brute-force your way into this web application, and I'm sure you can agree it was rather easy to do so. Of course, this is only because Node.js is inherently non-blocking. In an environment where non-blocking is the exception rather than the rule, you have to keep a few more things into account when trying to implement this strategy.

For instance, ASP.NET MVC is a typical blocking web framework. There's a certain number of threads that are waiting to handle requests, and once they receive a request, they process that request in its entirety. That means that if your code has to wait on something, the request handling thread is blocked and can't handle any other requests. So obviously, if you'd like to implement this strategy for dealing with repeated failed log-ins, you really want to avoid doing something like this:

        [HttpPost]
        public ActionResult LogOn(LogOnModel model, string returnUrl)
        {
            if (ModelState.IsValid)
            {
                if (CredentialsAreValid(model.UserName, model.Password))
                {
                    FormsService.SignIn(model.UserName, model.RememberMe);
                    if (Url.IsLocalUrl(returnUrl))
                    {
                        return Redirect(returnUrl);
                    }
                    
                    return RedirectToAction("Index", "Home");
                }

                Session["attempts"] = Session["attempts"] == null ? 1 : (int)Session["attempts"] + 1;
                Thread.Sleep((int)Session["attempts"] * 1000);
                ModelState.AddModelError("", "The user name or password provided is incorrect.");
            }

            return View(model);
        }

(note: this is a slightly modified LogOn method from the default AccountController when selecting 'internet application' in the MVC project wizard)

While this looks like it does the same as the Node/Express example, it certainly doesn't. The experience for the attacker is the same, because each failed attempt causes the response time to be increased with an extra second. But on your server, the thread handling the request is blocking the whole time and is thus incapable of handling extra requests while you're making the attacker wait.

Luckily, you can use ASP.NET MVC's asynchronous controllers to provide an asynchronous implementation of an action without blocking the request handling thread:

        [HttpPost]
        public void LogOnAsync(LogOnModel model, string returnUrl)
        {
            if (ModelState.IsValid)
            {
                if (CredentialsAreValid(model.UserName, model.Password))
                {
                    FormsService.SignIn(model.UserName, model.RememberMe);
                    AsyncManager.Parameters["returnUrl"] = returnUrl;
                }
                else
                {
                    Session["attempts"] = Session["attempts"] == null ? 1 : (int)Session["attempts"] + 1;
                    var timeout = (int)Session["attempts"] * 1000;
                    AsyncManager.OutstandingOperations.Increment();

                    var timer = new System.Timers.Timer(timeout) { AutoReset = false };
                    timer.Elapsed += (sender, e) =>
                    {
                        ModelState.AddModelError("", "The user name or password provided is incorrect.");
                        AsyncManager.Parameters["model"] = model;
                        timer.Dispose();
                        AsyncManager.OutstandingOperations.Decrement();
                    };
                    timer.Start();
                }
            }
        }

        public ActionResult LogOnCompleted(LogOnModel model, string returnUrl)
        {
            if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl))
            {
                return Redirect(returnUrl);
            }

            if (model == null)
            {
                return RedirectToAction("Index", "Home");
            }

            return View(model);
        }

Your controller has to inherit from AsyncController instead of Controller to make this work. Of course, it's much more complicated and requires more ceremony compared to the Node/Express approach, but then again, ASP.NET MVC isn't optimized for this kind of usage whereas Node/Express definitely is.

Either way, no matter what web framework you use, if you can add an incremental delay to the response of each failed log-in attempt without blocking a request-handling-thread, you've added a very effective and low-cost protection against brute-force and dictionary attacks.

  • http://twitter.com/jcrespoalvez Javi Crespo

    Wouldn’t this implementation keep  connections opened for far too long? You’d probably want to limit the max delay to avoid clients hogging connections forever

    • http://davybrion.com Davy Brion

      If you get into the situation where you’re delaying a user’s responses, then yes, the actual connections will remain open far longer than they’d normally would. But the number of open connections that a machine can handle is far higher than the number of threads typically assigned to handle them. A server can have several tens of thousands of simultaneously open connections as long as it doesn’t need a thread or process to handle an incoming request from the moment the connection is opened until it is closed.

      Unless you’re the target of a distributed brute-force or dictionary attach, you’re unlikely to notice anything due to the connections remaining open longer.  And even then, it should take a long while before it becomes problematic.

  • http://twitter.com/jcrespoalvez Javi Crespo

    A server can have several tens of thousands of simultaneously open connections <- a client too, can't it?
    Imagine a script that constantly send bogus requests to your login page. The connections are handled asynchronously in the client too; when the client receives a response from the server it creates a new request within the same session. At the beginning the client would struggle to keep up with the server but once the delays would grow the forces between client and server would level.Now, get a few computers running a script like that and I think that the server would be in trouble. That's of course assuming that no firewall or router on the server premises would ban such a big load of requests from a few client machines… Not likely I guess :)

    • http://davybrion.com Davy Brion

      yeah, it’s possible but for limiting simultaneous connections i’d always prefer a firewall based solution

      when it comes to security, the most important thing to realize is that no matter what you do, if the attacker really wants to get in he will find a way sooner or later. All you can do is try to make it as hard as possible with multiple layers of defense. Script kiddies and lesser skilled attackers will probably quickly move on to something where they’ll get the instant satisfaction they crave.  But an attacker who’s determined to get into your system almost always has the advantage, provided that he has the time to try to circumvent your layers of defense.

      • http://twitter.com/jcrespoalvez Javi Crespo

        Indeed, a firewall would do the trick here.
        Completely agree with you, if a seasoned attacker is after your site, no matter how hard you try to secure it, you’re doomed… 

  • Hernan

    I do like your approach but i wont use sesiions for this, but the ip of the attacker. That way you destriy the session and avoid the problem of a script that creates bogus session ids. You still have the prolem of attackers that changes ips, but i saw that less frequently. Nice article.

  • Sdfsdf

    ertewrtwertewrt

  • http://twitter.com/dagda1 dagda1

    I pretty much do it a similar same way as you are describing but I also display a captcha that is displayed after the user fails their first login attempt.

  • http://jcallico.myopenid.com/ Javier Callico

    How a “session” is defined in node.js, using a cookie? What if the attacker is not sending cookies at all?

    • http://davybrion.com Davy Brion

      it uses a cookie, like asp.net

      as Hernan mentioned above, it’s better to store the number of attempts per IP address… it’s a bit more bookkeeping code to write, but not much

  • Anonymous

    The guess shoes new idea for the window display of the brand’s guess sale store in Paris has managed to make quite an impression.