Working out what you have

A plugin you do not remember installing

There is no patched version to update to, because nothing in this plugin is broken. It does what it was written to do. On a bad day it does not appear on your Plugins screen at all.

Checked against a live WordPress install on .

A plugin with nothing to patch

Most plugin compromises follow a familiar shape. Someone finds a mistake in code that was trying to be useful, the vendor ships a fix, and your job is to install it. This one has no such shape. The plugin was written to give somebody else a way in, it does that correctly, and there is no version of it that behaves differently.

A recent case makes the point plainly. link-factory, recorded as CVE-2026-15413 on 13 August 2026 with a base score of 10.0, is distributed as a "homepage sentence publisher". It mounts a REST API under /wp-json/link-factory/v1/, and every route on it except the health check is gated by a detached Ed25519 signature checked against a public key compiled into the plugin. Whoever holds the matching private key decides what those routes do. The site owner never holds it. We wrote the case up when it landed, in a plugin that is itself the backdoor.

Three consequences follow, and they are why the usual advice fails here. An outdated-version scan finds nothing, because the installed version is the only version. The traffic is well-formed REST or admin-ajax with a valid signature, so it does not look like an attack in a log. And the update button, if the plugin even offers one, updates a backdoor to a newer backdoor.

This is also not a new trick. In 2019 a plugin directory called wpframework shipped a mining binary alongside a PHP file that took commands through two POST parameters. In 2025 a directory called wp-compat, presenting itself as a "WP Compatibility Patch", spent its whole life recreating one administrator account called adminbackup and hiding it. Six years and a different payload apart, both are the same idea with a plugin header on top.

Read the code. Do not run it. Everything on this page can be done with a file listing, a text editor and read-only commands, and none of it requires you to load a page that executes the plugin.

The tells, and what each is worth

No single one of these convicts a plugin. Two together almost always do. Work down the list, because the cheap checks are at the top.

  • You cannot account for it. Weak on its own. Hosts install performance and backup plugins without asking, page builders pull in their own add-ons, and a developer you no longer work with may have left something behind. Treat it as the reason to run the rest of the list rather than as a finding.
  • The name is generic and says nothing. Real names collected from live infections: "WP Compatibility Patch", "Content Delivery Helper", "Database Optimizer", "WordPress Framework", "WP Content Optimizer". They are chosen so that your eye slides past them in a list, and so that the name sounds like something WordPress itself might ship.
  • The header has no author, or claims an author who does not write plugins. Some fakes leave Author URI and Plugin URI empty, which no plugin from the directory does. Others fill them in with something that looks official. The wpframework sample used this header verbatim.
wp-content/plugins/wpframework/wpframework.php php
Plugin Name: WordPress Framework
Plugin URI: http://wordpress.org/#
Description: WordPress Framework
Author: wordpress.org
Version: 1.0
Author URI: http://wordpress.org

WordPress.org hosts plugins. It does not author them, and no legitimate plugin lists it as the author. A Plugin URI ending in # is a field somebody filled in to stop the row looking empty.

  • One file where a real plugin has a directory of them. A working plugin of any size has an includes/ or assets/ directory, a languages folder, something. A directory holding a single PHP file and nothing else is a payload with a header stapled to it. One 2025 family named both the directory and its single file after the victim's own domain, so the path read wp-content/plugins/exampledomain-com/exampledomain-com.php and looked, at a glance, like something the site owner had made.
  • No readme.txt. Every plugin distributed through WordPress.org carries one, because the directory builds its listing page from it. Commercial plugins nearly always ship one too. All nine plugins on the install used for this page had a readme.txt. A directory without one is worth ten minutes of your attention.
  • The version never moves. Version 1.0, or a version that has read the same since the day the directory appeared, with no update ever offered. Nobody is maintaining it because nobody needs to.
  • It was modified last week and its neighbours were not. Plugin directories carry the timestamp of the install or update that wrote them, so they cluster. The odd one out is the one to read.
bash
find wp-content/plugins -maxdepth 1 -mindepth 1 -type d -printf "%TY-%Tm-%Td %p\n" | sort

Timestamps are trivial to forge and attackers who care do forge them, so a normal-looking date proves little. An abnormal one still proves something.

Checking the slug against WordPress.org

The directory name under wp-content/plugins/ is the slug. Open wordpress.org/plugins/<slug>/ in a browser, or ask the API and read the status code.

bash
curl -s -o /dev/null -w "%{http_code}\n" \
  "https://api.wordpress.org/plugins/info/1.2/?action=plugin_information&request%5Bslug%5D=hello-dolly"

A plugin in the directory answers 200 with its metadata. Anything else answers 404 with {"error":"Plugin not found."}. Now the important part, because this is where people convict the wrong file: 404 does not mean malware. Commercial plugins are not allowed in the directory at all. Running that same check against elementor-pro returns exactly the same 404 as running it against link-factory.

What the 404 actually tells you is that WordPress.org cannot vouch for those files, so nobody has published a copy you can compare yours against. If the plugin is one you bought, log into the vendor's site and confirm the version you are running exists there. If it is one nobody in your organisation can name, you have a second tell and it is time to read the code.

The same gap shows up in checksum verification, and it is worth knowing before you trust a clean result. wp plugin verify-checksums skips any plugin with no manifest on WordPress.org and reports it as skipped rather than as a pass. A premium plugin and a planted one are both invisible to it for the same reason. That trap is the subject of is this a real detection or a false positive, which is worth reading before you delete anything on a scanner's word.

When the Plugins screen is lying

Everything above assumes you can see the plugin, and the better families make sure you cannot. WordPress builds the Plugins screen by reading the filesystem with get_plugins() and then passing the result through a filter called all_plugins. In WordPress 7.0.4 that filter is applied in three places, and all three live under wp-admin: the list table at wp-admin/includes/class-wp-plugins-list-table.php, the bulk action handler in wp-admin/plugins.php, and the ajax handler in wp-admin/includes/ajax-actions.php. Any active plugin can hook that filter, unset its own entry, and disappear from the screen while continuing to run on every request.

Samples do this in two styles. Filtering all_plugins is the common one. A 2026 sample took the other route and injected a <style> rule setting display:none!important on its own row, which survives a scanner that watches the filter and looks for missing entries. The same sample decremented the update counts through wp_get_update_data so the numbers in the admin menu still added up.

A neighbouring filter, show_advanced_plugins, controls whether the Must-Use and Drop-ins tabs appear at all. Returning false for the mustuse context removes the tab, and with it any hint that wp-content/mu-plugins contains anything.

Which listings can be lied to

This is the table to keep. The right-hand column is the only thing that matters when you are deciding what to believe.

Where you look What it actually reads Can a running plugin change it
Plugins screen in wp-admin get_plugins() through the all_plugins filter Yes
wp plugin list the same filter. WP-CLI 2.12.0 applies it in its plugin fetcher Yes
wp option get active_plugins get_option(), so the option_active_plugins filter Only from mu-plugins or a drop-in
wp plugin verify-checksums --all a bare get_plugins(), by design No
SELECT on wp_options over SQL the stored row itself No
ls wp-content/plugins the filesystem No

Two rows in that table deserve their reasoning spelled out.

WP-CLI is not automatically safer than the browser. wp plugin list and wp plugin deactivate both resolve plugins through a fetcher that applies all_plugins, so a plugin hiding from your Plugins screen is hidden from those commands too, and deactivating it by name fails with "the plugin could not be found".

The checksum command was written to defeat exactly this. It uses a separate fetcher whose own docblock says it exists so that plugins cannot hide themselves from the checks, and it enumerates with an unfiltered get_plugins(). A plugin that vanished from every other listing still gets counted here.

The four commands that settle it

bash
wp plugin list --fields=name,status,version,update
wp option get active_plugins --format=json
wp plugin list --status=dropin --fields=name,title
wp plugin verify-checksums --all

Run all four, then list wp-content/plugins over SFTP or your host's file manager and compare the four answers against the directory listing and against the Plugins screen. You are looking for any name that appears in one place and not another.

The count is often enough on its own. On the install used for this page, nine directories sit under wp-content/plugins and the checksum run ended with Error: Only verified 8 of 9 plugins (1 failed). Nine on disk, nine counted. Had the screen shown eight, the missing one would be named in the checksum output.

The wp_ prefix on wp_options is a default and yours may differ. Read $table_prefix in wp-config.php before you write any SQL. Note also that a wp db query matching no rows prints nothing at all, so an empty screen there is an answer and not a broken command.

Mismatches point both ways

An entry in active_plugins with no directory behind it is the harmless direction, and you will meet it often. WordPress checks that each active plugin's file exists before loading it and skips the ones that do not, with no warning anywhere. The install used here carries zz-fatal-test/zz-fatal-test.php in active_plugins, has no such directory, and runs normally. That is the residue of a deleted plugin.

The dangerous direction is a directory on disk, listed in active_plugins, absent from the screen. Nothing legitimate produces that combination, so treat it as hostile and go and read the main file.

Two places plugins live that the Plugins screen barely shows

Must-use plugins. Any .php file placed directly in wp-content/mu-plugins loads on every request. It needs no entry in active_plugins, it has no Deactivate link, and if the Must-Use tab has been suppressed you will never see it in the admin. Only the top level of that directory loads: WordPress does not walk subdirectories there, so a file one level down sits inert until something includes it. Live infections have used wp-content/mu-plugins/redirect.php, index.php and custom-js-loader.php for precisely this. The directory is often empty on a clean site, so anything in it is worth naming out loud. The install used for this page had one legitimate file and one stray plugin-loader-injected.php.bak next to it.

Drop-ins. Files like object-cache.php and advanced-cache.php sit in wp-content and are loaded by core directly. wp plugin list --status=dropin lists them. On a clean install with no persistent cache that list is empty, so an unexpected entry is a finding by itself.

bash
ls -la wp-content/mu-plugins/
ls -la wp-content/*.php

Reading the main file

Open wp-content/plugins/<slug>/<slug>.php. That is where the header comment lives and it is nearly always where the whole payload lives too. You are looking for five shapes and you do not need to understand the code around them.

  1. A route that anyone can reach, gated on a secret you do not hold. Search for register_rest_route and read its permission_callback. A callback that returns true, or that compares a request value against a constant defined in the same file, is an open door with a lock only the author has the key to. The same applies to add_action( 'wp_ajax_nopriv_...' ), which registers an endpoint reachable without logging in. In the link-factory case the gate is a signature check against a public key baked into the plugin, which is a lock that looks reassuring and is not yours.
  2. A hardcoded key. A long hexadecimal or base64 constant near the top of the file, compared against something out of the request. One documented family exposed a function called emergency_login_all_admins that logged the caller in as an administrator when an emergency_login parameter matched a password written into the source.
  3. Code fetched from elsewhere and then executed. wp_remote_get, curl_exec or file_get_contents pointed at a URL, with the response reaching eval, assert, call_user_func or a variable function name. A licence check calls one documented endpoint belonging to the vendor whose plugin you are reading and does nothing with the answer except decide whether the plugin runs. Anything that executes the answer is a different thing. Where the payload is packed rather than plain, work through reading eval and base64_decode in a PHP file and decode it offline.
  4. Account creation, and the hiding that goes with it. wp_insert_user, wp_create_user, add_role, set_role or wp_set_auth_cookie. The partner to look for is pre_user_query, which is how the same code keeps its account out of the Users screen. One sample also detached user_register, profile_update and wp_login while it worked, so nothing logged the change, and filtered wp_mail at priority 999 to blank the recipient of any message that named its account.
  5. Writes to disk. file_put_contents, fwrite, copy or rename aimed at wp-content/mu-plugins, wp-content/upgrade, the active theme's header.php or functions.php, or wp-cron.php. This is the persistence layer and it is the reason removal has an order to it.

A sixth shape shows up in the more developed families and it is worth recognising because it explains a scanner that stopped working. Some samples attack the security plugins already installed: deleting their directories with raw unlink and rmdir calls that no WordPress hook can intercept, removing a .user.ini or the auto_prepend_file line that loads a firewall before WordPress, and clearing the scheduled events those plugins rely on. If a security plugin you installed has quietly gone missing, that is a finding and not a glitch.

Reading, decoding and searching are safe. Loading a page that runs the file is not. Nothing in this section requires the plugin to execute.

Removing it by hand

Work over SFTP, SSH or your host's file manager. The admin screens are the one surface the plugin can rewrite, so do not use them for this.

  1. Copy the directory off the server first. Download wp-content/plugins/<slug>/ somewhere local before you change anything. You will want to re-read it after the site is back, and you cannot read what you have deleted.
  2. Read the main file and write down what it names. Usernames and email addresses, option names, cron hook names, file paths it writes to, and any remote host. That list is your checklist for the rest of this procedure. Skipping this step is the single most common reason the infection returns.
  3. Rename the directory rather than deleting it. Add a suffix, for example link-factory.quarantine. WordPress loads an active plugin only after confirming its file exists, and skips the entry silently when it does not, so the site keeps working and WordPress stops loading the plugin the moment you rename. You keep the bytes and you can undo it in one move.
  4. Understand what renaming does not do. It stops WordPress loading the code. It does not stop anyone reaching a file inside that directory directly, because wp-content/plugins/link-factory.quarantine/link-factory.php is still a URL your server will serve and execute. Treat a rename as isolation from WordPress, not as isolation from the internet, and move the directory outside the web root as soon as you have read what you need from it.
  5. Load the front page and wp-admin. A fatal error naming the renamed directory means something else on the site includes its files, which is itself worth finding. Search the codebase for the directory name to see what.
  6. Clear the entry in active_plugins, or leave it. With the directory gone the entry does nothing at all. Tidying it is housekeeping, not remediation, and it is the last thing to worry about.
  7. Empty the persistence layer. List wp-content/mu-plugins/ and read every .php file at its top level. Run wp plugin list --status=dropin --fields=name,title and account for every row. Compare wp-cron.php, wp-config.php, .htaccess and any .user.ini against clean copies. Read the active theme's functions.php and header.php to the bottom, because injected code sits after the last real line.
  8. Check the scheduled events. A hook name you do not recognise is usually the thing that rewrites the file you just removed.
  9. Find the accounts it made. The plugin's whole purpose may have been an administrator that outlives it, and the Users screen is as filterable as the Plugins screen was. An administrator account you did not create covers finding the ones the user list hides and removing them without losing the content attached to them.
  10. Re-run the count. wp plugin verify-checksums --all against the directory listing again, and confirm the number of plugins it counts matches the number of directories on disk.
  11. Delete the quarantined copy after a week, once you are sure nothing legitimate depended on it and you have the local copy.
bash
wp cron event list --fields=hook,next_run_relative
wp user list --role=administrator --fields=ID,user_login,user_email,user_registered
find . -name "*.php" -newermt "-7 days" -printf "%T+ %p\n" | sort

That last command is the one that finds what you missed. Anything written in the days around the infection, outside a cache directory, deserves a look.

Where it came from

A planted plugin arrives in one of three ways, and the fix differs in each. Work out which one you had before you decide you are finished.

You installed it, from somewhere that was not the vendor. A nulled premium plugin or theme is a paid product with its licence check removed and, often enough, something else added in its place. The seller's business model is the addition. This is the same supply route that carries wp-vcd, the nulled theme backdoor, which infects every theme on the site rather than one plugin and has to be removed in a specific order. If you have one nulled component you usually have more, and finding the rest is the work.

A plugin you were right to trust was compromised upstream. In June 2026 tampered JavaScript served from the vendor's own delivery network for OptinMonster, TrustPulse and PushEngage reached around 1.2 million sites. The malicious script fired only when a logged-in administrator loaded a page, created accounts named developer_api1 and dev_xxxxxx, and installed a self-hiding plugin under rotating names including content-delivery-helper and database-optimizer. The window on two of the three products was about twenty-five minutes. You did nothing wrong and you still have a backdoor, so removing the tampered script is not the end of it: the accounts and the planted plugin outlive the incident. A comparable case reached a theme ecosystem through a poisoned API response, which we covered in the BDThemes ecosystem compromise.

Something already on the site wrote it. If you never installed it and no vendor of yours was compromised, the plugin is the output of a backdoor rather than the backdoor itself. Removing it removes a symptom and the site rebuilds it. Work through the malware came back after the cleanup, which is about finding the entry point before cleaning anything.

What to actually close

  • Rotate credentials for every administrator, and rotate the salts. Replacing the eight keys and salts in wp-config.php invalidates every existing session, which matters when you cannot be sure whose browser is still logged in. Do this after the plugin is gone and not before.
  • Stop installations happening from inside WordPress. Setting define( 'DISALLOW_FILE_MODS', true ); in wp-config.php turns off the plugin and theme editors, the installer and the updater in one line. It is a real trade: you lose one-click updates and take on doing them by deployment or by WP-CLI. On a site where nobody installs anything and publishing goes through you, it closes the route the June 2026 campaign used.
  • Install from the directory or the vendor, and nowhere else. The saving on a nulled plugin runs to a few hundred dollars, and the cleanup on this page costs more than that in your own time. Most of what is described here arrives through that one route.
  • Keep a record of what belongs. Save the output of wp plugin list --fields=name,status,version,update somewhere off the site. Comparing against a list you wrote down is faster and more reliable than trying to remember, and it survives a Plugins screen that has started lying.

Questions

The plugin is not on WordPress.org. Does that prove it is malware?
No. Every commercial plugin is absent from the directory as well, because paid plugins are not allowed in it. Checking the slug against api.wordpress.org returns the same 404 for elementor-pro as it does for a known backdoor. Absence tells you the directory cannot vouch for the code, so you now have to. It becomes strong evidence only alongside a second tell, such as no readme.txt, a header with no author, or a directory nobody in your organisation can account for.
The files are on the server but the plugin is not in my Plugins list. What does that mean?
A plugin that is loaded can remove itself from the list, because WordPress builds that screen by passing get_plugins() through the all_plugins filter and any active plugin can hook it. Legitimate code almost never does. Compare the directory listing of wp-content/plugins against the screen, and treat anything that appears on disk and not on screen as hostile until you have read its main file.
Can I just delete the plugin directory?
Delete it at the end of the job. Read the main file before anything else, because it names the accounts, options, scheduled events and dropped files you will otherwise leave behind, and those are what bring it back. Rename the directory instead: WordPress skips an active plugin whose file no longer exists, so the site keeps running and the code stops, and you still have the bytes to read.
I removed it and it came back.
Then something else on the site rewrote it. The usual sources are a file in wp-content/mu-plugins, which loads on every request and cannot be deactivated, a modified wp-cron.php, an injected functions.php in the active theme, or a second administrator account that reinstalls it. One documented family recreated and reactivated its own plugin from wp-cron.php on the next page view after deletion.
Is there an update that fixes this plugin?
No, and looking for one wastes the hour that matters. A vulnerable plugin has a patched version because a mistake was fixed. A plugin written to be a backdoor has no mistake in it. CVE-2026-15413 records exactly this case and lists no fixed version, because there is nothing to fix.
Should I run the plugin to see what it does?
No. Read it. Opening the file in a text editor, decoding an encoded string offline and following what the code would do are all safe. Loading a page that executes it is not, and it is how an investigation becomes a second incident. If the code is packed and you need it readable, decode it in an editor rather than with the PHP that is sitting in the file.

Next

What a scanner does with this

Everything above starts on the filesystem. You compared a directory listing against a screen, read a header, read one file, and moved a directory. Segurium does the reading part on a schedule: every file is hashed and the hash is looked up before any body is read, and around 94% of the files on a site are settled that way, so what actually gets examined is the code nobody has ever published. A plugin directory that exists on your server and nowhere else in the world is exactly that code.

The directory is not the end of the list above. The persistence layer, the scheduled events and the accounts the plugin made are each their own step, and the one you skip is the one that puts it back.