Table of contents.
After two rather protracted blog articles detailing how and why I dug myself into this hole in the first place, I finally have time to document how I modified the code snippet for limiting WordPress login attempts, and applied it to this blog website.
Unlike my previous coding practices, however, I have opted to NOT share a full copy of my modified code snippet publicly.
There are a few reasons for doing so:
- After warning against the dangers of blindly copying and pasting code, it would be remiss to start encouraging such callous behaviors after only two blog posts.
- I am not a security professional, and I know nothing about login authentication. I am just one curious man poking around in my own WordPress website. The code I write is not well tested (despite my best attempts) and it would be irresponsible to even claim that it can protect anyone’s websites against an actual cyberattack.
- There are simply too many copying-and-pasting websites peddling random (and sometimes poorly understood) code snippets online, and I do not want to add my own (badly-written and barely-tested) code into the cacophony.
Anyway, enough of my ramblings.
Enjoy the rest of this blog post and try not to get too bored.
The premise.
By default, WordPress allows unlimited login attempts on their login page.
This is considered a bad security practice, because anyone with a little bit of know-how can download a script and take a crack at guessing the login credentials of any WordPress site, i.e. trying to log in repeatedly using a massive list of common usernames and passwords in a dictionary-based brute force attack.
I want to prevent this from happening on my own WordPress website.
But I did not want to rely on random security plugins, because I saw a rather simple looking piece of code online that claims to protect against repeated failed logins.
I ran the code on a test website, but it did not do what I expect.
Instead of tracking failed login attempts by the username or IP address, the code snippet simply kept a count of ALL failed login attempts in the last few minutes.
Once that number reaches a threshold, the code would prevent anyone else from logging in for a fixed amount of time, or until the snippet gets removed or disabled. This prevents even the owner or administrator of the website from getting in.
The login snippet does protect against brute force attacks, but it also introduces a new vulnerability: anybody can abuse this protection mechanism to lock down a WordPress site for good, completely denying all access to the website backend.
So I studied the code, and decided I could modify it to track and block login attempts based on the originating IP address of the login request.
This approach allows me to (temporarily) reject repeated login attempts coming from a single source, while removing the vulnerability that allows anyone to cause a complete system lockdown simply by failing to log in a certain number of times.
The modifications I made.
Identifying an IP address.
First, I need to identify the originating IP address of the failed login. A quick search online revealed the following code from stackoverflow.com:
<?php
if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
$ip = $_SERVER['HTTP_CLIENT_IP'];
} elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'];
} else {
$ip = $_SERVER['REMOTE_ADDR'];
}
?>
However, some comments in the same page indicated that HTTP_CLIENT_IP and HTTP_X_FORWARDED_FOR can be spoofed (i.e. faked) by an attacker, so I ended up creating a function that uses only a single line of code from the above:
<?php
function scttr_clds_get_login_ip() {
return $_SERVER['REMOTE_ADDR'];
}
?>
I also searched for cases where $_SERVER[‘REMOTE_ADDR’] would fail, and realized that it might not return anything if you were to say, install WordPress on your own computer and accessed it locally.
So I added more code to return the default localhost IP address for edge cases:
<?php
function scttr_clds_get_login_ip() {
return isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '127.0.0.1';
}
?>
Recording the IP address.
Now that we can identify the origin of each login attempt, we have to figure out a way to record and count the number of failed logins from any single IP address.
The easiest way is to create transients with unique $name(s) based on the IP. To help identify the transients we have created, we also need to prefix each transient $name with an identifier unique to this code snippet. For the purpose of my modified code, I have chosen to use ‘scttr_clds_attempted_login_’.
Since I am also a lazy bastard, I have wrapped everything in a function so I don’t have to repeat myself too often.
<?php
function scttr_clds_generate_transient_name() {
return 'scttr_clds_attempted_login_' . scttr_clds_get_login_ip();
}
?>
And I can simply call the above function before getting or setting a transient.
<?php
$transient_name = scttr_clds_generate_transient_name();
set_transient($transient_name, $data, $expiration);
if( false !== ($data = get_transient($transient_name)) ) {
// do something with $data if it has NOT expired
}
?>
Honestly, that is pretty much all you need to modify the original (flawed) login code snippet and make it less abusable. All you need to do is figure out how to weave the above functions back into the original code.
Bonus modification 1: Defining constants in PHP classes.
It is possible to define constants using PHP classes to make your code more readable. Something like this would do quite well for our purposes:
<?php
if ( !class_exists('SCLimitLogin') ) {
final class SCLimitLogin {
const TRANSIENT_PREFIX = 'scttr_clds_attempted_login_';
const TRANSIENT_TIMEOUT = 1800; // calculated in seconds
const MAX_LOGIN_ATTEMPT = 4;
private function __construct(){}
}
}
?>
It is important to note the naming convention in PHP, where constants are defined with all UPPERCASE letters separated by ‘_’ underscores.
As an example, you can utilise the above constants as follows in a previous function:
<?php
function scttr_clds_generate_transient_name() {
return SCLimitLogin::TRANSIENT_PREFIX . scttr_clds_get_login_ip();
}
?>
Or for setting transient expirations:
<?php
$transient_name = scttr_clds_generate_transient_name();
set_transient($transient_name, $data, SCLimitLogin::TRANSIENT_TIMEOUT);
?>
Bonus modification 2: Make error messages less verbose.
WordPress login error messages likes to be helpful. TOO helpful. For example, the following login error message tells attackers EXACTLY what they need to know:
Unbelievable, isn’t it?
By default, WordPress will always divulge the EXACT reason as to why your login attempt failed. And WordPress is not shy to also reveal that you CAN indeed log in with “admin” as the username – you just need to figure out the right password!
This makes it trivial to find out if a username exists on a WordPress site. Just try a list of common usernames on the login page, and WordPress will tell you its validity!
So if you need to brute force your way through a WordPress login page, WordPress is all too happy to lend a helping hand. Instead of making you guess BOTH a correct username AND a correct password, now you only need to get the password right!
Hooray for overly verbose error messages!
If you’re the owner of a WordPress site, however, you’d smack your forehead at one of the dumbest decisions ever made by the development team of an open source website builder that currently powers over 40% of the internet.
Fortunately, WordPress also provides us with a filter hook that allows us to evaluate and edit the error message BEFORE it gets displayed on the WordPress login page.
And we can make use of the filter like so:
<?php
function scttr_clds_evaluate_login_error($erorr_message) {
$result = "Incorrect username or password!"; // generic default error message
// add some logic to evaluate the $error_message and edit the $result accordingly
return $result;
}
add_filter('login_errors', 'scttr_clds_evaluate_login_error');
?>
With the above ‘login_errors’ filter, you can get the modified login snippet to provide useful feedback (e.g. the number of remaining login attempts) without revealing any confidential information that is supposed to be kept private (e.g. existing usernames).
All you need to do is figure out how to evaluate the $error_message, and integrate your code into the modified login snippet – on top of two other hooks originally used in the flawed login snippet: a filter hook called ‘authenticate’, and an action hook called ‘wp_login_failed’.
I would give you my solution, but I think this is better left as an exercise for anyone who bothered to read this far.
Author’s note: Verbose error messages is not the only way WordPress reveals confidential usernames to the public. I recommend reading up on WordPress user enumeration to find out how to patch up holes like these in your website.
Closing words.
As mentioned at the start of this article, I will not provide a full copy of my work.
I believe there is enough information provided in this blog post for anyone interested or motivated enough to make their own attempt at modifying the original login code snippet. And they should be able to arrive at a somewhat functional solution, at least for small websites with limited traffic.
If you have a moderate to large website, or have a good number of users logging into your website frequently, you are better off sticking with a proper paid security plugin instead of some random code snippet found on the internet.
Anyone who wishes to test out my modified login snippet are welcome to attempt logging in to my website using random usernames and passwords, and see what error messages would crop up. Most folks should have access to a home internet connection and a mobile data connection these days, which would allow you to test things out using at least two different IP addresses.
Important note: Anyone who tries and fails to log in will have their IP address recorded automatically as temporary transients in my website database.
This allows my website to count and deny successive failed login attempts from the same IP address. No other information will be recorded, and I won’t be able to tell which IP address belongs to who.