Brute force protection with ModSecurity

I recently noticed high volume of brute force login attempts directed at WordPress and Joomla sites residing on company’s shared hosting servers. In some cases, such attacks were hitting login pages at 10-20 times per second, tying up significant portion of system resources and slowing down websites.

I was thinking about ways to use ModSecurity to apply blanket brute force protection to all websites on a server. After a bit of research, I’ve found an excellent post and example here at  which uses rules to monitor and block IP addresses if they make too many failed login attempts. It’s a clever solution that makes distinction between successful login and brute force attempts using HTTP codes returned by the wp-login.php page.

However, it wasn’t working for all WordPress sites since some of them were not returning correct HTTP codes, most likely due to installed plugins. I decided to try improve on this solution and also expand it to cover Joomla sites as well.

Protection for WordPress & Joomla

This is the set of ModSecurity rules I’ve come up with, and they have been running on multiple servers for weeks without issues or customer complaints.

# WordPress & Joomla brute force protection rules for ModSecurity 2
# -----------------------------------------------------------------------------
#   TX.max_requests - maximum number of requests allowed over time period
#   TX.requests_ttl - time period (sec)
#   TX.block_ttl    - IP block duration (sec)
# -----------------------------------------------------------------------------
SecRequestBodyAccess On
SecDataDir /tmp
SecAction "phase:1,pass,setvar:TX.max_requests=11,setvar:TX.requests_ttl=180,setvar:TX.block_ttl=900,initcol:ip=%{REMOTE_ADDR},nolog,id:5001000"

# Drop and log connections from blocked IP's
SecRule IP:blocked "@eq 1" "phase:1,drop,log,id:5001001"

# WordPress
<LocationMatch "/wp-login.php">
    SecAction "phase:2,chain,nolog,id:5001002"
    SecRule REQUEST_METHOD "^POST$" "chain"
    SecRule ARGS_POST_NAMES "^log$" "chain"
    SecRule ARGS_POST_NAMES "^pwd$" "chain"
    SecAction "setvar:ip.request_count=+1,expirevar:ip.request_count=%{TX.requests_ttl}"

    SecRule IP:request_count "@ge %{TX.max_requests}" "phase:2,drop,setvar:ip.blocked=1,expirevar:ip.blocked=%{TX.block_ttl},log,msg:'Blocked for %{TX.block_ttl} sec',id:5001003" 

# Joomla
<LocationMatch "/administrator/index.php">
    SecAction "phase:2,chain,nolog,id:5001012"
    SecRule REQUEST_METHOD "^POST$" "chain"
    SecRule ARGS_POST_NAMES "^username$" "chain"
    SecRule ARGS_POST_NAMES "^passwd$" "chain"
    SecRule ARGS_POST:option "^com_login$" "chain"
    SecRule ARGS_POST:task "^login$" "chain"    
    SecAction "setvar:ip.request_count=+1,expirevar:ip.request_count=%{TX.requests_ttl}"

    SecRule IP:request_count "@ge %{TX.max_requests}" "phase:2,drop,setvar:ip.blocked=1,expirevar:ip.blocked=%{TX.block_ttl},log,msg:'Blocked for %{TX.block_ttl} sec',id:5001013" 

# Old Joomla
<LocationMatch "/administrator/index.php">
    SecAction "phase:2,chain,nolog,id:5001022"
    SecRule REQUEST_METHOD "^POST$" "chain"
    SecRule ARGS_POST_NAMES "^usrname$" "chain"
    SecRule ARGS_POST_NAMES "^pass$" "chain"
    SecAction "setvar:ip.request_count=+1,expirevar:ip.request_count=%{TX.requests_ttl}"

    SecRule IP:request_count "@ge %{TX.max_requests}" "phase:2,drop,setvar:ip.blocked=1,expirevar:ip.blocked=%{TX.block_ttl},log,msg:'Blocked for %{TX.block_ttl} sec',id:5001023" 

downloadYou can download the rule set here. It works both with WordPress 3, 4 and Joomla 2.5 and 3 versions, and can be easily adapted to cover virtually any CMS login page.

Basic idea is this: if single IP performs 10 login attempts at WordPress or Joomla login page within 3 minutes, IP will be blocked for 15 minutes and all it’s connections dropped. It does not differentiate between successful login or brute force attempt, since that has proven to be unreliable. Instead, it is assumed that 10 attempts from same IP address (be it successful or not) should cover regular website usage.

Of course, those values are easily customizable at the beginning of this rule set. Note that TX.max_requests is set to 11 in order to allow 10 attempts to proceed without blocking.

The attempt is counted only if it is directed to login page, and is using POST method with required variables. This is especially important for Joomla, since /administrator/index.php is used for many other things, not just logging users in.

The result can be seen in Apache access_log – after 10 attempts, entries show that no page content is returned to attacker and, at the same time, Apache error_log reports ‘ModSecurity: Access denied with connection close’ for that IP address.

x.x.x.x - - [01/Dec/2014:09:31:37] "POST /wp-login.php HTTP/1.0" 200 1519
x.x.x.x - - [01/Dec/2014:09:31:38] "POST /wp-login.php HTTP/1.0" 200 1519
x.x.x.x - - [01/Dec/2014:09:31:39] "POST /wp-login.php HTTP/1.0" 200 1519

x.x.x.x - - [01/Dec/2014:09:31:40] "POST /wp-login.php HTTP/1.0" 301 -
x.x.x.x - - [01/Dec/2014:09:31:40] "POST /wp-login.php HTTP/1.0" 301 -
x.x.x.x - - [01/Dec/2014:09:31:41] "POST /wp-login.php HTTP/1.0" 301 -

How to implement this solution

Note that this implementation is intended for server administrators.

You should already have ModSecurity installed, if not – there are some great tutorials to get you started, like this one on nixCraft.

If you’re running a cPanel server, ModSecurity is probably installed with EasyApache. In that case, place bruteforce.conf in /usr/local/apache/modsecurity.d folder and edit /usr/local/apache/conf/modsec2.user.conf file to add the following line:

Include /usr/local/apache/modsecurity.d/bruteforce.conf

If you don’t have cPanel, placing bruteforce.conf inside modsecurity.d folder (found usually at /etc/httpd/modsecurity.d) should be enough.

After these changes, restart Apache and everything should be up and running. Be sure to check error_log for any problems though. To test if rules are working, visit a WordPress site on the server and click on login button 10 times – that should block you from accessing it again for next 15 minutes.

Should you encounter error ‘collection_store: Unable to store collection’ or ‘collection_retrieve_ex: Unable to retrieve collection’ make sure that SecDataDir is writable by the web server user.

Share your thoughts on this article

Your email address will not be published. Required fields are marked *