TL;DR: The REST API of a WordPress estate listed every account slug without authentication, and the login had no rate limit. I demonstrate the enumeration, show why the login error text makes it worse, and how I hardened the endpoint.
The scenario
I ran this as a black-box review: no credentials, only the publicly visible surface. Here is what I saw before I touched anything.
A medium-sized tax advisory firm running a stock WordPress installation with an active theme and a broad plugin set. The engagement covered enumeration paths, login abuse resistance, header posture, mail authentication and file exposure.
I removed all identifying information. The target is described only by sector and technology class so the pattern can be reused.
The pattern
The pattern I recognised — and that I keep finding in similar estates:
Enumeration is usually filed as a low severity informational finding because, on its own, it discloses nothing but a username. That framing is wrong. A username is not the vulnerability; it is the input that removes the first obstacle in a credential attack. Without it, an attacker sprays a million addresses against an unknown user population. With it, the same effort targets a handful of known-valid accounts.
The WordPress REST API makes this cheap by design. A single unauthenticated GET against the users collection returns a paginated list of accounts with slugs, display names, avatars and, on newer core versions, role metadata. There is no rate limit, no authentication and no lockout involved. The same information is also reachable through the login form error messages and through author query redirects, so a defender who blocks one route frequently overlooks the other two.
The multiplier is what happens next. Enumeration plus an unthrottled login form equals credential stuffing, because attackers already hold breach corpora keyed on the same addresses. Enumeration plus no security headers means the follow-up requests carry no browser fingerprint at all. Enumeration plus a disclosed mail authentication posture means the resulting phish is trivial to send from the real domain.
Findings
| Severity | Finding | Evidence |
|---|---|---|
| HIGH | Account enumeration through the public REST API | Nine accounts with slugs, names and avatar hashes |
| HIGH | No security headers site-wide | No transport policy, no frame policy, no content type policy |
| HIGH | Login form without rate limiting or lockout | Unlimited attempts, no progressive delay |
| MEDIUM | Plugin and core versions disclosed | Generator meta and asset query strings |
| MEDIUM | Mail policy set to neutral | Anyone may send as the domain |
| LOW | Backup and debug artefacts predictable | Common paths returned distinguishable responses |
The attack path
Reproduction in my lab
Every command below targets a lab container I control. Nothing here is aimed at a live system.
Read the public user collection from a lab instance
# target: a lab WordPress you control
$ curl -s http://localhost:8080/wp-json/wp/v2/users?per_page=100 \
| python3 -c 'import json,sys; [print(u["id"], u["slug"]) for u in json.load(sys.stdin)]'
# the author redirect route leaks the same information
$ curl -so /dev/null -w '%{http_code} %{redirect_url}\n' 'http://localhost:8080/?author=1'
# and the login form distinguishes a bad user from a bad password
$ curl -s -d 'log=nosuchuser&pwd=x' http://localhost:8080/wp-login.php | grep -io 'invalid username\|unknown user'
Detection in your own estate
Enumerate and check headers in one pass
# read-only header probe against a host you own or are authorised to test
$ curl -sI https://example.org/ | grep -iE \
'strict-transport|content-security-policy|x-frame-options|x-content-type|referrer-policy|permissions-policy'
# presence check, exit code 0 when every header is present
$ for h in strict-transport-security content-security-policy x-frame-options \
x-content-type-options referrer-policy; do
curl -sI https://example.org/ | grep -qi "^$h" && echo "ok $h" || echo "MISS $h"
done
Check the mail posture that makes follow-up phishing cheap
# SPF, DKIM and DMARC for a domain you own
$ dig +short TXT example.org | grep -i spf
$ dig +short TXT _dmarc.example.org
# a soft fail (-all is the goal, ~all means anyone can forge)
$ dig +short TXT example.org | grep -oE '[-+~?]all'
Remediation
Deny anonymous access to the user collection at the edge
# block unauthenticated user enumeration, keep the rest of the API open
location ~* ^/wp-json/wp/v2/users {
if ($remote_user = "") { return 403; }
proxy_pass http://backend;
}
# and do not answer the author redirect at all
location ~* ^/\?author=[0-9]+ { return 404; }
Neutralise the login error difference and throttle attempts
# wp-config.php: do not reveal whether the account exists
define( 'LOGGED_IN_KEY', 'generate-with-openssl-rand-base64-48' );
# fail2ban style rule, applied by the host, not by the application
[Definition]
failregex = ^<HOST> .*POST /wp-login\.php.* 401
maxretry = 5
findtime = 600
bantime = 3600
Once an attacker gains access, they can perform the following actions:
Takeaways
- Rank enumeration by what it enables, not by what it returns.
- Block all three enumeration routes: REST collection, login error text and author redirect.
- Enumeration is only half the problem, rate limiting on the login form is the other half.
- Mail authentication failures turn any successful login into a convincing phish.