Working out what you have

There is an administrator account you did not create

The Users screen can lie about this, because the code that made the account can filter it back out of that list. One SQL query settles what accounts exist. Then comes the part that keeps the site: something wrote that row, and it is still on your server.

Checked against a live WordPress install on .

What the account tells you

You noticed it one of four ways. A name you do not recognise sits in Users. An email arrived saying a new user registered. The Administrator count is higher than the number of rows you can see. Or you found it the slow way, after the site started redirecting and you went looking.

Whichever it was, one thing is already true. At the moment that account appeared, somebody could write to your database. There are only two routes to that, and they lead to different work.

  • Something ran PHP on your server. Some code called wp_insert_user(), or wrote the two rows itself. The file holding that code is still on disk unless it deleted itself, and it is the thing you have to find.
  • Something reached the database without running your PHP. An SQL injection in a plugin, or a script that ran inside a logged-in administrator's browser and used their own session to create the account. Nothing is added to the filesystem, so a file search comes back clean and the version number of one plugin is the whole story.

Published campaigns give you a feel for the names, and the names are the least reliable thing here. WPScan reported administrator accounts called wpsupp-user and wp-configuser created through CVE-2023-40000 in LiteSpeed Cache. The indicator published for CVE-2024-27956 in WP Automatic was administrator usernames starting with xtw. Sucuri has published license_admin2, help, and mr_administartor, misspelling included. Wordfence found a backdoor creating superadmin.

Do not use that as a checklist. Campaigns change usernames within days, and plenty of them pick a name that reads like a real member of staff. The signal is an account nobody can explain, created at a moment nobody can explain. The name confirms what you already suspect and nothing more.

Rule out the boring explanations

Several honest things create administrator accounts and none of them announce it. Spend five minutes here before you delete anything, because deleting your host's management account is a phone call you do not want to make at 2am.

  • Your host. One-click WordPress dashboards, staging tools and support sessions all create their own administrator, often named after the hosting brand. Some create it every time you clone the site.
  • A migration or backup plugin. Restoring a site brings the source site's accounts with it, including the ones you forgot the source site had.
  • A person. A developer, an agency, a freelancer, or you, six months ago, making a temporary account for someone and never removing it. Ask before you assume.
  • A demo import. Some theme demo importers create an author account so the imported posts have somewhere to hang.

Start with the list of administrators and their registration dates.

bash
wp user list --role=administrator --fields=ID,user_login,user_email,user_registered

Then check whether the front-end registration form could have done it. Two options settle that. On a stock install they read 0 and subscriber.

bash
$ wp option get users_can_register
0
$ wp option get default_role
subscriber

A 0 means the public form is switched off, so nobody signed up. A 1 means people can sign up, and the role they get is whatever default_role says. If that second command answers administrator, stop reading and fix it: the form has been handing out full control to anyone who filled it in, and every account it created is an administrator. That is the whole explanation and you have just found it.

Now read user_registered against everything else you know. When did the site start redirecting? When did the host email you? What is the newest modified time under wp-content? An account whose registration date lands in the same hour as a file nobody edited is the one. One caution: that column holds whatever was written into it, and code that wrote the row directly could have written any date, including a blank one. A plausible date proves little. An impossible date proves plenty.

Read the users table directly

The Users screen is drawn by WordPress, which means WordPress code decides what appears on it. Two hooks are enough to remove an account from that list completely, and both ship with core for legitimate reasons.

pre_user_query fires inside WP_User_Query::prepare_query() in wp-includes/class-wp-user-query.php, and it has been there since WordPress 3.1. It hands the query object over by reference after the SQL has been assembled and before it runs. Appending one condition to the WHERE clause removes a row from every user listing on the site, in the dashboard and everywhere else.

That leaves a tell. The numbers in the links above the table (All (4), Administrator (2)) do not come from that query. They come from count_users() in wp-includes/user.php, which builds its own SQL against the capabilities rows in wp_usermeta joined to wp_users. A filter on pre_user_query cannot touch it, so a naive hide leaves a count that is one too high. Malware that has read this far filters views_users as well, which is the generic list-table views filter named after the current screen, and rewrites the numbers in the HTML. Since WordPress 5.1 there is also pre_count_users, which returns a fabricated array and skips the counting query outright.

On a site with a very large number of users WordPress stops printing those counts at all, to avoid the expensive query. The count-mismatch check is not available to you there.

WP-CLI is filtered too

wp user list builds the same WP_User_Query the dashboard uses, so one filter hides the account from both. This was measured rather than assumed. One line of test code in wp-content/mu-plugins/, hooked to pre_user_query and excluding a single ID, produced the following on WordPress 7.0.4 with WP-CLI 2.12.0:

bash
$ wp user list --fields=ID,user_login
ID	user_login

$ wp user list --skip-plugins --skip-themes --fields=ID,user_login
ID	user_login

$ wp db query "SELECT ID, user_login FROM wp_users;"
ID	user_login
1	admin

The column header prints and no rows follow. --skip-plugins does not rescue you, because it filters the active plugins option and everything in wp-content/mu-plugins/ loads regardless of that option. The last command works because wp db query hands the statement to the MySQL client, so no PHP filter is in the path. Your host's phpMyAdmin or Adminer works for the same reason.

The two queries that settle it

sql
SELECT ID, user_login, user_email, user_registered FROM wp_users ORDER BY ID;

SELECT user_id, meta_key, meta_value FROM wp_usermeta WHERE meta_key = 'wp_capabilities';

Run them through wp db query, or paste them into whatever database tool your host gives you.

bash
$ wp db query "SELECT user_id, meta_key, meta_value FROM wp_usermeta WHERE meta_key = 'wp_capabilities';"
user_id	meta_key	meta_value
1	wp_capabilities	a:1:{s:13:"administrator";b:1;}

Two things about that command before you use it. The wp_ in wp_users, wp_usermeta and wp_capabilities is only the default table prefix. Read the $table_prefix line in wp-config.php and use whatever it says, including in the meta key, because WordPress builds that key from the prefix. And note that wp-config.php is not always where you expect. On the install these commands were run against, it sat one directory above the document root. And a query matching no rows prints nothing at all, not even the header. Zero output means zero rows, and it means the command worked.

Reading the capabilities value

That meta_value is PHP's serialisation format, and it is readable once you know three letters. a:1 is an array with one element. s:13 is a string of 13 bytes, which is the length of the word administrator. b:1 is the boolean true. So the row says: one role, called administrator, granted. An account with the editor role carries s:6 and the word editor in the same shape, because editor is six bytes long. WP-CLI will print the same thing unserialised if you prefer:

bash
$ wp user meta get 1 wp_capabilities
array (
  'administrator' => true,
)

Then check the trick that survives all of this. Sucuri documented it in December 2018 and it still works: instead of giving the account the administrator role, edit the wp_user_roles option so the subscriber role carries administrator capabilities. The account reads as a subscriber in every list, the Administrator count never moves, and it can still do anything. Two commands catch it.

bash
$ wp cap list subscriber
read
level_0

$ wp db query "SELECT option_name, LENGTH(option_value) AS len FROM wp_options WHERE option_name = 'wp_user_roles';"
option_name	len
wp_user_roles	3133

A stock WordPress 7.0.4 subscriber has exactly those two capabilities, and the five default roles serialise to 3,133 bytes. Anything much larger, or a role in wp role list whose name you do not recognise, is worth reading in full before you go any further.

Where the code that made it lives

The account is a row. Something wrote it. Until you find that something, every deletion you perform is temporary.

A file that runs on every request

This is the case that decides your cleanup order. Sucuri published one in March 2022: two lines added to wp-content/themes/twentytwentyone/functions.php that created an administrator called user, and because a theme's functions.php loads on every page view, the account came back every time anyone visited the site. Look in these places, in this order:

  • wp-content/mu-plugins/*.php. Must-use plugins load with no activation step, they cannot be deactivated from the dashboard, and most site owners have never opened that directory. It is where a hiding filter belongs and it is where you should look first.
  • The active theme's functions.php, and the parent theme's if you run a child theme. Injected code sits at the very top before the opening comment, or at the very bottom after the last closing brace.
  • A plugin that exists only to do this. Published examples: wp-content/plugins/php-ini.php, wp-content/plugins/DebugMaster/DebugMaster.php, wp-content/plugins/wp-engine-fast-action/, and a file that turned up as both WPCache.php and wp-seo-conf.php. The last one removed itself from the active plugins list, so it never appeared on the Plugins screen at all. A plugin that is itself the backdoor covers how to recognise one and remove it safely.

Wordfence documented a variant in April 2025 that is worth knowing about because it changes your cleanup order. The plugin, WP-antymalwary-bot.php, carried a function called emergency_login_all_admins that logged an attacker in as any administrator given a hardcoded password in the URL, and another called execute_admin_command reachable over the REST API with no permission check. Deleting the plugin was not enough: a modified wp-cron.php wrote it back the next time somebody loaded the site.

A dropper somewhere PHP should never run

A shell uploaded through a file-upload flaw usually lands in the uploads directory, and a few PHP files there are enough to create accounts on demand for months.

bash
find wp-content/uploads -name "*.php"
find . -name "*.php" -newermt "-7 days" -printf "%T+ %p\n" | sort

The first command should print nothing on most sites. Anything it does print needs an explanation before you go further, and PHP files in wp-content/uploads covers which of them are legitimate and how to stop the directory executing PHP at all. The second orders every recently changed PHP file by time, which brackets the compromise even when the attacker cleaned up after themselves.

Grep for the calls this particular family needs. Every one of these is also legitimate code in some real plugin, so a hit is a file to read rather than a verdict.

bash
grep -rlE --include="*.php" "wp_insert_user|wp_create_user|->add_cap\(|->set_role\(" wp-content/
grep -rlE --include="*.php" "pre_user_query|views_users|pre_count_users" wp-content/
grep -rlE --include="*.php" "eval\(|gzinflate|str_rot13|assert\(" wp-content/

Read what you find. Do not run it to see what it does. Decoding an obfuscated block in a text editor is safe and it is the whole job; executing it is how an investigation becomes a second incident.

No file, because there never was one

Sometimes the search comes back empty and that is the answer. Two published cases show the shape.

CVE-2024-27956 in the WP Automatic plugin, versions up to and including 3.92.0, let an unauthenticated request run arbitrary SQL against the database. Patchstack published it on 13 March 2024 at CVSS 9.9 and it was fixed in 3.92.1. Attackers used it to insert administrator rows directly, and the indicator that circulated was usernames beginning xtw. No PHP of theirs ever touched the disk.

CVE-2023-40000 in LiteSpeed Cache, fixed in 5.7.0.1, was a stored cross-site scripting flaw. The payload sat in the database, in the litespeed.admin_display.messages option, and it ran in a logged-in administrator's browser, using that administrator's own session to create wpsupp-user or wp-configuser. Again, nothing to find on disk.

When the filesystem is clean, go through your plugin and theme versions against their published advisories, and read the largest autoloaded options for injected script:

bash
wp plugin list --fields=name,status,version,update
wp db query "SELECT option_name, LENGTH(option_value) AS len FROM wp_options WHERE autoload IN ('yes','on','auto-on','auto') ORDER BY len DESC LIMIT 20;"

Match four values, not one. WordPress 6.6 added on, auto-on and auto to that column, and anything created since then carries one of the new ones. On a stock 7.0.4 install the split was 139 rows on yes against 25 on the newer spellings, so autoload = 'yes' reads clean while skipping them.

Remove it without losing content

Order matters. Remove the code first, then the account. Do it the other way round on a site with an injected functions.php and the next visitor recreates the account while you are still reading the confirmation message.

  1. Write down what you are about to delete. ID, login, email, registration date, and the capabilities row. Copy the output of both queries from the previous section into a file. You will want the email address and the date when you go through the access log, and once the rows are gone they are gone.
  2. Remove the code you found. The mu-plugin, the fake plugin directory, the dropper. For an injected block inside a file you legitimately need, such as your theme's functions.php, cut out the injection and keep the file: cleaning a file that has no clean copy anywhere goes through that edit line by line.
  3. Pick the account the content moves to. Usually your own. Note its ID.
    bash
    wp user list --fields=ID,user_login,user_registered,roles
  4. Delete with reassignment. The --reassign flag is the difference between losing posts and keeping them.
    bash
    wp user delete 7 --reassign=1
    Without it, wp_delete_user() collects every post type whose delete_with_user flag is true, which covers posts and pages, and deletes those posts along with the account. If the rogue account never published anything the flag costs you nothing, so pass it every time. On multisite, plain wp user delete only removes the user from the current site; --network removes the row from the database.
  5. If you have no shell, delete the rows by hand. You cannot use the Users screen for an account a filter is hiding, because you cannot tick a checkbox you cannot see. In phpMyAdmin or Adminer, both rows have to go. Deleting the wp_users row alone leaves orphaned meta behind; deleting the meta alone leaves a login that still works with no capabilities attached.
    sql
    DELETE FROM wp_usermeta WHERE user_id = 7;
    DELETE FROM wp_users WHERE ID = 7;
    Prefer the WP-CLI route when you have it. It reassigns the content, clears the caches and fires the hooks other plugins listen to. Raw SQL does none of that.
  6. Reset the roles if the capabilities were edited. If wp cap list subscriber returned more than read and level_0, put the default roles back. Custom roles your own plugins registered survive this.
    bash
    wp role reset --all
  7. Force new passwords on the accounts you keep. Assume every administrator password on the site is known.
    bash
    wp user reset-password 1 --skip-email --show-password
  8. Destroy the sessions, because the password change did not. This catches people out. Setting a password writes the new hash and leaves the session_tokens row in wp_usermeta exactly as it was, so a cookie the attacker is already holding keeps working. The two core functions that destroy sessions only ever act on the user who calls them, which is nobody when you are on the command line.
    bash
    wp user session destroy 1 --all
    Or invalidate every login cookie on the site in one move by shuffling the salts. Everyone gets logged out, including you.
    bash
    wp config shuffle-salts
  9. Rotate the database password. If anything read wp-config.php, and a shell in the uploads directory can, then the database credentials are the attacker's too. Change them at the host and update wp-config.php.
  10. Check again tomorrow. Run the two SQL queries a day later, and after the next time the site gets real traffic. An account that returns means the code is still there and you removed a copy rather than the original.

Close the way it got in

Everything above removes a symptom. If you stop here you will do it again next week, and the malware came back after the cleanup is the page you will be reading when you do.

Update the plugin that let it happen. Both the CVEs named earlier were fixed before the attacks peaked, and the sites that got hit were the ones running the old version. Compare what you have against what is current, and read the changelog of anything that is behind rather than just clicking update:

bash
wp plugin list --fields=name,status,version,update

Turn off what you do not use. If your site has no reason to accept public signups, the option should read 0. If it has a reason, the default role must be the smallest one that works. Neither of these stops a determined attacker, and both remove the easiest possible route.

bash
wp option get users_can_register
wp option get default_role

Give yourself a way to notice. WordPress will not tell you that a new administrator appeared. The nearest thing to an alarm you can build without a plugin is to save the output of the two queries from earlier, run them again on a schedule, and diff the results. Any new row in that output needs an explanation.

Turn on two-factor authentication for every account that stays. A stolen administrator password produces exactly the symptom this page is about, with no vulnerability involved and no file to find. That case looks identical from the database and it is only ruled out by the access log, so make the password insufficient on its own.

Stop the uploads directory executing PHP. It is a few lines of server configuration on Apache or nginx, and it turns an uploaded PHP dropper into a file that sits there doing nothing. The uploads folder page has the exact rules for both servers.

Questions

Can malware hide an account from WP-CLI as well as from the dashboard?
Yes. The wp user list command builds the same WP_User_Query the dashboard uses, so a filter on pre_user_query hides the account from both. Tested on WordPress 7.0.4 with WP-CLI 2.12.0: a one-line mu-plugin adding a condition on that hook made wp user list print its column header and no rows at all, and --skip-plugins --skip-themes did not change it, because that flag skips the active plugins list and mu-plugins load regardless. The same install answered wp db query with the row, because that command hands the SQL to the MySQL client and no PHP filter ever sees it.
The Users screen says Administrator (3) but I count two. What is that?
That is a partial hide. The rows come from a user query and the numbers in those links come from count_users(), which runs its own SQL against the capabilities rows in usermeta. Filtering the query does nothing to the count, so a row disappears and the number stays. Malware that knows this filters views_users too and rewrites the numbers. Treat the mismatch as proof and go to the database. On a site with a very large number of users WordPress stops printing the counts entirely, so this check simply does not exist there.
The account shows as a Subscriber but it can do everything. How?
Someone edited the wp_user_roles option so the subscriber role carries administrator capabilities. Every subscriber on the site then has full control, the account reads as harmless in the Users screen, and the Administrator count never moves. Sucuri documented this in December 2018 and it still works. Compare wp cap list subscriber against read and level_0, which is all a stock subscriber has, and check wp role list for a role name you do not recognise.
Will deleting the user delete their posts?
It can. wp_delete_user() with no reassignment collects every post type whose delete_with_user flag is true, which includes posts and pages, and deletes them. Pass --reassign with the ID of an account you keep and the content moves instead. If the rogue account never wrote anything the flag costs you nothing, so pass it either way.
I changed every password. Am I done?
No. Changing a password from the command line writes the new hash and leaves that user's session tokens in place, so a cookie the attacker already holds keeps working. The two core functions that destroy sessions only ever act on the user running them. Destroy the sessions explicitly for every account you keep, and shuffle the salts in wp-config.php, which invalidates every login cookie on the site at once.
Do I remove the account first or the file first?
The file first. Sucuri published a case in March 2022 where two lines in a theme's functions.php recreated an administrator called user on every single page view. Delete that account and the next visitor puts it back. Find and remove the code, then delete the account, then reload the site and check that the account stayed gone.

Next

What stops the account coming back

Something recreates this account, and most of the time it is a file. The mu-plugin holding the hiding filter, the fake plugin directory, the dropper in uploads, the block someone pasted into your theme's functions.php: any of those puts the user back after you delete it. Segurium fingerprints every file under the install, and the files it stops to read are the ones nobody has ever published. A dropper is exactly that. Where the malicious code sits inside a file you actually need, it removes the injected part and writes the rest back, so a functions.php you spent two years on survives the cleanup.

Then do the user-table work above, in order and to the end. It holds once whatever created the account is gone: the file if there was one, and the stolen password if closing the way in is the part that applies to you.