Defeating another hacking attempt.

Yesterday evening, I received a suspicious email.

Someone has requested a password reset for the following account:

Site Name: The Ploopy Blog

Username: colin

If this was a mistake, ignore this email and nothing will happen.

This password reset request originated from the IP address 36.72.217.165.

I actually received an email for each username on the website.

I didn’t request a password reset. And my IP is not 37.72.217.165. That’s an IP that’s currently assigned to an Internet provider in Indonesia. That’s quite a distance from where I currently am.

I’ve got quite a bit of security set up on my box, so I’m not particularly worried about anything happening. However, there’s one thing that’s a bit worrying: how did my username leak out? Anybody who’s looking at the site can see my username, but what about the other ones? They’re not visible, anywhere.

So, how did they get out?

The first place to look when doing this sort of forensic work is to get onto the server and check the log files. In my case, those are located in /var/log/apache2. The access.log files contain a log of every request that hits the web server (Apache).

Take a look at the following:

root@blog.ploopy.co:/var/log/apache2# cat access.log | grep 36.72.217.165
36.72.217.165 - - [23/Nov/2021:18:20:06 -5000] "GET /wp-json/wp/v2/users HTTP/1.1" 200
36.72.217.165 - - [23/Nov/2021:18:20:08 -5000] "GET /?author=1 HTTP/1.1" 200
36.72.217.165 - - [23/Nov/2021:18:20:09 -5000] "POST /wp-login.php?action=lostpassword HTTP/1.1" 302
36.72.217.165 - - [23/Nov/2021:18:20:10 -5000] "GET /wp-login.php?checkemail=confirm HTTP/1.1" 200

I cat the access log to print out the entire thing, and then I pipe it into a grep, and look for the string “36.72.217.165”. This command will show me every line in the access.log file containing the offending IP address.

And, what do you know? It shows up. What’s that, there? “GET /wp-json/wp/v2/users“. What’s that?

WordPress has a pretty great JSON API. That means it will spit out information about the WordPress installation which can be easily processed by things like plugins. Great, right?

Well, that ability to be digested easily by plugins means that it can also be easily digested by automated programs that crawl the Internet, looking for weak boxes. In the industry, we call these programs crawlers. They’re not always well-behaved, like this one.

What happened was this particular crawler was lurking about the Internet, trying to find WordPress instances, and then it found mine. It then tried its luck by doing a GET request (the most common regular HTTP request that occurs whenever you type an address into your Internet browser) on the JSON API of my WordPress blog. Specifically, it was looking for all of the users on the server.

And the server just gave them up. Just like that. And that was enough for the crawler to try to attack the website. It didn’t work, but it’s still a potential weakpoint.

So, now the problem is clear: the JSON API is leaking information. The good news, though, is that I don’t use any plugins or anything like that on this blog that uses the JSON API. Disabling it is a pretty simple matter, which can be done easily by dropping the following code into WordPress’ functions.php file:

add_filter( 'rest_authentication_errors', 'disable_rest_api' );

function disable_rest_api( $access ) {
    return new WP_Error(
        'rest_disabled',
        __('The WordPress REST API has been disabled.'),
        array( 'status' => rest_authorization_required_code())
    );
}

Perfect! This disables the entire JSON API, returning an error code. The crawler is defeated!

Right?

Well, yes, it is. That crawler can no longer get access to the usernames on this website. So, that’s a win. But, there’s a downside. A major downside.

WordPress stops working. Whoops.

The WordPress engine heavily utilises the JSON API to function. In fact, it is the official position of the WordPress developers that disabling the API is a no-no.

This is definitely the right track, but disabling the JSON API wholesale isn’t the answer. It’s going to require a bit of finesse. Thankfully, WordPress itself has the answer: a snippet of code that requires all JSON API requests to be authenticated.

Pretty simple, really: if you’re logged in to the website, you can access the JSON API. If you’re not logged in, then you can’t. Here’s the code:

add_filter( 'rest_authentication_errors', function( $result ) {
    // If a previous authentication check was applied,
    // pass that result along without modification.
    if ( true === $result || is_wp_error( $result ) ) {
        return $result;
    }
 
    // No authentication has been performed yet.
    // Return an error if user is not logged in.
    if ( ! is_user_logged_in() ) {
        return new WP_Error(
            'rest_not_logged_in',
            __( 'You are not currently logged in.' ),
            array( 'status' => 401 )
        );
    }
 
    // Our custom authentication check should have no effect
    // on logged-in requests
    return $result;
});

And that’s it. Once that’s in WordPress’ functions.php file, it starts behaving well. This time, the crawler is defeated without breaking the whole website.

This begs an unfortunate question: why the hell isn’t this the default behaviour? Security and utility are usually diametrically opposed when it comes to software development, but in this case, I think the emphasis on utility went a bit too far.

Oh, well. Stick it in your WordPress instance and move on.