A couple weeks ago, I covered what a WordPress brute-force attack looks like. However, you may have realized that trying an unlimited number of passwords is futile if you don’t know any valid usernames to guess passwords for. Fortunately for crackers, there’s a simple way to abuse the WordPress “pretty permalinks” feature to obtain valid usernames for a WordPress installation. Fortunately for us, there’s a simple way to block this with Nginx.
The Logs
Like a brute-force attack, a user enumeration attempt is usually pretty easy to spot. The logs usually start out like this:
203.0.113.42 - - [23/Jun/2016:17:04:11 -0700] "GET /?author=1 HTTP/1.1" 302 154 "-" "-"
And then continue like this…
203.0.113.42 - - [23/Jun/2016:17:04:12 -0700] "GET /?author=2 HTTP/1.1" 302 154 "-" "-" 203.0.113.42 - - [23/Jun/2016:17:04:13 -0700] "GET /?author=3 HTTP/1.1" 302 154 "-" "-" 203.0.113.42 - - [23/Jun/2016:17:04:15 -0700] "GET /?author=4 HTTP/1.1" 302 154 "-" "-" 203.0.113.42 - - [23/Jun/2016:17:04:16 -0700] "GET /?author=5 HTTP/1.1" 302 154 "-" "-"
…until the cracker gives up.
203.0.113.42 - - [23/Jun/2016:17:04:18 -0700] "GET /?author=6 HTTP/1.1" 302 154 "-" "-" 203.0.113.42 - - [23/Jun/2016:17:04:19 -0700] "GET /?author=7 HTTP/1.1" 302 154 "-" "-" 203.0.113.42 - - [23/Jun/2016:17:04:20 -0700] "GET /?author=8 HTTP/1.1" 302 154 "-" "-" 203.0.113.42 - - [23/Jun/2016:17:04:22 -0700] "GET /?author=9 HTTP/1.1" 302 154 "-" "-" 203.0.113.42 - - [23/Jun/2016:17:04:23 -0700] "GET /?author=10 HTTP/1.1" 302 154 "-" "-" 203.0.113.42 - - [23/Jun/2016:17:04:25 -0700] "GET /?author=11 HTTP/1.1" 302 154 "-" "-" 203.0.113.42 - - [23/Jun/2016:17:04:26 -0700] "GET /?author=12 HTTP/1.1" 302 154 "-" "-" 203.0.113.42 - - [23/Jun/2016:17:04:28 -0700] "GET /?author=13 HTTP/1.1" 302 154 "-" "-" 203.0.113.42 - - [23/Jun/2016:17:04:29 -0700] "GET /?author=14 HTTP/1.1" 302 154 "-" "-" 203.0.113.42 - - [23/Jun/2016:17:04:30 -0700] "GET /?author=15 HTTP/1.1" 302 154 "-" "-" 203.0.113.42 - - [23/Jun/2016:17:04:32 -0700] "GET /?author=16 HTTP/1.1" 302 154 "-" "-" 203.0.113.42 - - [23/Jun/2016:17:04:33 -0700] "GET /?author=17 HTTP/1.1" 302 154 "-" "-"
Why This Works
By default, WordPress uses query strings as permalinks, such as:
http://example.com/?p=123
This example permalink would display a post with the ID number “123”. The post ID is generated when a new post is created. Query strings make for “ugly” permalinks, however, so WordPress allows you to enable “pretty permalinks” using Apache mod_rewrite
or a custom try_files
directive in Nginx. With “pretty permalinks” enabled, WordPress performs an HTTP 301 redirect from the “ugly” permalink to the “pretty permalink” configured on the Settings>Permalinks screen.
WordPress doesn’t just have IDs for posts, though. Every WordPress user, or author, has a unique ID that maps to their archive page, which is a list of all the posts that they have created. “Ugly” author permalinks look like:
http://example.com/?author=1
When pretty permalinks are enabled, the author archive page looks like:
http://example.com/author/username
This reveals the author’s WordPress username. A simple script can easily enumerate all the usernames on a WordPress site by trying ?author=
with sequential numbers, as we saw in the log excerpts above.
Mitigating WordPress User Enumeration Attempts
We can block user enumeration on two levels: by redirecting the “ugly” permalinks, or by redirecting the /author/
pages entirely. Keep in mind that even if you disable the /author/
pages, your username can be discovered through other methods, and you should assume it is publicly available knowledge. However, we can make it difficult for the public to obtain that knowledge.
Disable query-string based user enumeration
A simple if
statement works to disable user enumeration using query strings (or “ugly” permalinks). This is a “safe” if
statement in Nginx (see the infamous If Is Evil page) since we are using it with a return
statement.
if ($args ~ "^/?author=([0-9]*)") { return 302 $scheme://$server_name; }
This code uses a simple regex, or regular expression, to match any URIs that end in /?author=
plus a number. Here’s how it works:
$args
is an Nginx variable for the query string~
indicates that we want Nginx to perform a case-sensitive regex match using the regular expression inside the double quotation marks^
(the carat) indicates the beginning of the path/?author=
is the fixed part of the path([0-9]*)
is a capturing group that matches any combination of numbers between 0 and 9
The return
statement then redirects any URIs that fit the pattern.
Disable WordPress author pages entirely
We can add a simple location
block to disable the author pages entirely. This solution is a bit redundant, because you would have to already know the author’s username to access their /author/
archive page, but this is useful if you don’t want author archive pages on your blog for some reason.
Note: this solution, by itself, does not prevent user enumeration, because the intermediary step between the query string and the author archive page pretty permalink will not be hidden. In other words, the query string will redirect to the archive page, revealing the username, and then will redirect based on the code below.
location ~ ^/author/(.*)$ { return 302 $scheme://$server_name; }
~
starts a case-sensitive regex match, like above^
starts the path we want to match/author/
indicates we want paths beginning with /author/
to be matched(.*)
is a capturing group that matches any character except newlines$
marks the end of the path
If a URI is matched, it is redirected to the root server name using return
, like above.