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.
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 URIandPlugin URIempty, which no plugin from the directory does. Others fill them in with something that looks official. Thewpframeworksample used this header verbatim.
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/orassets/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 readwp-content/plugins/exampledomain-com/exampledomain-com.phpand 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.
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.
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.
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.
- A route that anyone can reach, gated on a secret you do not hold.
Search for
register_rest_routeand read itspermission_callback. A callback that returnstrue, 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 toadd_action( 'wp_ajax_nopriv_...' ), which registers an endpoint reachable without logging in. In thelink-factorycase 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. - 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_adminsthat logged the caller in as an administrator when anemergency_loginparameter matched a password written into the source. - Code fetched from elsewhere and then executed.
wp_remote_get,curl_execorfile_get_contentspointed at a URL, with the response reachingeval,assert,call_user_funcor 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. - Account creation, and the hiding that goes with it.
wp_insert_user,wp_create_user,add_role,set_roleorwp_set_auth_cookie. The partner to look for ispre_user_query, which is how the same code keeps its account out of the Users screen. One sample also detacheduser_register,profile_updateandwp_loginwhile it worked, so nothing logged the change, and filteredwp_mailat priority 999 to blank the recipient of any message that named its account. - Writes to disk.
file_put_contents,fwrite,copyorrenameaimed atwp-content/mu-plugins,wp-content/upgrade, the active theme'sheader.phporfunctions.php, orwp-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.
- 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. - 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.
- 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. - 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.phpis 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. - 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.
- 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.
- Empty the persistence layer. List
wp-content/mu-plugins/and read every.phpfile at its top level. Runwp plugin list --status=dropin --fields=name,titleand account for every row. Comparewp-cron.php,wp-config.php,.htaccessand any.user.iniagainst clean copies. Read the active theme'sfunctions.phpandheader.phpto the bottom, because injected code sits after the last real line. - Check the scheduled events. A hook name you do not recognise is usually the thing that rewrites the file you just removed.
- 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.
- Re-run the count.
wp plugin verify-checksums --allagainst the directory listing again, and confirm the number of plugins it counts matches the number of directories on disk. - Delete the quarantined copy after a week, once you are sure nothing legitimate depended on it and you have the local copy.
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.phpinvalidates 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 );inwp-config.phpturns 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,updatesomewhere 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.