// Security · WordPress
WordPress Brute Force & XML-RPC Attacks
WordPress sites face two main credential attack surfaces: the wp-login.php form and the xmlrpc.php API endpoint. XML-RPC is particularly dangerous because its system.multicall method lets attackers test hundreds of passwords in a single HTTP request — flying under rate-limit radars.
XML-RPC Attack Patterns
xmlrpc.php Log Signatures
# Simple xmlrpc probe — checking if endpoint exists 91.108.4.12 - - [10/Apr/2025:01:00:00 +0000] "GET /xmlrpc.php HTTP/1.1" 405 42 # 405 Method Not Allowed = xmlrpc.php exists but won't accept GET. Attacker confirmed endpoint. # system.multicall attack — one POST = hundreds of password attempts 91.108.4.12 - - [10/Apr/2025:01:00:01 +0000] "POST /xmlrpc.php HTTP/1.1" 200 97 91.108.4.12 - - [10/Apr/2025:01:00:02 +0000] "POST /xmlrpc.php HTTP/1.1" 200 97 # 200 + tiny response (97 bytes) = all attempts in multicall returned faultCode (all failed) # 200 + larger response = at least one credential pair succeeded # High-volume xmlrpc from distributed IPs (botnet) 185.220.101.45 - - [10/Apr/2025:01:00:03 +0000] "POST /xmlrpc.php HTTP/1.1" 200 97 185.107.213.1 - - [10/Apr/2025:01:00:04 +0000] "POST /xmlrpc.php HTTP/1.1" 200 97 45.83.64.200 - - [10/Apr/2025:01:00:05 +0000] "POST /xmlrpc.php HTTP/1.1" 200 97
⚠ The Multicall Trick
A single POST to xmlrpc.php using system.multicall can contain 500–1000 wp.getUsersBlogs calls, each testing a different password. To your log, this looks like one request. Your rate limiter sees one hit. The attacker tests 1000 passwords. Always block xmlrpc.php entirely if you don't use it.
wp-login.php Status Codes Decoded
| Method | Status | Meaning |
|---|---|---|
| GET /wp-login.php | 200 | Login form served. Normal. |
| POST /wp-login.php | 200 | Login failed — form re-displayed with error. Brute force in progress. |
| POST /wp-login.php | 302 | Login succeeded — redirecting to wp-admin. Investigate the source IP immediately. |
| GET /wp-login.php?action=lostpassword | 200 | Password reset form. May indicate account enumeration (testing if username exists). |
| POST /wp-login.php?action=lostpassword | 302 | Password reset submitted. Monitor for unusual timing with login attempts. |
Detection & Blocking
bash
LOG=/var/log/nginx/access.log # Count POST xmlrpc.php by IP grep "POST /xmlrpc.php" $LOG | awk '{print $1}' | sort | uniq -c | sort -rn | head -20 # Any xmlrpc.php POST that returned more than 200 bytes (possible success) awk '$7=="/xmlrpc.php" && $6=="\"POST" && $10+0 > 200' $LOG # wp-login brute force: IPs with > 10 failed POST attempts (200 response) awk '$7=="/wp-login.php" && $6=="\"POST" && $9=="200" {print $1}' $LOG | sort | uniq -c | sort -rn # Did any login succeed? (POST → 302) grep "POST /wp-login.php" $LOG | awk '$9=="302" {print $1, $4}'
Block xmlrpc.php — Nginx
nginx.conf
# Add inside server {} block location = /xmlrpc.php { deny all; access_log off; log_not_found off; return 444; } # Also rate-limit wp-login.php limit_req_zone $binary_remote_addr zone=wplogin:10m rate=3r/m; location = /wp-login.php { limit_req zone=wplogin burst=5 nodelay; include fastcgi_params; fastcgi_pass unix:/run/php/php8.1-fpm.sock; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; }
Block xmlrpc.php — Apache
.htaccess
# Block xmlrpc.php entirely <Files xmlrpc.php> Order Deny,Allow Deny from all </Files>