
Two Plugins, 530,000 Sites, and an Attack That Leaves Four Ways Back In. Updating Ninja Forms to 3.15.4 Is Not Enough.
Attackers are using XSS bugs in Ninja Forms and WPC Product Bundles to plant four backdoors. Update to Ninja Forms 3.15.5, then check and clean up.

If you run Ninja Forms or WPC Product Bundles for WooCommerce, this week's attack is a case where updating is necessary and still not the end of the job. On October 6, 2026, Patchstack published an analysis of a campaign that uses stored cross-site scripting bugs in both plugins to run a script inside an administrator's browser. That script uses the admin's own login to install a fake plugin, create administrator accounts, and leave behind four separate ways back into the site. Between them, the two plugins show more than 530,000 active installations on WordPress.org.
Two things catch people out. First, the campaign write-up points readers at Ninja Forms 3.15.4, and the vendor shipped 3.15.5 a week after that release with another stored XSS fix. Second, updating the plugin removes none of the backdoors the script has already planted. This article covers which versions to run, how the attack works, how to check a site, and how to clean one up. Everything here reflects what was published as of October 6, 2026. Version numbers and exploitation status can change within days, so recheck them before you act.
TL;DR
- Patchstack reports active exploitation of stored XSS in Ninja Forms (CVE-2026-94504) and WPC Product Bundles (CVE-2026-93836), first seen October 4, with limited volume in its own telemetry. Two plugins is what it has confirmed, not a ceiling.
- Run Ninja Forms 3.15.5, not just 3.15.4. Run the newest WPC Product Bundles release (8.7.3 at the time of writing), not just 8.6.7.
- The script rides the administrator's logged-in session, so it never needs to steal a cookie, and resetting passwords alone does nothing to it.
- A single successful run can leave a visible admin, a hidden admin, a login link that signs in as your oldest administrator, and a file manager with no password.
- Check with SQL and the file system, not the Users screen, and rotate salts and credentials as part of the cleanup.
Which plugin versions are affected, and which should you run?
Both bugs are unauthenticated stored XSS. An attacker needs no account to plant the payload, and it sits in your database until someone with enough access views it. Both were disclosed in the week of September 22. Patchstack saw the first exploitation attempt on October 4 against WPC Product Bundles and the same payload against Ninja Forms on October 5.
| Plugin | Active installs (WordPress.org) | Vulnerable versions | CVE | First fix | Version to run on Oct 6, 2026 |
|---|---|---|---|---|---|
| Ninja Forms | 500,000+ | Up to 3.15.3 | CVE-2026-94504 | 3.15.4 | 3.15.5 |
| WPC Product Bundles for WooCommerce | 30,000+ | Up to 8.6.6 | CVE-2026-93836 | 8.6.7 | 8.7.3 |
The install counts are floors that WordPress.org rounds down, so 530,000 is a minimum for installations, not a count of sites that are exposed. On severity, Patchstack scores both bugs 7.1. The vector published in the CVE record, CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:N, works out to 7.2, which is the figure WPScan and Strix list. Either way both are rated high, so the decimal should not change what you do.
Why is Ninja Forms 3.15.4 not the version to stop at?
Version 3.15.4, released September 21, closed the bug in this campaign: it tightened output escaping on the admin submission edit screen. Version 3.15.5, which the plugin's changelog dates September 28, strengthens sanitization of Paragraph Text (Rich Text) field submissions against stored XSS. That one is tracked as CVE-2026-90438, was published October 1 and 2, and affects versions up to and including 3.15.4. WPScan notes it is only exploitable when the targeted Paragraph Text field has the Rich Text Editor option turned on.
Patchstack's own advice says "3.15.4 or later," and 3.15.5 is a later version, so that wording is not wrong. The risk is a reader who sees "3.15.4" in a headline and stops there. I have not seen any report tying CVE-2026-90438 to this campaign. Patchstack connects the Ninja Forms attempts to CVE-2026-94504, so treat 3.15.5 as the correct target version rather than as a sign of a second attack.
One more check before you update: the plugin page lists WordPress 6.9 or higher and PHP 7.4 or higher as requirements for 3.15.5. A site below those will not be offered the update, and that is worth fixing in its own right.
What about WPC Product Bundles?
Version 8.6.7 is the first release that fixes CVE-2026-93836. The vendor's changelog describes it as a fix for a vulnerability reported by lhking, which matches the researcher credit in Patchstack's database. The plugin has moved on since then. Version 8.7.3 carries a changelog line describing a security fix that sanitizes bundle IDs from the request, the cart session, and order item metadata, and prevents stored XSS.
The changelog does not say whether that fix covers the same input the campaign abused, and I could not find a CVE for it. Treat it as a reason to go to the newest release, not as proof of anything. Because 8.7.0 changed the settings screen and 8.7.2 changed how stock status is handled, test the update on a staging copy first if the site takes orders. If your dashboard does not offer the new version yet, WordPress.org can hold plugin releases back from the update API for a few hours, which we covered in a separate article.
How does the attack work if it never steals a cookie?
The attack has three stages: plant, wait, and ride.
Plant. In WPC Product Bundles, a quantity that starts with a valid number passes the plugin's quantity check even if markup follows it. The plugin then stores the whole value in WooCommerce order item metadata and prints it later without escaping it. Patchstack saw requests that put a script tag after a number in the woosb_ids quantity parameter. In Ninja Forms, the attacker submits a normal form through the plugin's usual AJAX submit action. A value in a plain textarea-style field closes the textarea early and adds an image tag with an error handler that loads the remote script. The legacy submission editor in wp-admin printed that value without safe encoding.
Wait. Nothing happens until a privileged user opens the order or the submission. That is routine work on a store or a busy contact form, which is why stored XSS suits this campaign so well. The attacker does not need to trick anyone into clicking a link.
Ride. The script loads from a domain Patchstack says was registered on October 1, 2026. It then works through a fixed sequence:
- It asks the attacker's server what has already been done on this site.
- It loads wp-admin pages in the admin's browser and reads the one-time tokens (nonces) WordPress puts on them.
- It replays those tokens to upload a fake plugin through the normal plugin upload screen.
- It creates an administrator account through the normal Add User screen, with credentials the attacker's server supplies.
- It calls an installer file inside the fake plugin, which sets up the hidden persistence described below.
- It reports the result back.
The script never has to read the login cookie. The browser attaches that cookie to every same-site request on its own, so the script just makes requests as the logged-in user. That is why the HttpOnly cookie flag, which stops scripts reading a cookie, does not help here. It also sets the ceiling on the damage: the script can do whatever the person viewing the page can do. An administrator can upload plugins, so the whole chain works. A role without the install_plugins capability cannot upload one, though a stored XSS would still run with whatever that role is allowed to do. The same principle sits behind our earlier write-up of Comment2Shell, where an administrator's session was the real target.
What are the four ways back in?
One successful run leaves the attacker with four independent routes. Only one of them is easy to see from the dashboard.
| # | Route | Where it lives | What removes it |
|---|---|---|---|
| 1 | Visible administrator | A normal user created with attacker-chosen credentials | Delete the account |
| 2 | Hidden administrator | A second admin, concealed by a must-use plugin named like class-wp-query-<8 hex>.php |
Delete the mu-plugin and the account |
| 3 | Login link at /wp-login.php?_wplogin=<token> |
A second mu-plugin, class-wp-token-validate.php, plus the fz_emer_login_tokens option |
Delete the mu-plugin and the option |
| 4 | File manager with no password | wp-content/plugins/wp-smart-thumbnails/ |
Delete the plugin folder |
A few details explain why cleanup goes wrong so often.
The hidden admin is not hidden by clever database tricks. A must-use plugin edits the user query, the Users list, and the role counts shown above the list, so the account is missing from the screen and the totals add up. Must-use plugins sit in wp-content/mu-plugins/ and load on every request. WordPress shows them in a separate Must-Use view on the Plugins screen, not in the main list, and most site owners have never opened it. The two files in this campaign are also obfuscated with a different key on each site, so there is no single file hash to search for.
The login link is the nastiest of the four. Patchstack says the token is tied to the oldest administrator on the site, not to the hidden account. Whoever holds the URL is signed in as your original owner, and nothing that records only a user ID can tell that session from a real one. Resetting that person's password does not break it, because the link never needed the password.
The file manager sits inside a plugin named "WP Smart Thumbnails" from a made-up author. Its main file exits early when WordPress loads it, so installing or activating it appears to do nothing. It only runs when someone requests the file directly. Patchstack describes it as offering list, upload, delete, rename, read, and save operations with no authentication. Because it can read files, anything the web server user can read should be treated as exposed if the file manager was reachable, including wp-config.php and the database password inside it.
Finally, the installer sets the modification time of every file it writes to the oldest timestamp it can find in the WordPress root. A hunt for recently changed files will miss all of it. Search by name and content instead.
How do you check whether your site was hit?
Any site that ran a vulnerable version since September 22 deserves a check. The risk is highest where staff routinely open new orders or form entries, because that is what triggers the stored script. Do the checks from the shell or a database client, not from wp-admin, because the Users screen is one of the things the attack edits.
First, find your table prefix. It is wp_ on many sites and something else on others, and the queries below fail quietly with the wrong one.
grep table_prefix wp-config.phpSearch the database for the delivery domain
This finds payloads that were stored, whether or not anyone opened them.
wp db search imgcdn1.com --all-tablesFor WPC Product Bundles, the CVE write-ups say the payload lands in order item metadata under the _woosb_ids key. Normal values for that key should not contain angle brackets, so a match is a lead worth reading.
SELECT order_item_id, meta_key, LEFT(meta_value, 120) AS preview
FROM <prefix>woocommerce_order_itemmeta
WHERE meta_key = '_woosb_ids'
AND meta_value LIKE '%<%';Standard web server access logs record the request line but not the body of a POST request. A Ninja Forms attempt arrives as a POST, so the access log will not show it and the database search is the way to find it.
List administrators straight from the database
This query reads the tables directly, so the filters that hide the account from the Users screen do not apply to it. Run it in a database client or phpMyAdmin. On MagicWP, temporary phpMyAdmin access is available for this.
SELECT u.ID, u.user_login, u.user_email, u.user_registered
FROM <prefix>users u
JOIN <prefix>usermeta m ON m.user_id = u.ID
WHERE m.meta_key = '<prefix>capabilities'
AND m.meta_value LIKE '%"administrator"%'
ORDER BY u.user_registered DESC;Replace <prefix> with your real prefix in all three places. On a multisite network, each subsite stores its roles under its own key, such as <prefix>2_capabilities, so repeat the query for each one. Compare the result with the Users screen. An account that appears here and not there is hidden. Patchstack also lists two further signals: an email address on a wordpress.org domain, and a username that looks like routine upkeep, such as support, updater, or maintenance.
Look at the files and options
# Everything in the must-use directory
ls -la wp-content/mu-plugins/
# The two file names Patchstack reports
find wp-content/mu-plugins -name 'class-wp-query-*.php' -o -name 'class-wp-token-validate.php'
# The fake plugin
ls -la wp-content/plugins/wp-smart-thumbnails/Any file in mu-plugins that you or your host did not put there needs an explanation. Some hosts and plugins use that folder on purpose, so check before you delete anything that does not match the names above.
SELECT option_name
FROM <prefix>options
WHERE option_name IN ('fz_emer_done_v1', 'fz_emer_login_tokens');If fz_emer_login_tokens exists, at least one login link may still be live.
Check your logs
grep -n "_wplogin" <access-log-path>
grep -n "wp-smart-thumbnails" <access-log-path>
grep -n "imgcdn1.com" <access-log-path>A request carrying _wplogin to wp-login.php is a sign the login link was used. A direct request to the fake plugin's main file is a sign the file manager was used, since it only runs when called that way.
How to read the results
Finding the domain inside a stored order or submission means someone tried to attack the site. It does not prove anyone opened that entry. Finding an unexplained administrator, a file from the table above, or either option means the script ran, and you should treat the site as compromised, not just patched.
How do you clean up a site that was hit?
Work in this order. Each step assumes you did the one before it.
Take a backup before changing anything. Keep a copy of the database and files outside the web root as evidence, and use it as a way back if a step goes wrong. On MagicWP, on-demand backups before changes are part of the platform, and daily off-site backups with one-click restore sit behind that.
Update both plugins. Install Ninja Forms 3.15.5 and the newest WPC Product Bundles so no new payloads can be planted. Until the stored ones are removed, avoid opening orders or form entries in wp-admin.
Remove the files and options. Delete
wp-content/plugins/wp-smart-thumbnails/and the two mu-plugin files. Then delete both options:wp option delete fz_emer_done_v1 wp option delete fz_emer_login_tokensDelete the accounts. With the mu-plugin gone, the hidden administrator should show up in the Users screen and in
wp user list. Delete every administrator you cannot account for, using the ID from the SQL query above.wp user delete <ID> --yesClear the stored payloads. Use the database search results to remove the injected values from the affected orders or submissions. Edit or remove the bad value instead of deleting real orders, and do it on a copy first if you can.
Rotate credentials and salts. Change the passwords of every administrator, including your oldest one, because the login link signed in as that account. Replace the keys and salts in
wp-config.php. This logs out every existing session, including any the attacker already holds, and it is a general WordPress behavior rather than something specific to this campaign. Removing the option and the mu-plugin is what kills the link itself.wp config shuffle-saltsIf the file manager was reachable, also change the database password and any API keys stored in
wp-config.phpor in the database.Check what else changed. The file manager can write any file the web server can write, so a second shell is possible. Compare core and plugin files against WordPress.org checksums, and look for PHP files in the uploads folder, where none should exist.
wp core verify-checksums wp plugin verify-checksums --all find wp-content/uploads -name '*.php'Checksum commands only cover files WordPress.org publishes, and
wpcommands load must-use plugins, so run them after step 3.
Should you restore from a backup instead?
Often yes, if you have a backup from before the infection. Patchstack's earliest observation is October 4 and the attacker domain was registered October 1, so a backup from before October 1 is a safe reference point for this campaign. It is not proof the site was clean. Both bugs were public from September 22, and a different attacker could have used them earlier. After any restore, repeat the admin query, the mu-plugins check, and the options check, then update both plugins and rotate credentials. For a store where you cannot tell what an attacker saw, rebuilding from a clean install and importing only your content is the more cautious route.
How do you make the next campaign harder?
The post-exploitation stage does not depend on these two plugins. Patchstack points out that any stored XSS that puts script in front of an administrator can deliver the same implant, so the hardening below applies beyond this incident.
Keep the number of administrators small. Do daily order and form review in an account that cannot install plugins, and sign in as an administrator only when you need to.
Block plugin installs from the dashboard if your workflow allows it. Setting
DISALLOW_FILE_MODSto true inwp-config.phpstops plugin and theme installation and updates from the admin area, and it also disables the file editor, so the upload step in this chain would fail. The cost is that you must update through another route, such as WP-CLI or Git deploys, which MagicWP supports.define( 'DISALLOW_FILE_MODS', true );Look in
mu-pluginson a schedule. It takes seconds and it is the one place this campaign hides that regular plugin audits skip.Drive urgency from a vulnerability feed. Both of these bugs were public for roughly twelve days before the first exploitation Patchstack saw. The window between disclosure and attack is where the update does its work.
Do not lean on IP blocking. Patchstack reports that most of the source addresses it saw were Tor exit nodes, so a blocklist is not a lasting fix.
A web application firewall and malware scanning are part of what MagicWP provides, along with isolated containers and automatic core updates with rollback. None of that replaces updating the plugin. Whether a particular rule covers this campaign is something to confirm with your host or security vendor rather than assume. You can read more about the platform side on the MagicWP security features page. If you are dealing with malware that keeps coming back, our guide to the SC backdoor that rebuilds itself covers a different threat with a similar reinfection pattern.
Frequently Asked Questions
Is updating Ninja Forms to 3.15.4 enough?
It closes the bug used in this campaign, but it is not the version to stop at. Ninja Forms 3.15.5 fixes a separate stored XSS in Rich Text Paragraph Text fields, tracked as CVE-2026-90438, and affects versions up to 3.15.4. Neither update removes anything an attacker already planted. If an administrator opened an affected submission before you updated, check for the hidden admin, the mu-plugins, the two options, and the fake plugin.
Which WPC Product Bundles version should I run?
Run the newest release. Version 8.6.7 was the first to fix CVE-2026-93836, and at the time of writing the plugin page lists 8.7.3 as current. Its changelog describes an additional stored XSS fix covering bundle IDs from the request, cart session, and order item metadata. Test on staging first for a live store, since the 8.7 releases changed the settings screen and stock status handling.
Does changing my passwords remove the attacker?
No. The script creates its own accounts, a hidden administrator, and a login link that signs in as your oldest administrator without a password. Changing passwords helps only after you remove those. Delete the accounts, the two mu-plugin files, and the two options first, then rotate passwords and the keys and salts in wp-config.php.
How do I find the hidden administrator?
Query the users tables directly, because the attack hides the account from the Users screen and from the totals above the list. Join the users and usermeta tables on the capabilities key for your own table prefix, and compare the result with what wp-admin shows. Any account in the query but not on screen is hidden. Deleting the must-use plugin that hides it also makes it visible again.
Can a firewall protect me instead of updating?
A firewall can lower the risk, but it is not a substitute for the update. The payload is built from input that looks like an ordinary quantity or form value, and coverage depends on whether your vendor wrote a rule for these specific bugs. Confirm that with your vendor. Blocking IP addresses will not hold up either, since Patchstack found most source addresses were Tor exit nodes.
Are other plugins involved?
Possibly. Patchstack has confirmed two so far and says the number is not a ceiling. The second stage is not tied to either plugin. It works from any stored XSS that puts script in front of an administrator. So the safest approach is to keep every plugin current and apply the hardening steps above, not to rely on a short list of affected names.
What if no one ever opened an order or submission?
Then the stored script probably never ran. It executes only when someone with enough access views the injected entry. Still, run the database search for the delivery domain, remove any stored payloads, and update. Finding a payload that nobody opened is a good result, but it shows someone targeted the site.
Conclusion
The part of this attack that matters most is what happens after the XSS fires. The bug is the front door, and what comes through it is built to outlive the plugin that let it in. That is why the answer to this Ninja Forms and WPC Product Bundles campaign has two halves: run Ninja Forms 3.15.5 and the newest WPC release, then check for the four routes and remove them. Skipping the second half leaves a site that looks fixed and is not.
If you host with MagicWP, on-demand backups, one-click staging, and SSH with WP-CLI make that cleanup safer to rehearse before you touch production. The same advice holds wherever you host: keep a restore point from before the problem, test fixes on a copy, and read the mu-plugins folder at least once.
Get the best of MagicWP in your inbox.
Monthly engineering notes, product updates, and WordPress performance tips. No spam, unsubscribe anytime.

