Ktdigital.vn

A WordPress Backdoor That Came Back Three Times: Plugin Vendor Folders, a Theme File and a Leaked Password

Case Study · Security • November 6, 2026 • 12 min read • AHAiden Hoang Nguyen

In Part 1 I cleaned a business WordPress website in Vietnam after 119 casino spam posts and four rogue administrators. The cleanup looked complete. The next morning a Wordfence alert reported new administrator accounts again, and this time the cause was three layers: 16 webshells hidden in plugin vendor folders, two fake plugins, and a cookie-triggered backdoor inside the theme’s functions.php. I fixed those, patched PHP and rotated the secrets. Nine hours later the site was infected a third time, through a self-healing plugin installed with a leaked administrator password. This post records how each layer was found, what finally stopped the cycle, and which checks would have found it sooner.

Project Snapshot
Site type
WordPress + Elementor (GeneratePress) on a VPS with FastPanel
Scale
About 30 plugins, a 1.3 GB database
Role
Incident response, root-cause analysis, rebuild
Duration
5 days of incident response (2 to 6 November), monitoring continues
Access level
Root SSH and WP-CLI
3
Reinfections before it stopped
23
Backdoor files isolated as evidence
9
Persistence points in the third plugin

What came back after the first cleanup

A bot re-created two administrator accounts roughly every 2 hours 15 minutes. Blocking the attacker’s IP in Wordfence changed nothing: ten different addresses were calling the malicious files directly, and the free Wordfence firewall only runs when WordPress itself loads, so a request straight to a stray PHP file never reaches it.

Nobody could say when the site was first compromised. The server logs only went back to 24 October, and the only reliable clue was the order in which files had been planted.

Industry Fact
PHP released version 8.3.36 on 21 October 2026 to fix two critical flaws known as PHP2Shell (CVE-2026-109618 and CVE-2026-109619). This server was still running 8.3.8. I could not prove that was the way in, because the logs started too late, so I patched it anyway and treated the entry point as unknown.
Source: BleepingComputer and Cloudflare, October 2026

Layer 1: 16 webshells inside plugin vendor folders

wp core verify-checksums and wp plugin verify-checksums --all compare every file with the official release. Next to the “doesn’t verify” lines they print “File was added”, and those are the lines that matter. They exposed 15 PHP files inside the vendor/ folders of four popular plugins (a backup plugin, a page builder, a cache plugin and a security plugin), plus one stray accept.php in the site root.

The names looked like library code, for example album_rate.php, admin.modules.php or qtype_calculated.php. Each one accepted POST requests and ran whatever was inside them. A vendor/ folder is an ideal hiding place: thousands of legitimate files that nobody opens.

Layer 2: two fake plugins and one edited plugin file

  • A fake “core utility” plugin (wp-core-util) was a spam network client. It injected virtual posts, added URLs to the sitemap, hid links in the footer, showed different content to Googlebot, and accepted commands over the REST API with a key derived from the site’s own salts.
  • A fake “mail debugger” plugin (wp-mail-debugger) copied the header of a real plugin by a known author. Its ten .mp3 files were PHP scripts, and it contained a handler that deleted the plugin on demand to hide it.
  • A real lazy-load plugin had been edited. 119 lines were added to its main file to hide one user ID from queries, the Users list and the REST API, plus a cron “heartbeat” every 5 minutes. No eval, no network call. Easy to miss in a code review, and exactly what verify-checksums exists to catch.

Layer 3: a cookie-triggered backdoor in the theme

verify-checksums does not cover themes. So I downloaded the same GeneratePress version from wordpress.org and ran diff -rq against the installed theme. Exactly one file differed: functions.php.

Line 1 was <?php followed by more than a hundred spaces, so in most editors the malicious code sat far off-screen to the right. It did nothing unless four numbered cookies were present. Then it decoded cookie number 3 (ROT13 plus Base64), wrote the resulting PHP to a temporary file, loaded it with require, and deleted it. The cookie indexes were written as arithmetic (-87+87) to dodge a simple string search.

The effect: remote code execution on every request, with no new URL to find and no distinctive POST in the access log. The only trace was in the site’s temporary folder: 536 tiny files, one per trigger, left behind because the payload exited before the cleanup line ran. The first one was dated 1 November 06:47. There were bursts of 198 and 128 requests in a single hour that line up with the spam waves, then a steady 3 requests every 2 hours, which was the bot recreating its administrators.

The file’s modification date said 10 July 2026. Timestamps can be forged, so I treated that date as unconfirmed.

Why my first scans missed it. I assumed the intrusion began on 10 October and filtered by “modified after 10 October”. I cut long outputs with head -40, which hid hits that sorted later. And I trusted checksum tools that cover WordPress core and wordpress.org plugins, but not themes, mu-plugins, uploads or premium plugins. One wrong assumption about the start date made the scan blind to the one file that mattered.

Round three: the door was a leaked password

My last clean check was at 14:10 on 3 November. At 22:51 the same evening a new plugin appeared, named like a harmless “content sync connector”: 21 PHP files written within one minute, three new mu-plugins, a second edit of the theme’s functions.php plus an extra guard file in the theme and in uploads, and a modified cache configuration. The file names contained words such as guard, watchdog and persistence. That is the signature of self-healing malware: delete one file and another one writes it back.

The user list was unchanged (four users) and the old trigger files in the temp folder had not grown, so this was not the old theme loader. The evidence pointed to a normal administrator login followed by a plugin upload through the dashboard. In round two I had regenerated the salts and revoked the Application Passwords, but I had not changed the human administrator’s own password, and it had leaked. A new salt invalidates existing sessions; it does not change the password someone already knows.

The plugin then behaved like a client of a remote control server. Over 27 hours it received 13 commands: enrol the site, create and delete a test post, then ten commands to publish casino posts, roughly one every 30 to 60 minutes. The database held 15 options and transients with the same prefix, two scheduled hooks, 11 new spam posts and a re-created Application Password.

What actually stopped it

  1. Stop PHP first. I stopped the PHP-FPM and web server services before touching any file. With PHP running, the guard files recreate whatever you remove; with PHP stopped, nothing can execute while you clean.
  2. Map the persistence points before deleting. The plugin stored its own map of nine places it could restore itself from, including a recovery zip hidden in a cache folder. I read that map, quarantined seven files and two cache folders, then searched by file name and by size plus SHA-256. Nothing was left outside the evidence folder.
  3. Clean the database separately. Delete the 15 options and transients, the two cron hooks, move the 12 spam posts to the trash, and revoke the re-created Application Password.
  4. Change the passwords people actually type: the administrator and every other user, the salts again, and the database password.
A mistake worth sharing. When I changed the database password, WP-CLI printed the new value in the command output, so I had to change it a second time with the --quiet flag. Treat any secret that appears in a terminal, a chat or a log as already exposed.

The rebuild order that worked

  1. Preserve evidence first. Copy every suspicious file, hash it, and export the database before changing anything.
  2. Move, never delete. Use mv to put each malicious file in an evidence folder that keeps its original path, so you always know where it lived.
  3. Cut the entry points before deleting the accounts. Removing rogue admins is pointless while the webshells that recreate them still run.
  4. Replace plugins from the official zips. Unpack each into a staging folder and swap only if the expected folder exists. Do not use wp plugin delete: it runs uninstall hooks that can wipe plugin data. Then re-run the checksums: 29 of 30 verified, and the one premium plugin was reviewed by hand.
  5. Replace the theme file with the official copy, then diff the whole theme again.
  6. Scan the whole tree for signatures, not only for files changed recently.
  7. Rotate every secret: regenerate the salts, revoke every Application Password, change the database and admin passwords, and turn on 2FA.
  8. Patch and reboot: PHP 8.3.8 to 8.3.36, then a reboot for the pending kernel update.
  9. Close the front door. Turn on 2FA for every administrator, remove vulnerable or abandoned plugins, and consider DISALLOW_FILE_MODS so nobody can upload a plugin from the dashboard. Check the installed version before applying any vendor zip: the premium add-on I was given was older than the one on the site (a version with a known 8.8 CVSS upload flaw), so installing it would have been a downgrade, not a fix.

Detection commands you can reuse

Run these from the site root with WP-CLI. They find this specific family, so a clean result is not proof that a site is clean.

# Files that should not exist in core or plugins ("File was added")
wp core verify-checksums
wp plugin verify-checksums --all

# Themes are not covered: compare with the same version from wordpress.org
diff -rq /tmp/theme-official/generatepress wp-content/themes/generatepress

# The padding trick: a PHP tag followed by 40+ spaces
grep -rlE '^<\?php {40,}' --include='*.php' .

# Cookie-driven code that hides its indexes as arithmetic
grep -rlE '\$_COOKIE\[-?[0-9]+[-+][0-9]+\]' --include='*.php' .

# Hundreds of tiny files in the site's temp folder = a loader being triggered
ls -la /path/to/site/tmp | wc -l

Results so far

  • Backdoor files isolated: 16 in the second round and 7 files plus 2 cache folders in the third, all kept as evidence rather than deleted.
  • Plugins verified: 29 of 30 against official checksums; the theme matches the official release exactly.
  • PHP: 8.3.8 to 8.3.36, confirmed from both the command line and the HTTP response header.
  • Checked from outside on 6 November: the homepage contains 0 spam keywords (also when requested with a Googlebot user agent), sampled spam URLs and the malicious plugin folder return 404, the public REST API lists no post published after 28 October, and the user list is not readable without authentication.

A cleanup is only finished when nothing has come back through several of the attacker’s cycles. I keep checking three things every few hours for 7 days: the Users list, any new PHP file, and the number of files in the temp folder. Spam posts stay in the trash for those 7 days before permanent deletion, and Google Search Console gets a fresh sitemap and URL removal requests.

A cleanup is finished when nothing comes back, not when the symptoms disappear. And a salt change is not a password change.
Aiden Hoang Nguyen · Senior Marketing Manager, Ktdigital
Tools & Stack
WP-CLI Wordfence diff & grep FastPanel nginx and Apache logs Claude Code
WordPress Security Check

Does your site keep getting infected after a cleanup?

Send me your domain and the symptoms. I will tell you which of these layers is worth checking first.

Leave a Comment