قراءة 14 دقيقة

xmlrpc.php abuse and the 27-site one-shot fix on cPanel

A postmortem on 5,400 xmlrpc.php requests an hour from one /24, why per-site plugin fixes fail, and the shell loop that hardened 27 WordPress sites at once.

xmlrpc.php abuse and the 27-site one-shot fix on cPanel

The first time xmlrpc.php floods one of your servers, you Google the symptom, find a guide called "how to disable xmlrpc.php in WordPress", install a plugin, click a checkbox, and move on. The second time it happens, a week later, on a different site on the same box, you start to suspect that the per-site fix is not the fix. You have 27 WordPress installs on this server. You are not going to log into 27 wp-admin dashboards and install a plugin on each one. You need a fix that lives at the server layer, that covers every WordPress install on the box, that survives a client restoring from backup, and that you can run on a Tuesday morning without disturbing anyone.

This is that fix. It is a shell loop. It took twelve minutes to write the first time we needed it, and it has run on our cPanel servers ever since. The story below is the incident that prompted it, the diagnostic flow we used to confirm xmlrpc.php was the real source of the load, the loop itself, and the honest line between what ServerGuard automates today and what it still asks a human to approve.

What xmlrpc.php is and why attackers love it

xmlrpc.php is a legacy WordPress endpoint for remote publishing and remote management. It predates the REST API. It accepts XML payloads over POST, dispatches each call to a registered method name (wp.getPosts, wp.uploadFile, the lot), and returns an XML response. In 2026 almost nothing modern uses it. The WordPress mobile apps moved to the REST API years ago. Jetpack still uses it for some of its features. Most installs could disable it tomorrow and never notice.

Attackers love it for three reasons.

The first is amplification through system.multicall. The system.multicall method, defined in the XML-RPC specification, lets a single HTTP request carry up to several hundred nested method calls. One TCP connection, one TLS handshake, one PHP-FPM worker checkout, and a couple of hundred WordPress authentication attempts inside it. Brute-forcing wp-login is loud and gets rate limited at the front door. Brute-forcing xmlrpc.php is the same attack with 200x the throughput per HTTP request, and most rate limiters do not see it because they count HTTP requests, not nested XML-RPC method invocations.

The second is pingback amplification. The pingback.ping method takes a target URL and a source URL, and instructs the WordPress site to fetch the source URL "to verify the pingback". That makes any WordPress install with xmlrpc.php enabled a reflector for DDoS traffic against a victim of the attacker's choice. The WordPress site is the unwitting cannon; the attacker points the cannon by sending it pingback.ping requests with the victim's URL as the source.

The third is stealth. xmlrpc.php traffic does not show up in WordPress's own login audit trail. It does not increment failed login counters in the way wp-login does. Many WordPress security plugins log wp-login failures but not XML-RPC authentication failures, because the plugin's hook fires on a different code path. The first you hear about it is that your PHP-FPM pool is saturated and your slow query log is full of wp_users lookups.

That third point is the operational one. By the time the symptom is visible, the attacker has been at it for hours.

How the abuse pattern looked in our incident

The case that made us build the one-shot fix was textbook. The server hosted an e-commerce WordPress site for an agency client, on a cPanel box also hosting 26 other WordPress sites for the same agency. One morning the on-call alert was the usual one: PHP-FPM pool exhausted, requests queueing, site responding in seconds rather than milliseconds.

The Apache access log for that domain told the story in two lines of awk:

# How many requests per hour, per path, on the affected domain?
awk '$7 ~ /xmlrpc\.php/ {print $4}' \
  /home/westvd/access-logs/westvalleydental.com | \
  cut -c2-15 | sort | uniq -c | sort -rn | head -20

The output was a column of 5,4xx counts against single-hour buckets. Five thousand four hundred POSTs to xmlrpc.php in an hour, from a single source /24 — an Azure-hosted range that has been pumping XML-RPC brute force traffic against WordPress installs across the internet for months.

The user agent string was the laziest possible spoof: a single Mozilla/5.0 with no version, no platform, no engine. Every request identical. If you graph requests-per-second against this domain over the previous twenty-four hours, you see a flat baseline of normal user traffic with a hard square wave on top of it that starts at one in the morning local time and runs continuously. The attacker started the attack and walked away from the keyboard.

The slow query log on the MariaDB side was the second confirmation. Every slow query in the affected hour was a wp_users lookup by user_login, exactly the query that a WordPress authentication attempt issues. Five thousand four hundred of them per hour, all on the same site's database, all returning quickly because the index is hot, but each one still booking a PHP-FPM worker for the duration of the request.

This was a brute force at scale, dressed as xmlrpc.php traffic. The fix had to do two things: stop accepting the traffic at the front door, and harden every other WordPress install on the box before the attacker noticed.

The diagnostic flow on a cPanel server

Before you fix xmlrpc.php across 27 sites, prove that xmlrpc.php is the problem. The diagnostic flow is three commands.

The first counts xmlrpc.php requests across every domain hosted on the server in the last hour. cPanel writes per-domain access logs to /home/<user>/access-logs/<domain> (and historically to /etc/apache2/logs/domlogs/):

# Count xmlrpc.php POSTs per domain in the last hour.
for log in /etc/apache2/logs/domlogs/*; do
  domain=$(basename "$log")
  count=$(awk -v d="$(date -d '1 hour ago' +'%d/%b/%Y:%H')" \
    '$4 ~ d && $7 ~ /xmlrpc\.php/ {n++} END {print n+0}' "$log")
  if [ "$count" -gt 0 ]; then
    printf '%6d  %s\n' "$count" "$domain"
  fi
done | sort -rn

If xmlrpc.php is the source of the load, one or two domains will dominate the output by an order of magnitude. In our case the top domain had a four-digit count and the rest had zeros.

The second command confirms the offending IP range. The cPanel domlog is combined format, so column one is the source IP:

# Top source IPs for xmlrpc.php in the last hour, on the worst domain.
grep 'xmlrpc\.php' /home/westvd/access-logs/westvalleydental.com | \
  awk '{print $1}' | sort | uniq -c | sort -rn | head -10

We saw one source dominating with 5377 requests at the top and a long tail of near-zero counts below it. Single attacker, single IP, single subnet.

The third command checks PHP-FPM pool utilisation and pairs it with the access-log volume to confirm causation. We use ea-php81-fpm's pool status endpoint if it is enabled; otherwise we count active children directly:

# Active PHP-FPM children per user pool, sorted by count.
ps -eo user,comm | awk '/php-fpm: pool/ {print $1}' | \
  sort | uniq -c | sort -rn | head

If the user pool serving the targeted domain is at its pm.max_children ceiling and queueing requests, and the access log shows thousands of xmlrpc.php POSTs in the last hour, you have your causal chain. Block the traffic and the pool drains within seconds.

The one-shot fix across all WordPress sites

There are three places you can block xmlrpc.php on a cPanel server, and they layer. We deploy all three.

The Apache approach, server-wide

cPanel's blessed location for per-site Apache configuration is /etc/apache2/conf.d/userdata/std/2_4/<user>/<domain>/, and a file named wp.conf in that directory is included into the site's VirtualHost. After dropping the file in, run whmapi1 apache_config_check followed by /scripts/rebuildhttpdconf and restart Apache. The directive itself is two lines:

# /etc/apache2/conf.d/userdata/std/2_4/westvd/westvalleydental.com/wp.conf
<Files xmlrpc.php>
  Require all denied
</Files>

This block returns a 403 at the Apache layer, before PHP-FPM is ever invoked. A blocked xmlrpc.php request never books a worker. The CPU cost is roughly that of serving a static 403 page. We prefer this layer when we can deploy it, because it is the cheapest enforcement point.

The reason we do not stop here is that the cPanel userdata directory is owned by cPanel. cPanel's domain-management tooling rebuilds these files when domains are added, parked, or moved. Rolling out a wp.conf per domain via this path works, but you also need a periodic reapply to recover from cPanel regenerating the directory. That is the same operational problem as the per-site .htaccess approach has with backup restores, just from a different direction.

The .htaccess approach, per-site

WordPress writes its own .htaccess for permalink rewrites. We append an xmlrpc.php block to that file, in every WordPress install on the box, in one pass. The append is idempotent: if the block is already present, skip; otherwise, append.

The directive itself is short and self-documenting:

# Block xmlrpc.php (added by sguard-hardening)
<Files xmlrpc.php>
  Require all denied
</Files>

The advantage of .htaccess over the server-wide Apache layer is that .htaccess lives in the site's document root, alongside wp-config.php. It is part of the site's content. When clients back up their site, they back up .htaccess with it. When clients restore from backup, .htaccess comes back. When clients install a plugin that rewrites .htaccess, most of them preserve non-WordPress directives outside the # BEGIN WordPress / # END WordPress markers.

The disadvantage is the exact opposite of the advantage. When a client restores from a backup taken before we deployed the directive, the directive is gone. When a client uses a poorly written plugin that rewrites .htaccess and discards everything outside its own markers, the directive is gone. The .htaccess approach is resilient to most events but not to all of them. The mitigation is a periodic reapply via cron.

The shell loop we actually use

This is the loop. It walks every cPanel user's public_html, plus the common per-domain subdirectory pattern, detects WordPress by looking for wp-config.php, and appends the block if it is not already there. Idempotent and logged.

#!/usr/bin/env bash
# /root/sguard/harden-xmlrpc.sh
# Append an xmlrpc.php block directive to .htaccess for every
# WordPress install under /home/*/public_html and one level deep.
# Idempotent: skips installs where the directive is already present.
 
set -euo pipefail
 
MARKER='# sguard-xmlrpc-block'
BLOCK=$(cat <<'EOF'
# sguard-xmlrpc-block
<Files xmlrpc.php>
  Require all denied
</Files>
# end-sguard-xmlrpc-block
EOF
)
 
updated=0
already=0
skipped=0
 
for wpconfig in /home/*/public_html/wp-config.php \
                /home/*/public_html/*/wp-config.php; do
  [ -f "$wpconfig" ] || continue
  docroot=$(dirname "$wpconfig")
  htaccess="$docroot/.htaccess"
  if [ ! -f "$htaccess" ]; then
    # No .htaccess at all. Create one with just our block.
    printf '%s\n' "$BLOCK" > "$htaccess"
    chown --reference="$wpconfig" "$htaccess"
    chmod 644 "$htaccess"
    updated=$((updated + 1))
    echo "CREATED  $htaccess"
    continue
  fi
  if grep -qF "$MARKER" "$htaccess"; then
    already=$((already + 1))
    echo "SKIP     $htaccess (already protected)"
    continue
  fi
  printf '\n%s\n' "$BLOCK" >> "$htaccess"
  updated=$((updated + 1))
  echo "UPDATED  $htaccess"
done
 
echo
echo "Summary: $updated updated, $already already protected, $skipped skipped"

The script runs as root because it has to write into every cPanel user's home directory. It uses chown --reference="$wpconfig" to make sure the new or appended .htaccess ends up owned by the same cPanel user that owns wp-config.php, not by root. Getting that ownership wrong breaks Apache suexec and causes 500s.

The first time we ran this on the affected agency server, the output ended:

Summary: 27 updated, 3 already protected, 0 skipped

Twenty-seven installs hardened in one shot. Three already had a block from a previous attempt. None skipped, because every install on the box had a writable .htaccess and a discoverable wp-config.php. The whole run took under a second.

We wire the script into cron at 03:30 daily. Daily is overkill if nothing changes; daily is the right answer because clients occasionally restore from backup at unannounced times and the attack window between "client restored from backup" and "directive reapplied" should not exceed a working day.

The CSF approach, additional layer

The Apache and .htaccess blocks return a 403 to every xmlrpc.php request. That stops the load on PHP-FPM and on MySQL, which is the operational win. It does not stop the TCP connection or the bytes on the wire. For an attacker who is hammering a single subnet at five thousand requests per hour, that is still real bandwidth and still real Apache log volume.

CSF (ConfigServer Security & Firewall) is the network-layer equivalent. We add a deny rule for the offending subnet:

# Deny the attacker subnet at the firewall, with a comment so the
# next on-call knows why.
csf -d 172.191.49.0/24 "xmlrpc brute force, 5400 req/hr, SG-incident-009"

csf -d writes the rule into /etc/csf/csf.deny and reloads iptables. The next packet from that subnet is dropped before Apache sees it. We pair the deny rule with a csf.allow exception for any of our own infrastructure IPs that legitimately need to talk to the affected server, so we do not accidentally lock ourselves out.

The deny is not the primary fix. The primary fix is the Apache or .htaccess block, because that protects against the next attacker subnet too. CSF deny is the cleanup pass that quiets the logs and saves the bandwidth. We add deny rules in response to specific incidents and review them quarterly; we do not maintain a permanent allowlist or denylist of generic "WordPress attacker" ranges because the false positive cost is too high.

Why "disable xmlrpc plugin" isn't enough

Most "block xmlrpc.php" guides on the first page of Google point you at a WordPress plugin. There are several of them. They all work, in the sense that they hook into WordPress and refuse to process XML-RPC requests. They are also all the wrong layer for a multi-site cPanel reality.

Plugins can be deactivated. A client doing housekeeping in wp-admin deactivates "the one I don't use" and the protection is gone. Plugins can fall out of date. A plugin author abandons a plugin, a WordPress core update changes the hook signature, and the protection silently stops applying. Plugins can be removed during a restore. A client restores a backup taken before the plugin was installed, and the protection is gone.

There is also the matter of CPU cost. A plugin runs inside WordPress. WordPress has to boot, load the database connection, load the active theme bootstrap, and dispatch the request to the plugin's xmlrpc_enabled filter before the plugin can return "no". That is at minimum 30-50ms of PHP-FPM time per request, sometimes more. An Apache or .htaccess block costs roughly zero, because Apache never invokes PHP at all. At 5,400 requests per hour, the difference between "PHP runs" and "PHP does not run" is the difference between "pool exhausted" and "pool fine".

The plugin layer is useful as a fourth layer behind Apache, .htaccess, and CSF, for the specific case where a future WordPress release moves authentication off xmlrpc.php onto some other endpoint and the attackers follow. It is not useful as the only layer.

Edge cases: when you actually need xmlrpc.php

There are real WordPress users who need xmlrpc.php. Jetpack, Automattic's general-purpose WordPress plugin, uses XML-RPC internally for its server-to-server communication with WordPress.com's infrastructure. Some old WordPress mobile app installs still use XML-RPC, though the official apps moved to the REST API years ago. A small number of WordPress-driven publishing tools (the late MarsEdit-style desktop clients) still post via XML-RPC.

If you have a client who genuinely uses one of these, blanket-deny breaks them. The fix is to allow the specific source IP ranges that need access, while denying everyone else. The Apache directive becomes:

<Files xmlrpc.php>
  Require ip 192.0.66.0/24
  Require ip 192.0.80.0/22
  # ... add Jetpack and WordPress.com IP ranges here
</Files>

The Jetpack and WordPress.com IP ranges are documented by Automattic and they update them occasionally. Maintaining the allowlist is real ongoing work; you do not want to do it for 27 sites if only one of them uses Jetpack. The pattern we use is to deny xmlrpc.php at the server-wide layer for the default case, and override per-site in userdata for the one or two clients who need it.

This is the layered-defence principle in concrete form. The default is denial. The exception is opt-in, documented, and scoped to the specific site that needs it.

The 30-second audit

If you run cPanel servers and have not yet looked at your xmlrpc.php traffic, this is the audit. It is one command. It counts xmlrpc.php POSTs across every domain hosted on the server, in the last 24 hours, and tells you which domains an attacker has been working on.

# Top 20 domains by xmlrpc.php POST count in the last 24 hours.
for log in /etc/apache2/logs/domlogs/*; do
  domain=$(basename "$log")
  count=$(grep -c 'POST.*xmlrpc\.php' "$log" 2>/dev/null || echo 0)
  if [ "$count" -gt 0 ]; then
    printf '%8d  %s\n' "$count" "$domain"
  fi
done | sort -rn | head -20

If the top number is in the thousands, you have an active incident. If it is in the hundreds, you have a slow brute force that is not yet visible in your PHP-FPM metrics but will be next week. If it is in the single digits, you have legitimate Jetpack traffic.

Running this audit on every cPanel server you operate, monthly, catches every xmlrpc.php campaign before it becomes a phone call.

This post is part of our Tier 1 postmortem series on cPanel and WordPress operations. Two adjacent posts are worth reading if xmlrpc.php is on your radar:

How ServerGuard handles this

This is the honest version. ServerGuard's use case for xmlrpc.php abuse maps to two sections of the spec: the WordPress hardening section and the firewall-layer section. The detection half is today; the remediation half is split into a Safe-tier action that ships today and a Moderate-tier action that ships.

Detection. ServerGuard ingests Apache access logs across every domain on every server it monitors, and runs a correlation between request volume on xmlrpc.php and PHP-FPM pool utilisation. When the volume crosses a per-server threshold (default: 500 xmlrpc.php requests per hour from a single source IP or /24), and the affected user's PHP-FPM pool is at or near its pm.max_children ceiling, ServerGuard raises an incident with the offending source range, the affected domain, and the request-per-hour count. The incident appears in the dashboard and fires a Telegram alert.

Remediation, Safe tier, automated today. Adding the offending subnet to csf.deny is classified Safe in our risk taxonomy. It modifies firewall configuration on the server we manage, not any client's site files. ServerGuard runs csf -d <subnet> with an incident reference as the rule comment, records the change in the audit log, and notifies the on-call engineer. The block is reversible and is reviewed automatically at thirty days; if the subnet has stayed quiet, the rule rotates out.

Remediation, Moderate tier, requires approval, upcoming roadmap. Running the .htaccess hardening loop across every WordPress install on the box is classified Moderate, because it modifies files inside client document roots. ServerGuard does not run this automatically. When detection fires, the proposed action goes through the Telegram or web approval flow: the on-call engineer sees the exact script ServerGuard proposes to run, the list of affected document roots, and approves or rejects. After approval, ServerGuard executes, logs every file modified, and writes the run summary back to the incident.

Remediation, Moderate tier, also upcoming. Deploying the wp.conf Apache block into cPanel userdata is classified Moderate for the same reason: it changes how the client's site is served. Same approval flow.

Out of scope, explicitly. ServerGuard does not modify .htaccess on individual client sites without explicit human approval. That is not an oversight; it is a deliberate risk classification. If a future request asks us to make this fully automatic, we will say no. The cost of getting .htaccess modification wrong on a production e-commerce site at three in the morning is higher than the cost of paging a human.

The line we draw is the line we drew in our WordPress compromise postmortem: the boring, scheduled, server-layer work (counting xmlrpc.php requests on every domain on every server, every hour, never skipping a Tuesday) is where ServerGuard earns its keep. The decision to touch a client's site files is still a human one. That is the deal.

شارك هذه المقالة

XLinkedInEmail
  • قراءة 17 دقيقة

    WordPress WP-Cron stacking on cPanel: a complete fix

    WordPress WP-Cron stacking on cPanel: a complete fix The page came in at 09:02 local time on a Tuesday. Every WordPress site on was returning 500s for roughly forty seconds, then quietly recovered, then went down again at 09:07, then again

  • قراءة 8 دقيقة

    Hardening every WordPress site on cPanel in one loop

    Hardening every WordPress site on cPanel in one loop You manage twenty-seven WordPress sites on one cPanel server. A clean hardening pass on a single site (disable xmlrpc, lock down file editing, force SSL on the admin, security headers int

  • قراءة 14 دقيقة

    Patchman activation breaks PHP sites: memory_limit gotcha

    Patchman activation breaks PHP sites: memorylimit gotcha The ticket landed mid-morning. Thirteen WordPress sites on were intermittently returning 500s. Not all at once, not on a clean five-minute beat, not correlated with traffic. The sites