Stopping Brute-force Logins Against Wordpress

Update 4/26/2013: Given recent events, this post has gotten a bit of attention, there is another idea I came up with HERE that doesn’t require mod_security and seems to work quite well … In my last post I showed how to use Selenium to make complex brute force attacks easier.  I showed a very basic and quick example against my website.  Here is an even shorter post on how to mitigate the attack using the mod_security Apache module.  I won’t cover how to install it or configure it, but just show the rules I am using to detect and respond to a similar brute force attack.  It works reasonably well, although I must admit I haven’t done extensive testing with these rules. I don’t want to be blocked forever just in case I did something dumb, so I setup the rules to only blacklist a particular IP address for only five minutes.  You can definitely play with the timing and number of failed login attempts required to trigger the filter.  The way it is setup, it will block an IP address from accessing only the login page (the rest of the site is still available) if that IP address fails 15 times in three minutes.  The block lasts for five minutes, and is then reset. Before showing you the rules a brief explanation is in order.  Mod_security allows rules to work off of persistent storage.  There are a defined number of collections, and within them you can set a variable and a value.  There are a few features that allow you to either deprecate the count of a numeric variable over time, or to expire it completely after a specified timeframe.  Unfortunately, these variables work in an unexpected way.  I initially setup my rules to set a block variable (IP.bf_blocked) in the IP collection once a certain threshold had been reached, and the variable was set to expire in 5 minutes.  Every time I accessed the site after triggering the rule, I was still blocked.  I kept asking why aren’t my modsecurity expirevar values being honored?  Frustrating! So after furious searching and trying to figure out what was happening, I finally found a post here that explains the problem … “The reason for this is how ModSecurity handles expiry timers for variables. Basically, every time a collection is updated, the LAST_UPDATE_TIME timestamp for that collection gets set to the current time. Since we increment the request_count variable for every request, this will monotonically increase.” Okay, that makes sense.  The examples they provide are pretty good, but there are a couple of errors in the rules that mean they don’t work right.  Anyways, based on that information and some testing here is what I came up with for the wordpress login page:
<IfModule mod_security2.c>
        # This has to be global, cannot exist within a directory or location clause . . .
        SecAction phase:1,nolog,pass,initcol:ip=%{REMOTE_ADDR},initcol:user=%{REMOTE_ADDR}
        <Location /wp-login.php>
                # Setup brute force detection. 

                # React if block flag has been set.
                SecRule user:bf_block "@gt 0" "deny,status:401,log,msg:'ip address blocked for 5 minutes, more than 15 login attempts in 3 minutes.'"

                # Setup Tracking.  On a successful login, a 302 redirect is performed, a 200 indicates login failed.
                SecRule RESPONSE_STATUS "^302" "phase:5,t:none,nolog,pass,setvar:ip.bf_counter=0"
                SecRule RESPONSE_STATUS "^200" "phase:5,chain,t:none,nolog,pass,setvar:ip.bf_counter=+1,deprecatevar:ip.bf_counter=1/180"
                SecRule ip:bf_counter "@gt 15" "t:none,setvar:user.bf_block=1,expirevar:user.bf_block=300,setvar:ip.bf_counter=0"
        </location>
</IfModule>
But instead of playing with Selenium this time, we’ll just use Metasploit to test, since they have a nice module for performing brute force attacks against wordpress specifically:
msf > search wordpress

Matching Modules
================

   Name                                                      Disclosure Date  Rank       Description
   ----                                                      ---------------  ----       -----------
   auxiliary/scanner/http/wordpress_login_enum                                normal     Wordpress Brute Force and User Enumeration Utility

. . .

msf > use auxiliary/scanner/http/wordpress_login_enum
msf  auxiliary(wordpress_login_enum) > set RHOSTS 192.168.1.80
RHOSTS => 192.168.1.80
msf  auxiliary(wordpress_login_enum) > set VHOST www.frameloss.org
VHOST => www.frameloss.org
msf  auxiliary(wordpress_login_enum) > set USER_FILE /root/users
USER_FILE => /root/users
msf  auxiliary(wordpress_login_enum) > run

[*] http://www.frameloss.org:80/wp-login.php - WordPress Enumeration - Running User Enumeration
[*] http://www.frameloss.org:80/wp-login.php - WordPress Enumeration - Checking Username:'root'
[-] http://www.frameloss.org:80/wp-login.php - WordPress Enumeration - Invalid Username: 'root'
[*] http://www.frameloss.org:80/wp-login.php - WordPress Enumeration - Checking Username:'daemon'
[-] http://www.frameloss.org:80/wp-login.php - WordPress Enumeration - Invalid Username: 'daemon'
[*] http://www.frameloss.org:80/wp-login.php - WordPress Enumeration - Checking Username:'bin'
[-] http://www.frameloss.org:80/wp-login.php - WordPress Enumeration - Invalid Username: 'bin'
. . .
[*] http://www.frameloss.org:80/wp-login.php - WordPress Enumeration - Checking Username:'irc'
[-] http://www.frameloss.org:80/wp-login.php - WordPress Enumeration - Invalid Username: 'irc'
[*] http://www.frameloss.org:80/wp-login.php - WordPress Enumeration - Checking Username:'gnats'
[-] http://www.frameloss.org:80/wp-login.php - WordPress Enumeration - Invalid Username: 'gnats'
[*] http://www.frameloss.org:80/wp-login.php - WordPress Enumeration - Checking Username:'libuuid'
[-] http://www.frameloss.org:80/wp-login.php - WordPress Enumeration - Enumeration is not possible. 401 response
[-] 192.168.1.80:80 WORDPRESS - [18/66] - Bruteforce cancelled against this service.
[*] http://www.frameloss.org:80/wp-login.php - WordPress Brute Force - Running Bruteforce
[*] http://www.frameloss.org:80/wp-login.php - WordPress Brute Force - No valid users found. Exiting.
[*] Scanned 1 of 1 hosts (100% complete)
[*] Auxiliary module execution completed
msf  auxiliary(wordpress_login_enum) >
Pretty cool, it stops the attack after a few failed attempts, and five minutes later that IP address can re-attempt login.