Blog

xmlrpc.php: the hole that’s open on 80% of WordPress sites right now.

xmlrpc
Wordpress / xmlrpc

xmlrpc.php: the hole that’s open on 80% of WordPress sites right now.

Sunday, 2 AM. Your WordPress store server starts crawling. PHP-FPM is maxing out its worker pool, CPU is pegged at 100%, the site is unresponsive. You open access.log and see this:

185.220.101.42 - - [26/May/2026:02:13:44 +0300] "POST /xmlrpc.php HTTP/1.1" 200 674
185.220.101.43 - - [26/May/2026:02:13:44 +0300] "POST /xmlrpc.php HTTP/1.1" 200 674
185.220.101.44 - - [26/May/2026:02:13:44 +0300] "POST /xmlrpc.php HTTP/1.1" 200 674
185.220.101.45 - - [26/May/2026:02:13:44 +0300] "POST /xmlrpc.php HTTP/1.1" 200 674

Thousands of lines per second. Different IPs, one target. This is not a bandwidth DDoS — this is an attack on xmlrpc.php, and your server is dutifully processing every single request, burning CPU on PHP.

If you manage WordPress sites and haven’t locked down xmlrpc.php yet — keep reading.

WHAT IS xmlrpc.php AND WHY DOES IT EXIST

XML-RPC is a remote procedure call protocol over HTTP, dating back to 1998. WordPress added it to let third-party applications manage a site without a browser: publishing posts from Windows Live Writer, syncing content through mobile apps, integrating with external services.

A request through xmlrpc.php looks like this:

POST /xmlrpc.php HTTP/1.1
Host: example.com
Content-Type: text/xml

<?xml version="1.0"?>
<methodCall>
  <methodName>wp.getUsersBlogs</methodName>
  <params>
    <param><value>admin</value></param>
    <param><value>password123</value></param>
  </params>
</methodCall>

WordPress responds — and with valid credentials — returns full access to the site.

To check whether xmlrpc.php is exposed on your site:

curl -s https://example.com/xmlrpc.php

If the response is:

XML-RPC server accepts POST requests only.

— congratulations, xmlrpc.php is active and reachable from outside. That exact string is what bots use as a reconnaissance probe.

THREE WAYS TO BREAK A SITE THROUGH xmlrpc.php

1. Brute force amplification via system.multicall

Classic brute force on wp-login.php is easy to throttle — one request, one attempt. With xmlrpc.php it gets interesting: the system.multicall method lets you pack multiple calls into a single HTTP request.

Before WordPress 4.4, one request could contain thousands of authentication attempts. The patch in December 2015 made WordPress abort the batch after the first failed authentication — but the attack didn’t disappear. Bots adapted: they now send hundreds of sequential requests from different botnet IPs, bypassing IP-based rate limiting.

The payload looks like this:

<methodCall>
  <methodName>system.multicall</methodName>
  <params>
    <param><value><array><data>
      <value><struct>
        <member>
          <name>methodName</name>
          <value>wp.getUsersBlogs</value>
        </member>
        <member>
          <name>params</name>
          <value><array><data>
            <value>admin</value>
            <value>password1</value>
          </data></array></value>
        </member>
      </struct></value>
      <!-- 499 more attempts -->
    </data></array></value></param>
  </params>
</methodCall>

The result: each request is not one attempt but dozens. A PHP worker is tied up for the entire duration. Even with the WordPress patch, the server load is dramatically higher than from a wp-login.php attack.

2. DDoS amplification via pingback

This attack targets other sites — using your server as a weapon. An attacker sends your (vulnerable) WordPress a request:

<methodCall>
  <methodName>pingback.ping</methodName>
  <params>
    <param><value><string>http://VICTIM.com/</string></value></param>
    <param><value><string>https://your-site.com/any-post/</string></value></param>
  </params>
</methodCall>

Your WordPress obediently makes an HTTP request to the victim’s site. If a thousand such WordPress sites do this simultaneously — the victim gets a thousand concurrent requests. Your server becomes a participant in a DDoS attack, and your IP ends up in blocklists.

This exact mechanism was used in an attack on a network of US Department of Defense sites — through their own WordPress installations with xmlrpc.php wide open.

3. Internal network port scanning (SSRF via pingback)

Pingback accepts arbitrary URLs, including internal addresses. An attacker can map your internal infrastructure through your server:

<!-- probing internal services -->
<value><string>http://192.168.1.1:8080/</string></value>
<value><string>http://10.0.0.1:3306/</string></value>

By measuring response timing and error codes, an attacker can determine which ports are open inside your network.

HOW TO CLOSE IT: THREE LAYERS OF PROTECTION

Layer 1: nginx — block at the web server level

This is the fastest and most effective approach. The request never reaches PHP at all.

If xmlrpc.php is not needed at all (most cases):

# Inside the server{} block of your nginx config
location = /xmlrpc.php {
    deny all;
    access_log off;
    log_not_found off;
}

If access is needed from specific IPs only (e.g., Jetpack or WooCommerce mobile app):

location = /xmlrpc.php {
    allow 198.51.100.10;    # your mobile app IP
    allow 198.51.100.11;    # Jetpack servers IP
    deny all;
    access_log off;
}

After making changes — test the config and reload:

nginx -t && systemctl reload nginx

Verify the block is working:

curl -I https://example.com/xmlrpc.php
# Expected response: HTTP/1.1 403 Forbidden

Layer 2: fail2ban — ban those who keep trying anyway

Even with nginx blocking — bots will keep knocking, receive 403s and move on. But they’ll keep knocking constantly, polluting logs. fail2ban adds them to a blocklist at the nftables level.

Create a filter:

cat > /etc/fail2ban/filter.d/nginx-xmlrpc.conf << 'EOF'
[Definition]
failregex = ^<HOST> - - \[.*\] "(GET|POST) /xmlrpc\.php HTTP/.*" (200|301|302|403|404) \d+ ".*" ".*"
ignoreregex =
EOF

Verify that fail2ban is using nftables as the backend (modern systems default to this, but worth confirming):

grep -r "banaction" /etc/fail2ban/jail.conf /etc/fail2ban/jail.local 2>/dev/null | grep -v "^#"
# Should show: banaction = nftables-multiport
# If it shows iptables — add to /etc/fail2ban/jail.local:
# [DEFAULT]
# banaction = nftables-multiport

Create a jail:

cat > /etc/fail2ban/jail.d/nginx-xmlrpc.conf << 'EOF'

[nginx-xmlrpc]

enabled = true port = http,https filter = nginx-xmlrpc logpath = /var/log/nginx/access.log maxretry = 3 findtime = 60 bantime = 86400 action = nftables-multiport[name=xmlrpc, port=”80,443″, protocol=tcp] EOF

Restart fail2ban:

systemctl restart fail2ban
fail2ban-client status nginx-xmlrpc

Layer 3: Cloudflare WAF (if the site is behind Cloudflare)

If your site runs through Cloudflare, configure a Rate Limiting Rule in the dashboard:

Security → WAF → Rate limiting rules → Create rule:

Field: URI Path
Operator: contains
Value: /xmlrpc.php

Action: Block
Duration: 1 hour
Threshold: 5 requests per 30 seconds per IP

On Pro and above, the managed WAF rule WP0018 is available — it automatically detects system.multicall patterns.

Important caveat: if the site is behind Cloudflare, your nginx access.log shows Cloudflare IPs, not the real attacker’s IP. Make sure real_ip is configured in nginx:

# In nginx.conf, http{} block
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22;
set_real_ip_from 103.31.4.0/22;
set_real_ip_from 104.16.0.0/13;
set_real_ip_from 104.24.0.0/14;
set_real_ip_from 108.162.192.0/18;
set_real_ip_from 131.0.72.0/22;
set_real_ip_from 141.101.64.0/18;
set_real_ip_from 162.158.0.0/15;
set_real_ip_from 172.64.0.0/13;
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 188.114.96.0/20;
set_real_ip_from 190.93.240.0/20;
set_real_ip_from 197.234.240.0/22;
set_real_ip_from 198.41.128.0/17;
set_real_ip_from 2400:cb00::/32;
set_real_ip_from 2606:4700::/32;
set_real_ip_from 2803:f800::/32;
set_real_ip_from 2405:b500::/32;
set_real_ip_from 2405:8100::/32;
set_real_ip_from 2a06:98c0::/29;
set_real_ip_from 2c0f:f248::/32;
real_ip_header CF-Connecting-IP;

IF xmlrpc.php IS ACTUALLY NEEDED (Jetpack, WooCommerce Mobile)

Jetpack still uses XML-RPC to communicate with WordPress.com. Blocking it completely breaks Jetpack. The right approach:

  1. Fetch the current Jetpack IP ranges and allow only those. The IPs change — Jetpack’s official documentation explicitly warns about this and recommends automating updates. The current list is always available in machine-readable format:
# View current Jetpack IP ranges
curl -s https://jetpack.com/ips-v4.json | python3 -m json.tool

# Example config based on current list (jetpack.com/support/how-to-add-jetpack-ips-allowlist)
location = /xmlrpc.php {
    # Current Jetpack IP ranges — verify at jetpack.com/ips-v4.json
    allow 122.248.245.244/32;
    allow 54.217.201.243/32;
    allow 54.232.116.4/32;
    allow 192.0.64.0/18;
    allow 192.0.80.0/20;
    allow 192.0.96.0/20;
    allow 192.0.112.0/20;
    allow 195.234.108.0/22;
    deny all;
}

Important: this list changes. Don’t hardcode the IPs once and forget — automate updates via a systemd timer or cron job.

  1. Add rate limiting even for allowed IPs:
limit_req_zone $binary_remote_addr zone=xmlrpc:10m rate=5r/m;

location = /xmlrpc.php {
    limit_req zone=xmlrpc burst=3 nodelay;
    allow 192.0.64.0/18;
    # ... remaining Jetpack ranges
    deny all;
    fastcgi_pass unix:/run/php/php8.3-fpm.sock;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}

DIAGNOSTICS: HOW TO TELL IF YOU’RE ALREADY UNDER ATTACK

Check the number of requests to xmlrpc.php in the last hour:

grep "xmlrpc.php" /var/log/nginx/access.log | \
  awk '{print $1}' | \
  sort | uniq -c | sort -rn | head -20

View the top attacking IPs:

grep "POST /xmlrpc.php" /var/log/nginx/access.log | \
  awk '{print $1}' | \
  sort | uniq -c | sort -rn | head -20

Check PHP-FPM load:

systemctl status php8.3-fpm
# Or directly:
ps aux | grep php-fpm | wc -l

If you see dozens of PHP workers with minimal legitimate traffic — xmlrpc.php is likely already hammering your server.

COMMON MISTAKES

Mistake 1: “I installed the Disable XML-RPC plugin — it’s secure now”

The plugin disables XML-RPC at the WordPress level, but the request still reaches PHP, PHP still bootstraps WordPress, loads all plugins, and only then returns an empty response. The server still burns resources. The correct approach is blocking in nginx before PHP ever runs.

Mistake 2: “I don’t have WordPress, just a PHP application”

xmlrpc.php is WordPress-specific. No WordPress — no problem. But it’s worth verifying: WordPress is sometimes installed in a subdirectory and forgotten about.

Mistake 3: “I’m blocking by IP — too many attackers, can’t keep up”

IP-based blocking doesn’t work against distributed botnets — IPs rotate constantly. The correct approach: block the endpoint entirely (location = /xmlrpc.php { deny all; }), not chase individual IPs.

Mistake 4: “fail2ban banned them, but requests keep appearing in logs”

fail2ban is reactive — the request is processed first, then the IP gets banned. If the attack is distributed (each IP makes 2 requests), fail2ban won’t keep up. The solution is a combination: nginx deny all for the endpoint + Cloudflare WAF at the top layer.

CHECK YOUR SITES RIGHT NOW

# Quick check — is xmlrpc.php exposed?
curl -s -o /dev/null -w "%{http_code}" https://your-site.com/xmlrpc.php
# 200 or "XML-RPC server accepts POST requests only" = open, needs to be blocked
# 403 = blocked by nginx
# 404 = file not found (WordPress not installed or different path)

# Check all WordPress sites on the server
for conf in /etc/nginx/sites-enabled/*; do
  domain=$(grep server_name "$conf" | head -1 | awk '{print $2}' | tr -d ';')
  code=$(curl -s -o /dev/null -w "%{http_code}" "https://$domain/xmlrpc.php" 2>/dev/null)
  echo "$domain: $code"
done

CONCLUSION

xmlrpc.php is legacy from 1998 that WordPress has been dragging along ever since. In 2026, the overwhelming majority of sites don’t need it. But it’s enabled by default, exposed to the internet, and bots know exactly where to look.

Three minutes in your nginx config closes the problem completely:

location = /xmlrpc.php {
    deny all;
    access_log off;
    log_not_found off;
}

If Jetpack is required — a whitelist instead of a full block. On top of that — fail2ban for logging and banning persistent knockers. And Cloudflare WAF if the site is behind a proxy.

Check your sites right now. Odds are at least one xmlrpc.php is sitting wide open.

Leave your thought here

Your email address will not be published. Required fields are marked *