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 -20The 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 -rnIf 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 -10We 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 | headIf 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 -20If 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.
Related reading
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:
- WordPress WP-Cron stacking on cPanel: a complete
fix. The other
major source of PHP-FPM pool exhaustion on cPanel servers, with
the same multi-site framing. If your pool keeps maxing out and
xmlrpc.phpis not the source, WP-Cron stacking probably is. - Three real WordPress compromises and how we found them. Three anonymised postmortems where the initial vector was credential brute force. XML-RPC abuse is one of the most common ways those credentials get tested at scale before the compromise lands.
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.
Related posts
- 17 min read
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 min read
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 min read
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