The Backdoor That Healed Itself: Anatomy of a Self-Reinstalling WordPress Infection
Most WordPress cleanups are boring, and that’s how you want them. You find the bad file, you delete the bad file, you patch the hole it came through, you’re done.
This one was not boring. It’s the kind of infection that makes you doubt your own eyes — you delete a file, refresh, and it’s already back. We want to walk through it honestly, including a few of the turns where we were confident we knew what was happening and were simply wrong. Those wrong turns are where the useful lessons live.
Details are anonymized. The malware indicators are real, because sharing them helps.
The thing that started it
The first artifact was a plugin called “Plate character class,” dressed up to look like an official WordPress component (Author: WordPress, a plausible version number, a bland description). It wasn’t. It was two backdoors in one small file:
- An unauthenticated admin login bypass: request the site with a secret URL parameter and a hardcoded key (
?plate-character-class=WhXCFMwM5Pz0Xdp), and the plugin logs you in as a randomly chosen administrator and drops you atwp-admin. No password. The “random admin” touch is deliberate — it smears the activity across accounts so the logs don’t point at one user. - An encrypted remote-code-execution channel over
admin-ajax.php, exposed to logged-out visitors, taking an AES-encrypted, base64-wrapped payload that decrypts to a little instruction set the plugin executes withcall_user_func_array. The encryption isn’t there for secrecy; it’s there to defeat WAF signatures and make traffic logs opaque.
And it hid itself: hooked into the all_plugins filter to remove itself from the admin plugin list. Nobody scrolling wp-admin would ever see it.
That alone is a full compromise. But it was just the doorway.
Delete. Refresh. It’s back.
The real payload was a must-use plugin — wp-content/mu-plugins/ionic-interface-run.php, MD5 e6ec0f5d995d28ddbebf43536c2c2e19, byte-identical across every site in the account. Must-use plugins load on every single request, can’t be disabled from the dashboard, and don’t appear in the plugin list. Perfect hiding spot.
Its job was persistence. On every page load it re-created a small family of helper files — .user.ini files that told PHP to auto-load a hidden payload on every request, hex-named loader files (plus hidden dot-prefixed backup copies), and a couple of malicious WP-Cron beacons. It did this using a register_shutdown_function handler, so the files reappeared at the end of the very request in which you deleted them.
This is why “delete the bad file” failed. You weren’t removing malware; you were losing a footrace against the next visitor — and on a live site, search-engine crawlers alone keep that race running constantly.
Wrong turn #1: the checksum “malware” that wasn’t
Early on we rebuilt WordPress core and ran wp core verify-checksums. It lit up with dozens of File should not exist warnings pointing at a library called php-ai-client, with truncated, corrupted-looking filenames. It looked like a nested payload dump.
It wasn’t malware at all. WordPress 7.0 bundles that library with very deeply nested paths, and the version of WP-CLI we used extracts the core .tar.gz with PHP’s PharData, which silently truncates any path over 100 characters — a known bug. Our own clean download had corrupted itself, and the checksum tool was faithfully reporting the damage.
The lesson: verify-checksums flags everything that isn’t pristine core, and “not pristine” includes tooling bugs and perfectly legitimate files. Extracting from the .zip build instead of the .tar.gz fixed it in seconds. We’d have wasted an hour hunting a “payload” that was our own extraction artifact if we hadn’t stopped to actually read the filenames — they were all truncated at the same length, which is a bug signature, not an attacker one.
Finding the real writer
To catch a file that recreates itself, you watch the filesystem. fatrace prints the process that touches a path, and it immediately named PHP-FPM workers writing the .user.ini files. That told us the re-dropping happened inside normal web requests — not from a cron job or a stray process.
One .user.ini was the smoking gun. The site was throwing 500 errors, and the log said:
PHP Fatal error: Failed opening required '.../wp-content/<hex>.php' in Unknown on line 0
in Unknown on line 0 is the fingerprint of an auto_prepend_file directive — PHP is told to load a file before every script runs, so a failure has no script or line to attach to. The malware had set auto_prepend_file in .user.ini (in two locations per site, so removing one wasn’t enough) to run its loader on every request. When we deleted the loader but left the directive, every page fatally errored. The site was down because the malware was half-removed.
Wrong turn #2: “we stopped PHP” (we hadn’t)
Here’s the one that cost us the most time, and it’s the most useful lesson in the whole story.
To clean a self-healing implant, you have to stop the thing executing it. So we stopped PHP-FPM. And it kept re-dropping.
We stopped it again — a different service name. Still re-dropping. We traced a PHP master process to its control group and found it running inside a Docker container, concluded the whole stack was containerized, stopped the container… and the sites’ workers were still alive. That container turned out to be an unrelated BookStack wiki. We’d traced the wrong process.
The truth was messier than any single theory: this server ran several overlapping PHP engines at once — the operating system’s own php8.1-fpm, plus a hosting-panel set of php80 / php81 / php84 services, plus that unrelated container. Different sites in the account were served by different engines, and the implant re-dropped to all sites from whichever engine handled a request. Stopping one engine did nothing while the others kept serving. We’d also, at one point, accidentally restarted the whole stack with a copy-pasted “start” command, undoing our own containment.
The lesson: before you “stop PHP,” find out what actually serves the sites. cat /proc/<pid>/cgroup tells you the layer; tracing a real pool worker up to its master (ps -o ppid=) tells you the true service — not the first “master process” you see, and not the service name you assume. On a tangled multi-stack box, surgical stop/start is fragile: one missed engine or one stray restart and you’re back to square one.
The turn that actually worked: stop chasing, start locking
After enough rounds of whack-a-mole, we changed strategy. Instead of trying to freeze every PHP engine simultaneously, we removed the malware’s footholds and made them un-writable at the filesystem level.
The implant runs as the site’s non-privileged user. That user cannot defeat root-owned immutability. So we emptied the mu-plugins directories and the .user.ini files, then:
chattr +i .../wp-content/mu-plugins
chattr +i .../.user.ini
A +i directory can’t have files created in it; a +i file can’t be overwritten or deleted — even by its owner. Suddenly it didn’t matter how many PHP engines were running or how many requests came in. The workers kept trying to re-drop the files and simply failed. We verified the lock had caught a clean state (empty directories, zero-byte files), brought the sites fully back online, and watched fatrace show write attempts bouncing off the lock.
That’s the move we’d reach for first next time: on a self-healing infection you can’t fully quiesce, lock the foothold instead of chasing the process.
Wrong turn #3: trusting our own heuristic over the checksums
Cleaning up, we scanned for shells named after core files and flagged every wp-cron.php as suspicious because it contained $_GET. Several root wp-cron.php files got tagged as trojans.
Except a legitimate wp-cron.php legitimately contains $_GET['doing_wp_cron']. Our signature over-matched. When we ran the authoritative check — verify-checksums --include-root — most of those “trojans” verified perfectly clean. The genuinely modified files were elsewhere: a class-wp-widget-recent-comments.php on two sites and two oversized root files on a third, all confirmed by size and by failing their official hashes.
The lesson: heuristics find candidates; authoritative sources render verdicts. A grep for $_GET/base64_decode/eval is a great way to shortlist files and a terrible way to convict them. Where an official checksum exists, that’s your judge.
How big it really was
By the end, the scope was larger than “a WordPress site.” The attacker’s write access covered the entire hosting account: five WordPress sites reinfecting each other, plus dozens of webshells disguised as wp-cron.php and wp-blog-header.php scattered through non-WordPress applications on the same account, plus five attacker-created admin accounts (a administrator_<random> naming pattern, created during the compromise) that were a whole separate login backdoor, plus a couple of directly trojanized core files. The self-healing implant was the loudest part, but it was one of several redundant footholds.
What we’d tell you
- If it comes back after you delete it, you have a live source, not a leftover file. Stop deleting and go find what’s writing it —
fatraceor the audit log will name the process. - You can’t out-delete a self-healing implant. Either stop execution completely (and verify it’s stopped — know your stack), or make the foothold un-writable with root immutability and let the malware fail against it.
- Know your serving topology before you “stop PHP.” Overlapping engines and control-panel layers are common on shared/reseller hosting, and a per-account implant defeats a per-service stop.
- Heuristics shortlist; authoritative tools convict. Lean on official checksums; treat
should not existand signature matches as leads, not verdicts. - Cleanup is not the cure. Removing malware without closing the entry vector (here, the fake plugin and however it was uploaded) and rotating every credential just resets the clock. Rogue admin accounts and injected core files survive a “file cleanup” untouched.
- The whole account is the blast radius, not one site. On shared hosting, one compromised site can write to its neighbors and to non-WordPress apps in the same account.
And the honest meta-lesson from our own missteps: the reason this took as long as it did wasn’t clever malware — the implant was ordinary. It was environmental complexity. Overlapping PHP stacks, a tooling bug that mimicked an attack, a container that looked relevant and wasn’t, our own heuristic crying wolf. Incident response is as much about not fooling yourself as it is about the adversary.
The part that would have made all of this a footnote
Every mechanism above had to write a file to work — the implant, the shells, the .user.ini hooks, the injected core edits. Continuous file-integrity monitoring flags exactly that: a new file in mu-plugins, a changed core file, a .user.ini appearing where it shouldn’t. Caught on the first write, this is a five-minute cleanup on one site. Caught after it has healed and spread, it’s the story you just read.
That’s the whole reason we watch files for a living — so the next one of these is boring again.
