WordPress incident response
A small healthcare practice’s website was quietly serving spam. I traced the break-in to one admin login and tracked down what the attacker left behind.
Overview
The site looked fine in a browser. Google’s index was full of Japanese product spam.
This case is anonymised: the client, the domain, the host and everyone involved are left out.
A small healthcare practice’s WordPress site was reported as “hacked by AI bots”. The proposed fix was a paid anti-spam plugin, with no diagnosis behind it. I investigated it myself instead.
Before touching anything, I got written authorisation from the owner, ran an intake questionnaire (what the site does, what data it holds, who owns which accounts, what backups exist) and took a backup. I worked through the incident with the PICERL framework: preparation, identification, containment, eradication, recovery and lessons learned. Findings went into a log as they happened, not reconstructed afterwards.
What I found was a compromise that had been running for a long time: 23 malicious files across three discovery waves, two webshells, a self-healing backdoor, a dropper that could rebuild the infection, seven command-and-control domains, and WordPress’s automatic updates switched off by the attacker. I removed every file I found, using free tools only.
How I found it
The obvious suspects were bystanders, and the obvious checks came back clean.
The error logs were full of AI crawlers (GPTBot, Meta and OpenAI) being rate limited. That looked like the attack. It wasn’t. The crawlers were following thousands of spam pages someone else had planted. They were a symptom, not the cause.
I checked the claim of a hack from three directions before trusting it:
Google search results, the server’s error logs and an nmap scan. Then I
recorded what normal looked like for this install (WordPress version,
plugins, file layout) so I could spot what wasn’t.
Searching file names and file contents for the usual malware patterns found
nothing. So I read index.php by hand, and the payload was sitting at the
top of it, before WordPress’s own code. A clean search only meant my search
wasn’t good enough.
The most useful idea in the whole investigation came later. WP-CLI wasn’t
available on the shared hosting, so I wrote a PHP script that checks every
core file against WordPress’s official checksums. It passed. But a checksum
check only asks whether every expected file is present and correct. It never
asks whether something is there that shouldn’t be. After a full core
reinstall, wp-admin/ still held 1,169 files where a clean install has about
573. Listing everything that wasn’t in the official manifest turned up 11 more
malicious files. A final fresh scan of wp-content/, which no core checksum
covers, found 8 more, including a PHP file inside an uploads folder.
What the attacker left
23 files that looked unrelated, all from one attacker’s toolkit.
The files used different names, different folders and different disguises. What tied them together was hardcoded authentication tokens: the password the attacker uses to get into their own backdoor. Four files shared one token and three shared another. That turned a pile of separate finds into one operator, one toolkit, coming back over months. It matches a toolkit that has been publicly documented since at least 202212.
The main pieces:
- A cloaking backdoor in
index.php. On every page load it sent the visitor’s IP, user agent and requested URL to a command-and-control server on a domain made to look like Rakuten, then served whatever came back. The attacker’s server decided per request whether to show spam or the real page, which is why the owner never saw anything wrong. It also rewrote.htaccesswhenever that file changed, so cleaning.htaccessfirst would have been undone on the next page load. - A
.htaccessallowlist. It blocked direct access to PHP files, then let a list of the attacker’s own file names through, includingwp-l0gin.phpwith a zero in place of the letter o3. - A webshell posing as a security plugin. It sat at
wp-content/plugins/wp-login.phpwith a header copied from Monarx, a real security vendor. Decoded, it was “Zerion Mini Shell”: a password-protected file manager with upload, edit and delete, plus functions to fetch and run more code from the attacker’s servers4. Without the password it answered with a plain 404. - A fake “WordPress Diagnostic API”. Loaded by one line injected into
wp-config.php. The classes it defined don’t exist in WordPress. - The second and third waves. Three identical copies of a webshell in unrelated folders, a dropper that downloads fresh backdoors on request, a pair of files that restore a backdoor and set it read-only, and more files under fake Monarx and WP Super Cache headers.
The code hid itself four ways:
- Function names built at runtime.
base64_decodenever appears in the file. It’s assembled from fragments:join('', str_split('Ba' . 'sE' . '64' . '_' . 'De' . 'cODe')). - Payloads hidden in comments. The file reads its own source, cuts out the text between two marker words (Indonesian for “downloaded” and “executed”), decodes it to a temporary file, runs it and deletes it.
- Function calls through a lookup table. Dangerous function names are
stored in an object and called by number, so searching for
curl_execorevalfinds nothing. - Polyglot file headers. A PHP file that starts with
%PDF-, so tools that judge a file by its first bytes treat it as a PDF.
The backdoors called out to seven command-and-control domains, plus a fallback IP address. Rotating domains like that keeps the operation alive when one gets blocked.
How they got in
One successful admin login on 21 July 2026 at 07:16, then a plugin upload.
I used the files’ real timestamps (stat on the live server) to find the
exact dates, then read the raw access logs for those days:
- A request for the plugin upload page, redirected to the login page.
POST /wp-login.php, answered with a 302.- The plugin upload page again, this time answered with a 200.
A failed WordPress login reloads the login page with a 200. A successful one redirects into the admin with a 302. The upload page only renders for a logged-in admin. The first malicious files appeared at 12:06 that day.
There were no failed logins in the ten minutes before, so this wasn’t password guessing. The logs can’t say who was behind the login: a plugin uploaded from an untrusted source, or a password stolen somewhere else. I wrote it up as exactly that.
On 18 August the attacker came back from a hosting-provider IP address with no
login at all and refreshed four planted backdoors in one session, using an
action named upload_bs_for_old. After the first upload they never needed to
break in again. The backdoors were the access.
Some files were dated as far back as 2023, so the site may have been
compromised in some form for years. Nobody was maintaining it, and the
attacker had added AUTOMATIC_UPDATER_DISABLED to wp-config.php to keep it
that way.
Clean-up
Order mattered. Doing things in the wrong order would have undone the work.
- Evidence first. Every malicious file was copied with its timestamps
intact into a quarantine folder outside the web root and made inert with
chmod 000before anything was deleted. - The live webshell next. It allowed remote code execution, so it was
removed as soon as I found it, ahead of everything else. The database
password was rotated straight away, together with
wp-config.php. index.phpbefore.htaccess, because of the self-healing.- A full core reinstall from official WordPress files, then removing the
injected
wp-config.phplines and the update block, and rotating the secret keys to end every existing session.
Restoring a backup wasn’t an option. The newest backup already contained the webshell, with an internal timestamp from July.
One thing I deliberately didn’t do: change the admin password. The account belonged to a third party who hadn’t been consulted, so I logged it as an open risk and escalated it instead.
Impact and recovery
Traffic fell by about 85% in a month.
The site had a steady 1,000 to 1,900 unique visitors a month from January to July 2026. In August it had 158. Google Search Console showed no manual action and no security issue, so this was an automatic ranking drop, not a human penalty. That matters for the owner: it lifts as Google re-crawls a clean site, with no review request to wait on.
I checked that the spam URL patterns now return 404, set up Search Console, submitted the real sitemap, asked Google to remove the spam URLs and gave the owner full access to their own Search Console property. Search Console’s crawl stats also showed a spike on 5 and 6 September, the same dates as leftover webshell temp files on the server: two separate sources agreeing.
The site was later rebuilt as a static Astro site, which removes the admin login, the plugins and PHP entirely.
Two mistakes I caught
A false alarm. An automated scan reported 11 files changed during my own
session, which looked like the attacker re-planting files live. I took the site
offline and checked for malicious processes and cron jobs. Then I ran stat
on the live server: every file was really from 21 July or 18 August. The SFTP
download had reset the local timestamps. The site was back up within minutes.
A trusted-looking header. Three files carried a Monarx copyright banner,
and Monarx’s real product does leave neutralised stubs like that. Decoding one
showed a live, self-decrypting eval() loader with the same token as two
confirmed backdoors. The banner was camouflage.
Both times, checking against the source of truth took a couple of minutes.
What I’d change
- Run the “everything not in the official manifest” check first. It found more than anything else.
- Check an automated tool’s timestamps against the live server before acting on the urgency it implies.
- Treat a clean search as a sign the search is too weak, not that the file is clean.
- A signature-based scanner as an extra layer, because no amount of manual checking can prove nothing is left.
Footnotes
-
Claudio Kuenzler, WordPress mass attacks: many vulnerabilities (2022). ↩
-
Claudio Kuenzler, Technical analysis of a WordPress hack with lock360.php as a running process (2022). ↩
-
Website Malware Removal blog, WordPress .htaccess has been tampered with. ↩
-
hacked.codes, Analysing a WordPress PHP malware campaign (2022). ↩