Enumerating Users on WordPress

2020-09-05

WordPress is an extremely popular Content Management System (CMS) and as a result receives a lot of interest from hackers. WordPress has a bad reputation in some circles for being insecure, however if you are selective about the themes / plugins you install and keep it up to date, it is my belief that it is a nice system for both developers and users.

However, assuming you keep everything up to date, in many cases the biggest security weakness is your credentials. If a malicious actor guesses your username and password, it doesn’t matter how recently you did your updates, they are probably going to get in.

When I am tasked with testing the security of a WordPress site, one of the first things I do is attempt to find usernames. In this blog post, I document some of the ways I do that.

For the purposes of this blog post, I have created a local WordPress site I can use for testing. Do not attempt these tactics unless you own the site you are testing or have explicit permission from the site owner to do so.

Trial and error

Login form showing valid user

The most simple way is to attempt to login in with common usernames, admin being one of the most common. You can see from the screenshots above that if you enter a correct username, the error message tells you that you have the password wrong; if you enter an incorrect username, the message tells you that there is an unknown username.

This makes it trivial to tell if a username exists: try it and if you get the “incorrect password” error, you know you have a valid username.

To fix this, you simply need to make wordpress return generic error messages:

<?php
function no_wordpress_errors(){
  return 'Something is wrong!';
}
add_filter( 'login_errors', 'no_wordpress_errors' );

User ID Cycling

Wordpress dynamically assigns users with IDs and creates pages for each user. Normally these can be accessed by going to a url like <domain>/author/admin/. However, you can also access them by manually specifying the users ID. For example <domain>/?author=1 will redirect the visitor to <domain>/author/admin/, this gives the attacker an easy way to get usernames.

for i in {1..5}; do
    curl -s -o /dev/null -w "%{redirect_url}\n" "example-wordpress.local/?author=$i"
done

In a default WordPress installation, you will get something like this:

http://example-wordpress.local/author/admin/
http://example-wordpress.local/author/user1/
http://example-wordpress.local/author/user2/
http://example-wordpress.local/author/user3/

We have just found 3 new usernames.

One solution to this is to simply prevent WordPress queries from being able to look up a user by ID.

<?php
function do_404_author_query($query_vars) {
    if ( !empty($query_vars['author'])) {
        global $wp_query;
        $wp_query->set_404();
        status_header(404);
        nocache_headers();
        
        $template = get_404_template();
        if ($template && file_exists($template)) {
            include($template);
        }
        exit;
    }
    return $query_vars;
}
add_action('request', 'do_404_author_query');

Rest API

Out of the box, WordPress gives out a lot of information out about its users through the Rest API.

I can pull out a list of all usernames with the following:

curl -s example-wordpress.local/wp-json/wp/v2/users | jq '.[].name'

The easiest way to mitigate this is simply to remove the user endpoints.

function remove_users_endpoints( $endpoints ) {
    return array_filter( $endpoints, function($endpoint){
        return (0 === preg_match( '/^\/wp\/v2\/users/', $endpoint ));
    } , ARRAY_FILTER_USE_KEY);
}
add_filter( 'rest_endpoints', 'remove_users_endpoints' );

oEmbed

Oembed is a protocol that allows websites to embed content from other sites. WordPress supports both embedding and being embedded. When a site requests to embed a page, it makes a request that looks like the following: <domain>/wp-json/oembed/1.0/embed?url=http%3A%2F%2F<domain>/a-post-by-user-3/.

The WordPress server then returns something like this:

{
  "version": "1.0",
  "provider_name": "Example Wordpress",
  "provider_url": "http://example-wordpress.local",
  "author_name": "user3",
  "author_url": "http://example-wordpress.local/author/user3/",
  "title": "a post by user 3",
  "type": "rich",
  "width": 600,
  "height": 338,
  "html": "<embed code>"
}

This includes the author’s name and a url for the author archive. In order to prevent wordpress from including this information in the oembed response, add the following to a plugin or to your themes functions.php file:

<?php
function remove_author_from_oembed($data) {
    unset($data['author_url']);
    unset($data['author_name']);
    return $data;
}
add_filter( 'oembed_response_data', 'remove_author_from_oembed' );

In-Page Info

Perhaps the least interesting is simply looking at pages. Many pages, particularly news or blog pages, include the author. This is often linked to the author archive page which will disclose their username. It is normally possible to use simple tools like grep or hq to get the usernames from these pages.

For example, to get the author of the page http://example-wordpress.local/a-post-by-user-2/, I could do the following:

$ curl http://example-wordpress.local/a-post-by-user-2/ | hq '.post-author a' attr href'
http://example-wordpress.local/author/user2/

Here we see the author is user2.

However, sites often have hundreds or thousands of pages so doing this manually would be tedious. Once again, we can turn to the rest api.

The following will go through the first 100 posts on the site and attempt to get the author’s link from it.

$ curl http://example-wordpress.local/wp-json/wp/v2/posts?per_page=100 | jq ‘.[].id’ | while read i; do
    curl -L …/?p=$i | hq '.post-author a' attr href
done

It is worth noting here that the wordpress rest api limits requests to 100 results per request. This means that if a site has more posts / pages than that, you might need to use the page=n parameter which will give you the nth page of results.

I don’t normally find this necessary since it is normally the earliest pages that are created by high privileged accounts.

The easiest way to rectify this is simply not to include the author’s details in the page, although instructions on how to do this will vary depending on the theme in use. However, you may wish to be able to group blog posts by author. In this case, I would suggest only publishing content using low privileged accounts (author or contributor). This will mean that in the event that one of these accounts is compromised, the damage an attacker can do is limited.


These steps should prevent most attempts at user enumeration although remember that security is not a plugin or a few lines of code copied from a blog on the internet. There are many layers that should be implemented and this is but one. For details on how best to secure your website, you should consult with an expert.