2026-11-02 · 12 min · Balazs Jancsik

Ninja Forms Backdoor: Why Patched Isn't Clean

Updating Ninja Forms to 3.15.4 won't remove a Ninja Forms backdoor. Learn how to find hidden admins, mu-plugin backdoors and webshells at server level.

Ninja Forms Backdoor: Why Patched Isn't Clean

A Ninja Forms backdoor can survive the update. You saw the advisory, you ran the update, the plugin page shows a green "up to date" tick. Done?

Not if an attacker got there first. The October 2026 campaign against Ninja Forms is a good example of malware built to outlive the vulnerability that let it in. Updating the plugin closes the door. It doesn't remove whoever already walked through it, and in this case they deliberately hid from the WordPress dashboard you'd normally use to check.

This post covers what happened, why a patch isn't a cleanup, and how to hunt down hidden admins and backdoor plugins from the server side, where the compromised application can't lie to you.


What happened

The vulnerability: CVE-2026-94504 (Ninja Forms ≤ 3.15.3)

Ninja Forms is one of the most widely used WordPress form builders (more than 500.000 installs). Versions up to and including 3.15.3 contain an unauthenticated stored XSS bug, CVE-2026-94504 (CVSS 7.1). An anonymous visitor can submit a crafted value in a non-rich-text textarea field. That value is later rendered without safe HTML encoding in the legacy admin submission editor. When an administrator opens the submission, the injected JavaScript runs inside their logged-in wp-admin session. Version 3.15.4 fixes it by strengthening output escaping on that screen. (Patchstack database, Patchstack analysis)

Patchstack also notes a possible CVE collision: the same issue was published by another CNA as CVE-2026-92438. Updating to 3.15.4 fixes both.

Timeline

Date (2026)Event
Sep 22CVE-2026-94504 (Ninja Forms) and CVE-2026-93836 (WPC Product Bundles for WooCommerce) publicly disclosed
Oct 1Attacker domain imgcdn1[.]com registered
Oct 4First exploitation seen, against WPC Product Bundles (≤ 8.6.6)
Oct 5The same payload delivered through Ninja Forms
Oct 6Patchstack publishes its analysis; BleepingComputer and others report on it

Source: Patchstack.

How widespread is it? As of publication, we've only seen minor traffic that would indicate exploitation. We don't have reliable numbers on how many sites were compromised, and we won't guess. What matters is that the post-exploitation stage works the same no matter which plugin delivers it, so any stored XSS that reaches an admin's browser can deliver the same implant.

Not to be confused with: earlier in 2026, a separate critical bug in the Ninja Forms – File Uploads add-on (CVE-2026-0740, ≤ 3.3.26, unauthenticated arbitrary file upload, CVSS 9.8, fixed in 3.3.27) was exploited starting April 6, with mass exploitation April 9–13 (Wordfence). That one let attackers drop PHP webshells directly. Different bug, same lesson: if you ran either vulnerable version, the hunt below applies to you.

The attack chain

  1. The attacker posts a form submission through the normal Ninja Forms AJAX endpoint (admin-ajax.php, action=nf_ajax_submit) with a loader that pulls https://imgcdn1[.]com/fz/x.js.
  2. An admin opens the submission. The script runs in their session. It never steals the cookie (so HttpOnly doesn't help); it just uses the active session and scrapes the CSRF nonces it needs.
  3. Through WordPress's own plugin uploader it installs a fake plugin, "WP Smart Thumbnails" 1.2.4 by "MediaPress Labs" (slug wp-smart-thumbnails), and creates an administrator with credentials supplied by the attacker's server.
  4. It calls the plugin's emer-run.php, which drops obfuscated must-use plugins for long-term access.

(Patchstack)

Four ways back in

Patchstack's analysis lists four independent ways back in after a single successful run:

  1. A visible admin account created through user-new.php.
  2. A hidden admin account. Its username is picked at random from about fifty harmless-sounding words like support, updater, maintenance and backup, with an @wordpress.org email. A mu-plugin at wp-content/mu-plugins/class-wp-query-<8 hex>.php hooks pre_user_query, users_list_table_query_args and views_users, so the account is missing from All Users, missing from the Administrator filter, and not counted in the role totals.
  3. A magic login URL, /wp-login.php?_wplogin=<token>, handled by mu-plugins/class-wp-token-validate.php. The token is stored in the fz_emer_login_tokens option, and it logs the holder in as the site's oldest administrator, which is usually the real owner.
  4. An unauthenticated file manager in wp-smart-thumbnails.php. It's packed in seven layers and loaded through a custom dyiv:// stream wrapper instead of eval(). Its main file exits when loaded by WordPress and only runs on a direct HTTP request, so in wp-admin the plugin looks completely harmless.

On top of that, the mu-plugins are obfuscated with a different key per site (so no shared hash), and every dropped file is backdated to the oldest modification time found in the WordPress root.


Why patching isn't enough to remove a Ninja Forms backdoor

Updating Ninja Forms to 3.15.4 does exactly one thing: it stops new XSS payloads from firing in that screen. It doesn't:

  • delete user accounts,
  • remove other plugins,
  • touch wp-content/mu-plugins/,
  • clear options from wp_options,
  • or revoke a magic-login token.

Patchstack puts it plainly: "Removing the vulnerable plugin, or even the malicious one, closes none of the last three." Even deleting wp-smart-thumbnails leaves the hidden admin and the magic login in place, because they live in mu-plugins, which load on every request, can't be deactivated from wp-admin, and never show up on the Plugins screen.

And the usual "check the dashboard" step fails by design here: the dashboard is running the attacker's code. If the hidden-user filter is loaded, what WordPress shows you is what the attacker wants you to see.


Practical hunt guide

Run these from a shell on the server, as the site's system user, with a fresh backup or snapshot taken first (for forensics and rollback). The paths assume the default wp_ table prefix; adjust if yours differs (wp config get table_prefix).

⚠️ Tip: wp-cli loads WordPress, which loads mu-plugins, so in theory a hidden-user filter could affect WP-CLI output too. Use --skip-plugins --skip-themes and always cross-check with raw SQL.

1. Enumerate administrators: WP-CLI vs. the database

# What WordPress reports (mu-plugins can't be skipped, so treat as "what the app says")
wp user list --role=administrator --fields=ID,user_login,user_email,user_registered --skip-plugins --skip-themes

# Every user, newest first
wp user list --fields=ID,user_login,user_email,user_registered,roles --orderby=user_registered --order=DESC --skip-plugins --skip-themes

Then ask the database directly:

-- Anyone with the administrator capability, straight from usermeta
SELECT u.ID, u.user_login, u.user_email, u.user_registered
FROM wp_users u
JOIN wp_usermeta m ON m.user_id = u.ID
WHERE m.meta_key = 'wp_capabilities'
  AND m.meta_value LIKE '%administrator%'
ORDER BY u.user_registered DESC;

-- Suspicious patterns from this campaign
SELECT ID, user_login, user_email, user_registered FROM wp_users
WHERE user_email LIKE '%@wordpress.org'
   OR user_login IN ('support','updater','maintenance','backup');

-- Users with elevated user_level (a second place privileges live)
SELECT user_id, meta_value FROM wp_usermeta
WHERE meta_key = 'wp_user_level' AND meta_value >= 8;

Any account the SQL returns that wp-admin doesn't show is being hidden. On multisite, also check wp_sitemeta → site_admins (super admins) and the per-site wp_N_capabilities keys.

2. Find code that hides users

Look for user-query filters anywhere in wp-content:

grep -rnE "pre_user_query|users_list_table_query_args|views_users|login__not_in" \
  wp-content/ --include=*.php | grep -v "/wp-content/plugins/<known-good-plugin>/"

Legitimate plugins do use some of these hooks, so read the hits rather than deleting them all. An obfuscated file using them is a strong red flag. Since the campaign's mu-plugins are XOR + gzdeflate blobs, the hook names may not appear in plain text, which is why step 3 matters.

3. Inspect mu-plugins, which the Plugins screen never shows

ls -la wp-content/mu-plugins/
wp plugin list --status=must-use --skip-plugins --skip-themes

# Campaign-specific paths
ls -la wp-content/mu-plugins/class-wp-token-validate.php wp-content/mu-plugins/class-wp-query-*.php 2>/dev/null

Many sites have no mu-plugins at all. Some hosts and managed platforms add their own, so you should know what yours are supposed to be. Anything named like a WordPress core class (class-wp-*.php) sitting in mu-plugins is suspicious: core classes live in wp-includes/, not here.

4. Compare plugins on disk vs. plugins WordPress lists

ls -1 wp-content/plugins/
wp plugin list --fields=name,status,version --skip-plugins --skip-themes

# Verify plugin files against wordpress.org checksums (only works for wp.org-hosted plugins)
wp plugin verify-checksums --all
wp core verify-checksums

Look for the campaign's slug wp-smart-thumbnails, but also for any directory you can't account for. A plugin with a readme.txt of two lines and empty "feature" files is a decoy.

5. Search for obfuscated PHP by content, not by date

The usual "recently modified files" search is still worth running, since plenty of malware doesn't bother with backdating:

find . -name "*.php" -mtime -45 -printf '%TY-%Tm-%Td %TH:%TM  %p\n' | sort -r
# ctime can't be set from userland as easily as mtime; it shows when the inode actually changed
find . -name "*.php" -cmin -$((60*24*45)) -printf '%CY-%Cm-%Cd %CH:%CM  %p\n' | sort -r

But this campaign backdates its mtimes on purpose, so hunt by content:

grep -rlE "gzuncompress|gzinflate|gzdeflate|str_rot13|base64_decode\s*\(|stream_wrapper_register|dyiv://" \
  wp-content/ --include=*.php

grep -rl "Moon, tell me if I could" .   # webshell title string from Patchstack's IoCs
grep -rl "_wplogin" wp-content/

Expect some false positives from legitimate libraries. Prioritise files that are tiny with one huge string, or that combine several of these functions.

6. PHP files in uploads

wp-content/uploads/ should contain media, not code:

find wp-content/uploads -type f \( -name "*.php" -o -name "*.phtml" -o -name "*.php[0-9]" -o -name "*.phar" \) -ls

This matters especially if you ran the File Uploads add-on ≤ 3.3.26 (CVE-2026-0740), which allowed path traversal to place PHP files anywhere, including the webroot. So extend the search beyond uploads too.

7. wp_options: campaign state and autoload injections

-- Campaign-specific options (Patchstack IoCs)
SELECT option_name, LENGTH(option_value) FROM wp_options
WHERE option_name IN ('fz_emer_done_v1','fz_emer_login_tokens');

-- Large or code-like autoloaded options
SELECT option_name, LENGTH(option_value) AS len FROM wp_options
WHERE autoload IN ('yes','on','auto-on','auto')
ORDER BY len DESC LIMIT 30;

SELECT option_name FROM wp_options
WHERE option_value LIKE '%<script%' OR option_value LIKE '%eval(%'
   OR option_value LIKE '%base64_decode%' OR option_value LIKE '%imgcdn1%';

-- Make sure active_plugins matches what you expect
SELECT option_value FROM wp_options WHERE option_name = 'active_plugins';

If fz_emer_login_tokens exists, a magic-login token is live. Deleting the options alone isn't enough, because the mu-plugin can simply issue a new one.

8. Scheduled tasks: WP-Cron and system cron

wp cron event list --fields=hook,next_run_relative,recurrence --skip-plugins --skip-themes
crontab -l -u <site_user>
ls -la /etc/cron.d/ /var/spool/cron/ 2>/dev/null

Patchstack's write-up of this campaign doesn't describe cron persistence, but cron is a common reinfection mechanism in WordPress compromises, so check it whenever you clean up a site. Look for hooks that don't belong to any installed plugin and for crontab lines that curl/wget remote scripts.

9. .htaccess and other server configs

find . -name ".htaccess" -newer wp-config.php -ls
find . -name ".htaccess" -exec grep -lE "auto_prepend_file|AddHandler|AddType.*php|RewriteRule.*https?://" {} \;
find . -name ".user.ini" -o -name "php.ini" | xargs grep -l "auto_prepend_file" 2>/dev/null

auto_prepend_file and handlers that make .jpg or .txt execute as PHP are classic ways to keep a backdoor running.

10. Logs

grep -E "_wplogin=|imgcdn1\.com|/fz/x\.js|wp-smart-thumbnails\.php|emer-run\.php" /var/log/*/access*log*
grep "nf_ajax_submit" /var/log/*/access*log* | grep -iE "script|onerror|textarea"

Any direct hit on wp-smart-thumbnails.php outside normal plugin screens is a signal. Also check which admin accounts were created around the time someone viewed Ninja Forms submissions.

If you find something

Following Patchstack's guidance: remove the rogue accounts and plugins, clear the dropped mu-plugins, delete the fz_emer_* options, rotate all admin passwords and the authentication salts in wp-config.php (wp config shuffle-salts), and review the rest of the filesystem and database. Treat the oldest admin's password as compromised, since the magic link logged in as that account. Then confirm the vulnerable plugin is at 3.15.4+ (and File Uploads at 3.3.27+).


Why server-side scanning beats in-WordPress security plugins

Security plugins are useful, but this campaign shows exactly where they fall short:

  • They run inside the compromised application. A plugin that asks WordPress "who are the admins?" gets the answer after the attacker's pre_user_query filter has rewritten it. Malware that controls the app controls what the app reports.
  • They load in the same process as the malware. mu-plugins load before regular plugins. Anything that loads first can tamper with whatever loads after it.
  • Dormant code stays dormant. The fake plugin's payload deliberately does nothing when WordPress loads it. Runtime checks inside WordPress never see it run. A file scanner reading the bytes on disk does.
  • Per-site obfuscation and backdating defeat simple checks. No shared hash, no recent mtime. Detection has to rely on content, structure and behaviour (packed blobs, stream-wrapper tricks, known webshell traits), looked at across the whole filesystem.
  • Hosting scale. A shared or reseller server can host hundreds of WordPress installs, each with its own plugin set and its own admin who may never log in. Installing, licensing and updating a security plugin on every site, and trusting every one of those sites to stay uncompromised, doesn't scale. A server-level scanner covers every docroot, including the sites nobody has touched in years.
  • It sees beyond WordPress. Webshells in uploads, rogue .htaccess handlers, system crontabs and files outside any WordPress install are all invisible to a plugin and visible to the OS.

That's the design idea behind BitNinja: run protection on the server, not inside the application it's protecting. BitNinja's Malware Scanner watches the filesystem for new and changed files and scans existing content on every site on the box, with quarantine for detected files. Its WAF and IP reputation layers aim to cut down exploitation attempts before they reach PHP at all. None of that replaces patching, but it gives hosting providers a view of what's actually on disk that doesn't depend on each WordPress install telling the truth.


Sources

← All postsPricingSolutions

Exclusive offer for new users only

Don’t let cyber threats haunt your servers! 🎃

Reactivate your BitNinja protection and get 65% off the original list price on all servers for 6 months. No tricks, just treats.

HALLOWEEN65

  1. Step 1 Login/Register
  2. Step 2 Apply Coupon
  3. Step 3 Install/Checkout

Apply the coupon before you enter a card or anything else.

Valid until November 8. Apply under Account > Subscription.

Log In & Claim 65% Off