WordPress Brute Force Protection Hack

Problem

The current “WordPress Brute Force Attack” has been targeting a lot of our blogs on PrimaryBlogger. We have nearly 10,000 blogs, so you can imagine how many requests we’re getting for wp-login.php. For some reason the attacks seem to be more prolific in the 7am-11am GMT time period. We’d been woken up on numerous mornings now over the previous weeks and were getting quite annoyed with it, so we decided to look into permanently protecting ourselves from brute force attacks.

The main signature of the attack is a POST request to wp-login.php with the usernames admin, adminadmin and administrator. We don’t allow any of those usernames on PrimaryBlogger, so initially we used Fail2Ban to block any requests coming from those usernames.

This worked for a short time, but it still allowed the users to load the entire WordPress instance until they’re blocked by iptables. That meant if we had 10,000 Fail2Ban blocked users in iptables and we blocked after two attempts, then it’s loaded at least 20,000 instances of the page. The wp-login page would directly hit our Nginx/PHP-FPM stack on each request too, blocking other users in the mean time. On one occasion the load of one of our servers was at 130, quite high to say we usually run around 0.5.

So, what next?

Mark had the idea of “why should it load the WordPress instance at all?“.

Solution

Our solution was to hack the wp-login.php file; we don’t want any part of WordPress to load so hacking into the wp-login.php file at the top worked. There are numerous downsides to this, mainly the fact that every time you upgrade WordPress you’ll have to make the changes again. For us, this isn’t too much of a problem, as we have routines in place for upgrading our instances of WordPress at each iteration. For others, this may be a nightmare for you to go through each time, but unfortunately as far as we’re aware you can’t currently hook right into the top of the WordPress wp-load.php file. You could use the `init` action, but that still leaves you loading the entirety of WordPress before any of the headers are loaded.

The actual hack entails adding a hidden field to the login form and sending it with the form POST data on login. If the hidden field is sent back with the POST request then the login can continue as normal. If the hidden field isn’t included or is wrong, then we’ll serve the end-user a 403 and stop the rest of the page load, therefor saving our server resources. We also added an error log output to let us know when a user had authed wrongly, we then use this to block the end-user/bot in Fail2Ban. So here’s what we did on WordPress 3.5.1, as of writing this…

  1. Either add this to your functions.php in your theme or make a new plugin and paste in the contents. It basically adds the hidden field to the login, register, lost password and retrieve password forms:
  2. function add_hidden_login_form_field() {
    ?>
      <input type="hidden" name="fromWPForm" value="<?php echo constant('LOGINFORMKEY'); ?>" />
    <?php
    }
    add_action( 'login_form','add_hidden_login_form_field' );
    add_action( 'lostpassword_form','add_hidden_login_form_field' );
    add_action( 'retrievepassword_form','add_hidden_login_form_field' );
    add_action( 'register_form','add_hidden_login_form_field' );
  3. Add the following near the top of your wp-config.php and change the "changeme" value to anything you like, just be warned that it will show up in the source code, so don't use any auth keys etc. You could put this with the wp-login.php code, but it makes it easier to change in future if needed if it's in the wp-config.php file:
  4. define( 'LOGINFORMKEY', 'changeme' );
  5. Next add this to your wp-login.php file above the
    require( dirname(__FILE__) . '/wp-load.php' );

    line.

  6. if ( isset( $_SERVER['REQUEST_METHOD'] ) && ( $_SERVER['REQUEST_METHOD'] === 'POST' ) ) {
      include_once( 'wp-config.php' );
      if ( ! defined( 'LOGINFORMKEY' ) )
        error_log( "Cannot test login form for key, LOGINFORMKEY missing from wp-config.php" );
      else
        if ( ( strlen ( strstr ( strtolower ( $_SERVER['HTTP_USER_AGENT'] ), "mobile" ) ) === 0 ) && ( strlen ( strstr ( strtolower ( $_SERVER['HTTP_USER_AGENT'] ), "android" ) ) === 0 ) ) {
          if ( !isset( $_POST['fromWPForm'] ) || ( $_POST['fromWPForm'] !== constant( 'LOGINFORMKEY' ) ) ){
            error_log( 'Bad attempt on wp-login from '. ( isset( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ? $_SERVER['HTTP_X_FORWARDED_FOR'] : $_SERVER['REMOTE_ADDR'] ) );
            header( 'HTTP/1.1 403 Forbidden' );
            include('errors/pb/403.html');
            die();
          }
      }
    }
  7. It appears that the reset password link from the email gives a form that isn't able to be modified by an action. Therefor you will need to add the following to below the submit button code in the "case 'rp' :" section of wp-login.php
  8. <input type="hidden" name="fromWPForm" value="<?php echo constant('LOGINFORMKEY'); ?>;" />
  9. The post password form will also not work with this hack unless you replace the original line with the following in wp-includes/post-template.php around line 1227, as part of the get_the_password_form function:
  10. <p><label for="' . $label . '">' . __("Password:") . ' <input name="post_password" id="' . $label . '" type="password" size="20" /></label><input type="hidden" name="fromWPForm" value="'. constant('LOGINFORMKEY') .'" /><input type="submit" name="Submit" value="' . esc_attr__("Submit") . '" /></p>
  11. Custom wp_login_form calls will also not work. We have had to modify wp-includes/general-template.php and add the following under the other hidden fields in the wp_login_form function. Most people will not need to do this, but if you're a theme developer including private areas using the wp_login_form function you will need to include this.
  12. <input type="hidden" name="fromWPForm" value="'. constant('LOGINFORMKEY') .'" />

Summary

As you can see, we're initially checking to see if the actual request is a POST request and if the LOGINFORMKEY is defined in the wp-config.php from above. If that's all good, then we go on to check if the LOGINFORMKEY is sent back with the POST request and if it's correct. If it's missing of wrong then we send a 403 forbidden header, include our error page and then kill the page load. You can also see that we've sent an error log message back to our http server logs, allowing Fail2Ban to read them and ban them.

So, using the above you can pretty much block all of the current brute force attacks on WordPress. We're not saying the code is fool proof, it will need testing in your environment and someone may be able to write a much better version, but most importantly it works for us.

I will reiterate that this is a messy messy hack, and it should only be undertaken in extreme circumstances.

If you have any opinions on the above, let me know on Twitter @Coopeh.

No comments yet.

Leave a Reply

css.php