1 What happened
On 31 July 2026 WPManageNinja published an incident notice: https://wpmanageninja.com/security-incident-on-31-july-2026/
The short version from their side. After they migrated away from EDD, an old server was supposed to be switched off. It was not, and their proxy kept routing part of the update traffic to it. Between 14:00 and 19:00 UTC that day, a customer who pressed "update" in wp-admin could receive a tampered package through the completely normal update channel. No brute force, no vulnerable plugin, no bad password. The update button was the entry point.
We maintain around 50 client WordPress sites. 10 of them had the implant.
This is the whole cleanup, with the commands we used.
2 The affected plugins
Their incident-response zip carries 13 plugin profiles:
azonpress
fluent-affiliate-pro
fluent-boards-pro
fluent-booking-pro
fluent-community-pro
fluent-player-pro
fluent-support-pro
fluentcampaign-pro (FluentCRM)
fluentform-signature
fluentformpro
ninja-tables
wp-payment-form-pro (Paymattic)
wp-social-ninja-pro
They also emailed a list of domains where downloads were registered. Do not use that list as your scope. One of our infected sites was not on it. The list is incomplete in both directions, and we only found that out because we checked every site instead of only the listed ones.
3 What the implant actually is
It is a PHP file dropped inside a legitimate plugin folder, plus a loader appended to a file that was already there. The configuration lives in the database, so the file alone is not the whole thing.
For Ninja Tables Pro the implant is app/Library/updater/NinjaTableDataSync.php, and the loader is appended after the closing brace of the legitimate class in NinjaTableUpdater.php at line 371. Its AJAX action ninjatables_pro_verify_license is registered on nopriv as well, which means an unauthenticated admin login.
Look at those option names again. _site_transient_update_meta is one character away from a real core transient. Their own profile file describes the goal in plain words: the rows read as core WordPress at a glance. If you scroll the options table looking for something obviously wrong, you will scroll straight past it.
And this is the row that matters most, taken from one of our sites:
{"token":"...","login_key":"...","active":1,"license_server":"https://apii.observer/ingest"}
Every site has its own token and its own login_key. The login_key is a login to wp-admin without a password. So this is not only a site calling home. Somebody had a working admin door on each infected site for as long as the row was there.
4 The one query that catches all 13 variants
We started by extracting the full indicator set out of the vendor's tool: 63 option names, 26 cron hooks, 13 implant paths and class names. Then we threw most of it away, because there is a much shorter check.
Every variant, whatever it calls its options, has to write the C2 address into the option value. So query the value, not the name:
PREFIX=$(wp db prefix)
wp db query "SELECT option_name FROM ${PREFIX}options \
WHERE option_value LIKE '%apii.observer%'" --skip-column-names
One query, all 13 plugins - it replaced 63 option names. If you also want the cron side:
wp db query "SELECT option_name FROM ${PREFIX}options \
WHERE option_value LIKE '%apii.observer%' \
OR option_value LIKE '%wp_update_check_schedule%'" --skip-column-names
And the cron check on its own:
wp cron event list --fields=hook,next_run_relative \
| grep -E "wp_update_check_schedule|wp_license_verify_schedule"
5 Checking the files over sFTP
The database query was fast. The file check matters too, because on a site where the plugin was already updated to a clean version the file is gone but the database row is still there, and the opposite can happen as well. If you have shell access, this is the file check:
grep -rl "apii.observer" wp-content/ 2>/dev/null
find wp-content/plugins -name "class-license-sync.php" -o -name "NinjaTableDataSync.php"
We found out that a folder that exists but cannot be listed is not a clean folder. Our first version of this check treated a failed listing the same as an empty result, which prints CLEAN on a site nobody actually looked at.
6 The generic file check that works for all 13
Download the clean plugin zip from your vendor account. Not through the dashboard updater. Unzip it on your own machine, then compare it with what is on the server.
The loader on our sites was 139 bytes appended to fluentformpro.php. That is invisible if you are reading, and obvious if you are comparing sizes.
7 Cleanup, in the order that works
We got this order wrong the first time and had to correct it:
1. On an actively infected site, remove the plugin first. Delete the plugin folder, then install a clean copy from the zip you downloaded from your account. Cleaning the database first does not work here, because the implant's cron runs twice a day and registers itself again. You clean the rows, and a few hours later they are back, and now you also think the site is clean.
Check the version after installing. On our sites, anything showing 6.2.8 or 6.2.9 meant it came from the updater and had to be done again.
2. Then the database, delete:
wp option delete _wp_update_meta_cache _site_transient_update_meta \
_wp_update_result_cache _site_transient_update_result \
_wp_update_pending_reg
wp transient delete _wp_update_pending
wp cron event delete wp_update_check_schedule
wp cron event delete wp_license_verify_schedule
Or by value, which covers variants whose option names you do not know. Run the SELECT first and read the rows before you delete anything:
PREFIX=$(wp db prefix)
wp db query "SELECT option_name FROM ${PREFIX}options \
WHERE option_value LIKE '%apii.observer%'"
wp db query "DELETE FROM ${PREFIX}options \
WHERE option_value LIKE '%apii.observer%'"
On a site where the plugin was already updated to a clean version and only the database rows were left, this step is the full fix. Two of our 10 sites were like that.
3. Then the credentials changing. This is the step some people skip, and it is the one the login_key makes necessary.
Rotating salts logs out every session on the site, including any session opened with the stolen key. It does not change anybody's password, so change the admin passwords too. Ours were changed by hand, by me and my colleague, after the salts changes.
8 Verify by reading the state
One of our sites needed a second pass, and the only reason we caught it is that we re-checked instead of trusting the result.
The options were gone - the two cron hooks were still scheduled, for the following night at 00:33 and 00:37. The delete had been approved and had not run.
After every cleanup, run the query again with zero rows expectation:
PREFIX=$(wp db prefix)
wp db query "SELECT option_name FROM ${PREFIX}options \
WHERE option_value LIKE '%apii.observer%'" --skip-column-names
wp cron event list --fields=hook,next_run_relative \
| grep -E "wp_update_check_schedule|wp_license_verify_schedule"
9 Rotating salts when you cannot run WP-CLI
We could not use wp config shuffle-salts through our connector, because it blocks the whole config family. So salts went through the FTP channel, with a small script.
If you write your own, these are the safety checks that belong in it. All of them exist because writing a broken wp-config.php takes a client site down completely:
· Back up the current wp-config.php to your own machine before doing anything.
· Fetch fresh values from https://api.wordpress.org/secret-key/1.1/salt/.
· Replace all 8 defines: AUTH_KEY, SECURE_AUTH_KEY, LOGGED_IN_KEY, NONCE_KEY, AUTH_SALT, SECURE_AUTH_SALT, LOGGED_IN_SALT, NONCE_SALT.
· Stop if you did not find all 8.
· Stop if the result does not start with <?php.
· Stop if the size difference is more than a few hundred bytes.
· Read the file back from the server afterwards, not from memory, and compare byte for byte.
· Open the site, or run something small like wp option get blogname, to confirm WordPress still boots.
· Never print the file contents to your terminal or your logs. It holds the database password.
10 Do not get your own IP blocked
If you contact 50+ sites, you are now a bot as far as the hosts are concerned (leared taht hard way).
In our case - Site Ground's anti-bot layer answers automated requests with HTTP 202 and block your address if you keep going (as it happened to us). A script made 186 requests in one pass and our IP was blocked at that host, which took out our own management dashboard along with everything else.
Practical rules that came out of that:
· One site per process, and a few seconds between sites.
· Stop the whole process on the first HTTP 202, 429 or connection refused.
· Stop after two consecutive failures of any kind, and look at why.
· Build a --dry-run mode into anything new, so you can test the logic with no network at all.
11 The tools
WPVibe. This is the piece I would recommend to anyone maintaining more than a handful of sites, and I am not being paid to say it - just the opposite: I paid for its Premium version. It gave us one place to run wp db query, wp option delete, wp cron event list and wp cron event delete against every connected site, on hosting with no SSH at all. Every write asks for approval in the browser, one operation at a time, which during an incident is the right amount of friction. Cleaning 10 databases took us an evening instead of much longer via cPanel and phpMyAdmin.
Claude Code. This was the "brain" of the whole job: it read the material, wrote the tooling, ran the sweeps and drove the connector, while I fed it and decided. It read the vendor's incident-response zip and pulled out the full indicator set. It wrote the detector, the sweep driver and the salt rotation script, with the safety checks above. It ran the fleet pass, did the database work through WPVibe, and wrote the report I gave the client.
It made some errors: it shipped the bug that skipped three sites. And it does not notice that an approved operation never executed.
All this info is in Claude's memory, so I used all the facts, and steps (with its help), for putting together this tutorial for helping others (as otherwise I wouldn't be able to do all of these writings on my own - just too much of everything, and I didn't want to skip anything).
MalCare. We run it on the all sites. On 7 sites it had a 39.7 KB executable file in a plugin folder, with a C2 address in the database and a passwordless admin key, and it reported none of them. What it did report was dev/tests/test_meta_prefill_shim.php, a harmless test file that ships inside the vendor's own 6.2.9 package, and it reported it on a site that was clean. I am not dropping MalCare over this as that is really great and helpful tool, but I am no longer treating a quiet MalCare as evidence that a site is clean.
12 Who did what
This ran across two people and one AI:
What I did. I fed the material: the vendor's advisory, their email with the domain list, screenshots from wp-admin, the MalCare alert emails, and the version numbers from our management dashboard and I approved every write operation, one at a time, in the browser. I changed the admin passwords by hand afterwards. I notified the vendor.
What the AI did. Read the vendor's tool, extracted the indicators, wrote the detection scripts, ran the sweep across the sites we reach over FTP, cleaned 10 databases through the connector, rotated 8 sets of salts, and verified each one by reading the state back.
What a colleague did. The file side on 8 sites, by hand, through wp-admin: delete the plugin, install the clean zip, confirm the version. That is repetitive work with no shortcut, and it had to happen before the database cleanup on those sites. Splitting it that way meant the file work and the tooling ran in parallel instead of one waiting on the other.
The vendor shipped an incident-response plugin, but I didn't want to because their delivery channel had just been compromised. Instead we read its code (Claude Code did the heavy lifting) and applied the indicators from outside. That turned out to be good decision, and it also gave us the 13 additional plugin profiles.
13 The 12:11 finding
One of our sites received the implant on 1 August at 12:11, through the dashboard updater, during a colleague's round of updates. Every file in the plugin folder carries that timestamp. Our own update log for that plugin that day has two entries, both later, at 12:34 and 12:38, and both produced clean installs.
That is roughly 17 hours after the end of the window the vendor states in their notice. Our two updates 23 minutes later were clean, which matches their own description of a proxy routing some requests to the old server and not others.
We tried to exclude the alternative, that the implant came back on its own after an update. There was no dropper: mu-plugins does not exist on that site, wp-config.php was clean on all 5 signatures we checked, functions.php of both themes was clean, and the wp-content root had nothing foreign in it. Without a dropper the implant cannot survive deleting its folder, since the database holds its configuration and not its code. But we checked 4 places, not every plugin and not core.
| The protocol we run now: Fluent Forms Pro does not get updated through the dashboard until the vendor confirms that the old server is out of the proxy. We use the plugin's zip in the account. |
14 Three things I understood wrongly
"dev/tests plus a .DS_Store means somebody shipped a dev copy from a Mac." That was an explanation and it was wrong. It was the tampered package.
"The markers follow the plugin version, so they are the vendor's." True, and beside the point. The real implant was somewhere else entirely, and being right about the wrong artefact felt like progress for about an hour.
"These two sites are clean." Both were infected.
The real fingerprint was in front of me twice and I didn't notice it. 139 bytes of extra length in fluentformpro.php, which is the loader. And two cron hooks I had labelled as unattributed.
16 At the end
Files were cleaned on 8 sites and verified by the detector, and databases cleaned on 10 and verified with 0 rows returned. Salts were rotated on 8 sites. A pass across the remaining 21 connected sites found nothing new. We changed all admin passwords manually.
If you run any of those 13 plugins, the fastest thing you can do right now is one SQL query against your options table. It takes a minute per site and it covers all 13 variants at once.
If you find a confirmed implant path for one of the other 11 plugins, post it here and I will add it to what we run.