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 › 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?">← 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.