Improving WordPress Login Security – Revisted

In a previous article, I spoke of a method for restricting access to the WordPress login screen by directing requests for “wp-login.php” away to a standard WordPress 404 page.

Server Ethernet Ports
https://www.flickr.com/photos/jemimus/8469760647

Recently I came across a situation with one site that I manage, where an unrelated change meant that this was no longer working. Interestingly, on some sites I manage it still works, and on others it does not – despite the underlying configuration of the web host being no different.

I believe I do know why it stopped working on some sites – but I choose not to explain the reason at this time for security purposes.

To get the “non-working” sites to work again, the included configuration needed to replace the configuration construct with the following, using the “Require” directive instead:

<Location />        
  ErrorDocument 403 /idontthinksotim
</Location>
<Files .htaccess>
  Require all denied
</Files>
<Files wp-login.php>
  Require all denied
  Require ip aa.bb.cc.dd
</Files>


Refer to the previous article for complete understanding, but the “Require all denied” directive here is equivalent to the combination of “order deny,allow” and “deny from all” directives in the previous example. It basically says “by default, deny everyone from the file.”

Each allowed IP is then listed in sequence below that – in this example “Require ip aa.bb.cc.dd”. This of course will need to be the IP address you wish to allow access from, and as before, can also use CIDR notation to allow entire ranges of IP addresses.

Other than the use of the “Require” directives, the concept of this article remains the same as the previous, and once again, don’t assume your website is 100% protected if you do this. Hackers are clever people, and if they are determined they will find a way.

Basically, each version could and should work – but if you find you’ve tried one, and it doesn’t work – try the other!

Improving WordPress Login Security

WordPress is the most common CMS used by websites, recently topping 43% market share of all sites currently on the internet.

With such a significant presence, it is also the largest target for website hackers, and given that it is open-source, good and bad actors are always examining the code for vulnerabilities.

There are plenty of things you can do to tighten defences – the Wordfence plugin is an excellent start, that I highly recommend.

https://flickr.com/photos/136770128@N07/40492737110

Another thing you can do is restrict access to the “wp-login.php” script, based on IP address – however, note that this solution will only work if you have a fixed and known IP address from which you will be logging in to your site.

If you move around, you’re probably locking yourself out of your own website console unless you’re at the IP address we’ll use in the example below. The example below is specific to Apache web servers, but the same principle can be applied to other configurations.

Let’s say my IP address is “aa.bb.cc.dd”. Put the following in the virtual host configuration for your WordPress website, and you can now only log in to your website from that IP address. Your site is still completely visible to the internet, but even if someone has your username and password, that login will be denied – they won’t even get to the login page.

<Location />
  ErrorDocument 403 /idontthinksotim
</Location>
<Files wp-login.php>
  order deny,allow
  deny from all
  allow from aa.bb.cc.dd
</Files>

The “order deny,allow” command tells Apache that it should follow any “deny” command access to “wp-login.php” first. The “deny from all” command is the only example of that that we need here. The “allow from aa.bb.cc.dd” command allows only the specifically listed IP address to get to “wp-login.php”.

You can of course add multiple “allow from” commands, and if you understand CIDR notation, you can use that to specify ranges of IP addresses you might want to allow with a single entry.

The above code means every other IP address on the internet is denied access to “wp-login.php”, and causes a “403” error to be thrown. To make things nice and neat and pretty, I have redirected “403” errors to a URL that does not exist – so that visitors are greeted with a proper “404” page from your WordPress site, rather than the standard “403” Apache error screen.

One final note – there are lots of other ways for bad actors to compromise a website – this is just another potential tool in your bag of tricks to keep them out. Don’t assume your website is 100% protected if you do this. Hackers are clever people, and if they are determined they will find a way.

Extracting Wordfence Attacker Data

Wordfence is a web application firewall (WAF) designed for WordPress – (the most common website platform on the internet today) – which I have used and trusted for a long time.

Wordfence helps identify attacks and vulnerabilities on your WordPress sites and takes appropriate action to mitigate what it finds. It comes in two forms – a paid and unpaid version, where the paid version gives more rapid updates to its core list of security vulnerabilities and known bad actors, as well as direct support if your attacker does manage to breach your site.

https://www.flickr.com/photos/140988606@N08/36883845116

I highly recommend it – it can be a little tricky to configure but is well worth the effort.

When it finds something hitting your site, the most common thing it does is to block the IP address of the attacker, carte blanche.

As I operate a series of WordPress sites – (in both my professional and personal spheres) – it would be nice to be able to extract the list of all of the IP addresses Wordfence has blocked, to ingest that list into other security systems you might have so they can be reused to implement security policy on other internet facing assets.

I do exactly this to block these identified bad IP addresses for accessing any service on any server I am responsible for.

On the surface, the data Wordfence stores in your WordPress database isn’t easily identifiable as an IP address, so it needs to be translated, for which I use the following SQL:

SELECT `raw_data`.`ipaddress` AS `ipaddress`,`raw_data`.`count` AS `count` FROM (SELECT REPLACE(CONVERT(INET6_NTOA(`wordpress_database_name`.`wp_wfBlockedIPLog`.`IP`) USING utf8mb4),'::ffff:','') AS `ipaddress`,SUM(`wordpress_database_name`.`wp_wfBlockedIPLog`.`blockCount`) AS `count` FROM `wordpress_database_name`.`wp_wfBlockedIPLog` GROUP BY `ipaddress`) `raw_data` ORDER BY `raw_data`.`ipaddress`;

When using the above, change ‘wordpress_database_name’ to the actual name of the database your WordPress installation is using.

Also, I’ve noticed that sometimes the Wordfence table ‘wp_wfBlockedIPLog’ can have different capitalisation, and can appear to be ‘wp_wfblockediplog’ – just look for the table, and change the SQL above to suit the name as it stands in your database.

This query spits out a list of blocked IP addresses, and a count of how many times it has been blocked. I usually create a database view using this query so that it is queriable in the same way as a table, making it much easier to work with.

Once you have that, you can use the data to spread the knowledge of bad IP addresses across your infrastructure, and use the intelligence Wordfence provides to help secure that infrastructure.

First Use Of Mastodon API

With the growing exodus of people to various Mastodon instances in the wake of the purchase of Twitter by serial boofhead Elon Musk, people will be starting to look for integration tools to start exploiting the Mastodon API.

My first dabble into it has been to update my personal management console to post my links to my chosen Mastodon instance, as well as Twitter for the time being at least.

Here’s a simple PHP function I wrote this morning that allows for simple toots to be posted via scripts or other tools you might like to do this for.

function mastodon_toot ($user_token,$mastodon_url,$toot_content)
{
  $post_data = Array("status"=>"".@str_replace("\'","▒~V~R~V~R~@~Y",$toot_content)."","language"=>"eng","visibility"=>"public");
  $post_headers = ['Authorization: Bearer '.$user_token.''];
  $curl_handle = @curl_init();
  curl_setopt($curl_handle,CURLOPT_URL,"https://".$mastodon_url."/api/v1/statuses");
  curl_setopt($curl_handle,CURLOPT_POST,1);
  curl_setopt($curl_handle,CURLOPT_POSTFIELDS,$post_data);
  curl_setopt($curl_handle,CURLOPT_RETURNTRANSFER,TRUE);
  curl_setopt($curl_handle,CURLOPT_HTTPHEADER,$post_headers);
  $curl_execute = @json_decode(@curl_exec($curl_handle));
  $http_code = "".@curl_getinfo($curl_handle,CURLINFO_HTTP_CODE);
  @curl_close($curl_handle);
  if ($http_code <> "200") {
    $result_code = "HTP";
    $result_message = "cURL returned HTTP code '".$http_code."' when attempting post to the '".$mastodon_url."' instance!";
    $result_data = NULL;
  } else {
    $result_code = "AOK";
    $result_message = "The flilm is okie dokie!";
    $result_data = Array("api_response"=>$curl_execute,"toot_id"=>"".$curl_execute->id."");
  }
  return(Array("result_code"=>$result_code,"result_message"=>$result_message,"result_data"=>$result_data));
}

There are three required parameters – $user_token, $mastodon_url, and $toot_content.

To make use of the function, create an application within your Mastodon account. In most cases you’ll find this in “Preferences -> Development“, then click “New Application“.

Fill in the fields – just use the URL of your website for the requested URLs. We won’t be doing any two-way communication between your application and Mastodon, so they won’t matter in this instance. Save your new application.

Take note of the “Your access token” value – this is what needs to be passed to the function as $user_token. Remember, you must keep this token secret and secure – as anyone with the token will be able to post to Mastodon as if it were you.

The value of $mastodon_url should just be the name of your Mastodon instance – eg: for me, it is “aus.social”.

The value of $toot_content is exactly what it sounds like – whatever you want to post to Mastodon via your script.

The first line in the function does a little cleanup on the content, just to remove crappy characters – I’m not 100% sure this is needed for Mastodon, but I carried this over from my corresponding Twitter tweet function, and it seems to not cause any issues here – so it’s there!

And that’s it. The function returns an array with some diagnostic information – (did it/didn’t it work?) – and the ID of the toot posted, if it was successful.

While the above function is written in PHP, given I’ve chosen to use PHP’s cURL library, if your chosen scripting language is not PHP it should be pretty straightforward to use the cURL library for whichever language you prefer, to come up with an equivalent script.

Over time I’ll delve more into what the Mastodon API can achieve, but in the meantime, happy automated tooting!

Inform Active Directory Users of Password Expiration

Sometimes your Active Directory users will appreciate receiving notifications of when their passwords are about to expire. Having your CEO call you on your day off to let you know that they can’t login because their password has expired is never fun.

Creative Commons [by-nc-nd]

Here is a simple Powershell script that you can use to easily send out emails to users with impending password expiration.

All you will need to modify is the “-From” address and the “-SmtpServer” address on line 13 to suit your environment, and potentially the “5” on line 28 to adjust the number of days until expiry that will trigger the email to be sent.

Import-Module ActiveDirectory

function Get-PasswordExpirationDays ($User)
{
    (([datetime]::FromFileTime((Get-ADUser –Identity $User -Properties "msDS-UserPasswordExpiryTimeComputed")."msDS-UserPasswordExpiryTimeComputed"))-(Get-Date)).Days
}

function Send-ExpirationEmail ($expDays,$expEmail,$expFirst,$expLast)
{
    if ([int]$expDays -eq 1) { $expNoun = "day" } else { $expNoun = "days" }
    $expSubject = "Your computer password will expire in $expDays $expNoun"
    $expBody = "Our records indicate that your computer password is due to expire in $expDays $expNoun`r`n"
    Send-MailMessage -From [email protected] -To $expEmail -Subject $expSubject -SmtpServer "smtp.example.com" -Body $expBody
}

$Users = Get-AdUser -filter { passwordNeverExpires -eq $false -and enabled -eq $true }

ForEach ($User in $Users)
{

    $CurrentUser = "" + $User.SamAccountName + ""
    $CurrentEmail = "" + $User.UserPrincipalName + ""
    $CurrentFirst = "" + $User.GivenName + ""
    $CurrentLast = "" + $User.Surname + ""
    
    if ($CurrentEmail -ne "") {
        $CurrentExpiration = Get-PasswordExpirationDays $CurrentUser
        if ([int]$CurrentExpiration -ge 0 -and [int]$CurrentExpiration -le 5 -and $CurrentEmail -notlike "*local*") {
            Write-Host "$CurrentExpiration - $CurrentEmail"
            Send-ExpirationEmail $CurrentExpiration $CurrentEmail $CurrentFirst $CurrentLast
        }
    }

}

Run the script via Task Scheduler once a day, and you’re all set.

However, any decision you might make towards implementing such a mechanism needs to be considered as part of your broader security policy. It may not be appropriate for your organisation, and I make no warranties towards your use of the above code.

Nevertheless, this is a quick and easy way to achieve the goal, if it is right for you.

The Little Server That Could

Eight months ago, we had a catastrophic sequence of failures that – (as one of several consequences) – brought down our web infrastructure at the place of employment.

Due to the nature of some of those consequences, we weren’t able to immediately bring that web infrastructure back up on any of our tier 1 equipment.

So I had to compromise.

Running around the office, I scrounged the most powerful PC I could find, and as much memory as possible to run up a temporary hypervisor – (Xen) – then copy the virtual hard disks off our storage array, fire all four virtual machines up on this temporary hardware, and finally get the suite of corporate websites back up and running.

I also didn’t have a switch to connect it all back up to the fibre link serving our websites. You’ll notice in the picture of this Frankenstein below, a Telstra VDSL NBN business modem acting as the switch. It was just laying around!

Yes – one of these!

Given the fibre link is 100Mbps, and the “switch” is a 1000Mbps “switch”, this shouldn’t have been a problem, but I was worried about the switching backplane of this little fellow. Was it going to be able to cope? I had my doubts.

And all of this was supposed to be temporary.

Temporary.

Right.

Got it.

Obviously, it didn’t work out that way, and it was eight months later that I was finally able to shut this “temporary” server down, having finally migrated everything back out onto proper hardware and networking again.

For eight months, this conglomeration hosted 28 corporate websites and served millions and millions and millions of web requests. I can’t tell you how many – if I’d known it was going to hang around for 8 months, I would have made provisions to log and find out exactly how many!

What I’m most proud of when it comes to this beast, is that nobody knew it was like that. The performance of the websites dropped only marginally – (almost negligibly) – and this thing just kept on trucking.

Day after day. Night after night. Week after week.

I lived in constant fear of arriving at the office each day and finding the little Telstra router melted into oblivion, or the PC itself having died.

But it never did, not once.

It was the little server that could, and I’m going to miss it!

The moral of the story is that even in the midst of massive IT disasters, there’s always a way – and that sometimes, the basics can get you by!

And above all, don’t panic!

Headspace: I Found Me

Since my late teens, I’ve gone through a few battles with my mental health. I’ve always found my way through, sometimes with professional help, and sometimes without.

I’m blessed with a group of friends who have supported me along the way, but I’ve never been able to really explain to them exactly how things are in my head.

Recently I’ve found the music and thoughts of Forest Blakk, through his beautiful “If You Love Her” – and as part of this I found this spoken word piece called “Find Me”.

And finally, I found myself. This is so close to how I feel that it’s astonishing. It’s how my head works, and it’s powerful. I could have written these words myself.

So if you’re one of my crew, I can finally explain: