Brute force is a simple type of attack: it consists of massively sending requests to a URL with different parameters each time. The main purpose is to try to find the right parameter combination. Usually, brute force is used to discover login/password credentials to enter a web application.

Fortunately, brute force is easy to detect, and the latest HAProxy version has everything you need to protect any web application web form from brute force.

In this article, we’ll apply the configuration on a WordPress CMS (Content Management System), which was brute forced around mid-April 2013.

Note that the ALOHA Load-Balancer firmware 5.5 and above also includes the features introduced here.

WordPress Login

WordPress provides the login form through the URL: /wp-login.php. Of course, it’s a regular GET. The user just fill-up the form, then the browser sends a POST on the URL /wp-login.php with form data in the body. So basically, a brute force attacker will forge a POST request on /wp-login.php and try many different form data combinations.

Below, is an example of a forged request to try the credential admin/admin:

log=admin&pwd=admin&wp-submit=Log+In&redirect_to=http%3A%2F%www.domain.tld%2Fwp-admin%2F&testcookie=1

We can clearly see the log and pwd fields.

Blocking a Brute Force

It would be easy with HAProxy to drop the TCP connection or to answer HTTP deny (403) status codes when we see somebody abusing it. Actually, the attacker could use this information to know the maximum request rate he can achieve without being blacklisted.

The current article proposes to send abusers into a sandbox that keeps on delivering a static version of the login form, letting the abuser try to hack your site but actually hacking a static page.

Furthermore, HAProxy will also slow down the abuser's request rate by tar-pitting the request during 1s.

How to Protect Against Brute Force Attacks with HAProxy

The ALOHA Load-Balancer configuration is split into 2 parts:

#1 Configuration for brute force detection in the backend

This configuration stores a hash of 3 elements: HTTP Host header, URL path, and source IP.
We’ll enable tracking only when the requests occur on the WordPress login URL (/wp-login.php) and if the method is POST.

Based on this, we can track the number of HTTP requests the source IP did over a period of 20s and we can decide if the source IP did more than 5 logins tentative during this period of time, then we want to flag this user as an abuser.
Basically, 5 tries are allowed per the 20s, over this limit, then the 6th try will make the user blocked.

[...]
  tcp-request inspect-delay 10s
  tcp-request content accept if HTTP
  # brute force protection
  acl wp_login                path_beg -i /wp-login.php
  stick-table type binary len 20 size 500 store http_req_rate(20s) peers local
  tcp-request content track-sc2  base32+src if METH_POST wp_login
  stick store-request base32+src            if METH_POST wp_login
  acl bruteforce_detection  sc2_http_req_rate gt 5
  acl flag_bruteforce       sc1_inc_gpc0      gt 0
  http-request deny if bruteforce_detection flag_bruteforce
[...]

#2 Configuration for blocking abusers in the frontend

The configuration below detects that a user has abused the login page and then redirects him into a sandbox where HAProxy has been configured to serve a WordPress login page.
This means the attacker will still think he is trying to brute force WordPress, but actually, he will brute force a static page !!!!! It will be impossible for him to know he has been sandboxed…

  • frontend configuration:

  tcp-request inspect-delay 10s
  tcp-request accept if HTTP
[...]
  acl wp_login                 path_beg     -i /wp-login.php
  acl flagged_as_abuser        sc1_get_gpc0 gt 0
  stick-table type binary len 20 size 500 store gpc0 peers local
  tcp-request content track-sc1  base32+src if METH_POST wp_login
  use_backend bk_login_abusers if flagged_as_abuser
[...]
  • sandbox backend configuration:

[...]
backend bk_login_abusers
  mode http
  log global
  option httplog
  timeout tarpit 1s
  http-request tarpit
  errorfile 500 /etc/haproxy/pages/wp_fake_login.http
  errorfile 503 /etc/haproxy/pages/wp_fake_login.http
[...]
  • errorfile content example is provided at the bottom of this article, in the Apendice section

The Protection in Action

Below is an extract of HAProxy logs (anonymized) which show the blocking capacity of the configuration above:

[...]
 ft_www bk_wordpress/w1 "POST /wp-login.php HTTP/1.1"
 ft_www bk_wordpress/w1 "POST /wp-login.php HTTP/1.1"
 ft_www bk_wordpress/w1 "POST /wp-login.php HTTP/1.1"
 ft_www bk_wordpress/w1 "POST /wp-login.php HTTP/1.1"
 ft_www bk_wordpress/w1 "POST /wp-login.php HTTP/1.1"
 ft_www bk_login_abusers/<NOSRV> "POST /wp-login.php HTTP/1.1"
 ft_www bk_login_abusers/<NOSRV> "POST /wp-login.php HTTP/1.1"
 ft_www bk_login_abusers/<NOSRV> "POST /wp-login.php HTTP/1.1"
[...]

5 attempts before being redirected to the sandbox, and still attempting 😉

Let’s have a look at the stick table:

# table: ft_www, type: binary, size:500, used:1
0x24f81e4: key=57FD750958BE12B3000000000000000000000000 use=0 exp=0 gpc0=1

# table: bk_wordpress, type: binary, size:500, used:1
0x24f8740: key=57FD750958BE12B3000000000000000000000000 use=0 exp=0 server_id=1 http_req_rate(20000)=6

Even if the http_req_rate decreases, as long as gpc0 is greater than 0 in the ft_www frontend stick-table, the user will be redirected to the sandbox.

Appendix

  • Errorfile content, which is the WordPress login page content:
    Don’t forget to change the www.domain.tld to your own domain, and don’t forget to update the Content-Length header using the following script from our GitHub: errorfile_content_length

[...]
HTTP/1.0 200 OK
Server: webserver
Date: Fri, 26 Apr 2013 08:17:37 GMT
Content-Type: text/html; charset=UTF-8
Expires: Wed, 11 Jan 1984 05:00:00 GMT
Cache-Control: no-cache, must-revalidate, max-age=0
Pragma: no-cache
Set-Cookie: wordpress_test_cookie=WP+Cookie+check; path=/
X-Frame-Options: SAMEORIGIN
Connection: close
Content-Length: 3253

<!DOCTYPE html>
        <html xmlns="http://www.w3.org/1999/xhtml" lang="en-US">
        <head>
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
        <title>Bedis &rsaquo; Log In</title>
        <link rel='stylesheet' id='wp-admin-css'  href='http://www.domain.tld/wp-admin/css/wp-admin.min.css?ver=3.5.1' type='text/css' media='all' />
<link rel='stylesheet' id='buttons-css'  href='http://www.domain.tld/wp-includes/css/buttons.min.css?ver=3.5.1' type='text/css' media='all' />
<link rel='stylesheet' id='colors-fresh-css'  href='http://www.domain.tld/wp-admin/css/colors-fresh.min.css?ver=3.5.1' type='text/css' media='all' />
<link rel="stylesheet" href="http://www.domain.tld/wp-content/themes/notes-blog-core-theme/custom/login.css" type="text/css" media="screen" /><meta name='robots' content='noindex,nofollow' />
<script type="text/javascript">
addLoadEvent = function(func){if(typeof jQuery!="undefined")jQuery(document).ready(func);else if(typeof wpOnload!='function'){wpOnload=func;}else{var oldonload=wpOnload;wpOnload=function(){oldonload();func();}}};
function s(id,pos){g(id).left=pos+'px';}
function g(id){return document.getElementById(id).style;}
function shake(id,a,d){c=a.shift();s(id,c);if(a.length>0){setTimeout(function(){shake(id,a,d);},d);}else{try{g(id).position='static';wp_attempt_focus();}catch(e){}}}
addLoadEvent(function(){ var p=new Array(15,30,15,0,-15,-30,-15,0);p=p.concat(p.concat(p));var i=document.forms[0].id;g(i).position='relative';shake(i,p,20);});
</script>
        </head>
        <body class="login login-action-login wp-core-ui">
        <div id="login">
                <h1><a href="http://www.domain.tld/" title="Bedis Sites">Bedis</a></h1>
        <div id="login_error">  <strong>ERROR</strong>: Invalid username. <a href="http://www.domain.tld/wp-login.php?action=lostpassword" title="Password Lost and Found">Lost your password</a>?<br />
</div>

<form name="loginform" id="loginform" action="http://www.domain.tld/wp-login.php" method="post">
        <p>
                <label for="user_login">Username<br />
                <input type="text" name="log" id="user_login" class="input" value="" size="20" /></label>
        </p>
        <p>
                <label for="user_pass">Password<br />
                <input type="password" name="pwd" id="user_pass" class="input" value="" size="20" /></label>
        </p>
        <p class="forgetmenot"><label for="rememberme"><input name="rememberme" type="checkbox" id="rememberme" value="forever"  /> Remember Me</label></p>
        <p class="submit">
                <input type="submit" name="wp-submit" id="wp-submit" class="button button-primary button-large" value="Log In" />
                <input type="hidden" name="redirect_to" value="http://www.domain.tld/wp-admin/" />
                <input type="hidden" name="testcookie" value="1" />
        </p>
</form>

<p id="nav">
<a href="http://www.domain.tld/wp-login.php?action=lostpassword" title="Password Lost and Found">Lost your password?</a>
</p>

<script type="text/javascript">
function wp_attempt_focus(){
setTimeout( function(){ try{
d = document.getElementById('user_login');
if( d.value != '' )
d.value = '';
d.focus();
d.select();
} catch(e){}
}, 200);
}

if(typeof wpOnload=='function')wpOnload();
</script>

        <p id="backtoblog"><a href="http://www.domain.tld/" title="Are you lost?">&larr; Back to Bedis</a></p>

        </div>


                <div class="clear"></div>
        </body>
        </html>

[...]
Subscribe to our blog. Get the latest release updates, tutorials, and deep-dives from HAProxy experts.