Skip to content
Alex Kovalev

Was My WordPress Site Hacked? 5 Places Attackers Hide

Hidden admin accounts, web shells in uploads, invisible must-use plugins, code that runs before WordPress, and spam only Google can see. Where to look and the exact commands.

Guide9min read
Hieronymus Bosch's The Conjurer as a meme: the conjurer, labelled "ME", shows a trick to a gaping man labelled "YOU", with the WordPress logo on the table between them. Zoom in: the man in glasses at the back lifting a purse is tagged admin_123, and the ball in the conjurer's hand is *.php.
Hieronymus Bosch's The Conjurer as a meme: the conjurer, labelled "ME", shows a trick to a gaping man labelled "YOU", with the WordPress logo on the table between them. Zoom in: the man in glasses at the back lifting a purse is tagged admin_123, and the ball in the conjurer's hand is *.php.

Got a weird redirect to an onion site? A host warning? Perhaps your site is asking you to prove you’re not a robot by pasting a funny terminal command? Maybe the site looks perfectly fine and you just don’t trust it anymore. Either way the question is the same: is somebody else living in this WordPress?

Don’t trust the dashboard to answer that. Malware that has been on a site for a while hides from the admin screens on purpose. That’s the first place you’d look, and the malware knows it. The checks below go around WordPress: straight to the database, the file system, and the server config.

Five places to look, in the order I’d go:

  1. Admins nobody knows
  2. PHP files where they have no business being
  3. Plugins and autoloaded code
  4. Cron jobs and hooks
  5. Spam that only Google gets to see

You’ll want SSH and WP-CLI. Run every command from the site’s root folder, the one with wp-config.php in it. Swap example.com for your own domain. Everything here also works through phpMyAdmin and a file manager; it’s just slower and more clicking.

Almost every command below only reads. The two that change something say so.

1. Hidden administrator accounts

The classic move: create an administrator, then hide it from the other administrators. A few lines hooked into pre_user_query drop that user from the menu, and a second filter fixes the admin counter so the numbers still add up. The dashboard shows two admins. The database has three.

So ask the database. wp db query hands SQL straight to MySQL, and no WordPress hook gets a say. This lists the 20 newest accounts. The first line saves the table prefix (usually wp_), the second puts it into the query:

Terminal window
P=$(wp db prefix)
wp db query "SELECT ID, user_login, user_email, user_registered FROM ${P}users ORDER BY ID DESC LIMIT 20"

The queries below use wp_. If wp db prefix prints a different prefix, use that instead.

Pay special attention to the registration date and time: compare them with working hours and with the Users screen. Red flags:

  • Registered outside working hours, on a weekend or a holiday. Write the date down before you delete the account: it’s often the best clue to when you got in trouble.
  • A login that isn’t on the Users screen.
  • A login you don’t recognize, especially generic ones like wpadmin, support, sysadmin or random letters.
  • An email on a throwaway domain.

Also check application passwords. They let a script log in over the REST API without the user’s normal password and without 2FA, so a stolen one survives a password reset:

Terminal window
wp db query "SELECT user_id FROM wp_usermeta WHERE meta_key = '_application_passwords'"

Empty is fine. Every user ID that does show up should map to an integration you can name.

Why not wp user list --role=administrator? It runs through WordPress, and --skip-plugins still loads must-use plugins, which is exactly where the hiding code likes to live (see check 3).

Panik Kalm Panik meme. Panik: someone logged in as admin at 3 a.m. Kalm: the Users screen shows only my 2 admins. Panik: the database has 3.

2. Web shells and PHP where it doesn’t belong

A web shell is a PHP file that gives whoever finds it a control panel in the browser: browse files, run commands, edit the database. It is usually small, often obfuscated, and named to blend in: wp-cache.php, class-wp-xyz.php, index.php in a folder that never needed one.

Start with the uploads folder. It should hold images, PDFs and the occasional font. It should not hold PHP:

Terminal window
find wp-content/uploads -iname '*.ph*'

It lists every .php, .phtml and .phar file in there. Good result: nothing. A blank index.php with // Silence is golden is fine too; some plugins drop those to block directory listing. Anything longer deserves a look.

Next, make WordPress check its own files against the official release checksums. Core first:

Terminal window
wp core verify-checksums

Good result: Success: WordPress installation verifies against checksums. Bad: File doesn't verify against checksum (a core file was changed) or File should not exist (someone added one).

Then plugins from wordpress.org:

Terminal window
wp plugin verify-checksums --all

Bad: any plugin listed with a changed or extra file. Premium plugins aren’t in that database and get skipped, so check those by re-downloading a clean copy from the vendor and diffing.

Now look at what changed recently:

Terminal window
find . -name '*.php' -ctime -14

It lists PHP files changed in the last 14 days. Bad: files you didn’t touch, especially outside a plugin you just updated.

Use -ctime, not -mtime. Attackers love to touch their files back to 2019, so a week-old shell looks like it shipped with the theme. touch can fake the modification time, but it can’t fake the change time without root access.

Finally, grep for the usual obfuscation:

Terminal window
grep -rlE 'eval\s*\((base64_decode|gzinflate|str_rot13|\$_)' --include='*.php' .

It prints the PHP files that eval decoded text or raw request data. Good result: nothing.

Treat the output as a list of suspects. Plenty of honest plugins call base64_decode. Almost none of them eval it, and none of them eval whatever arrives in a POST request.

3. Plugins the Plugins screen doesn’t show

Two kinds of code load without ever being “activated”:

  • Must-use plugins. Every PHP file directly inside wp-content/mu-plugins/ runs on every request. They can’t be deactivated from the dashboard.
  • Drop-ins. Specific filenames in wp-content/ that WordPress loads by name: advanced-cache.php, object-cache.php, db.php, maintenance.php, db-error.php, php-error.php, fatal-error-handler.php, and sunrise.php on multisite.

The Plugins screen does have Must-Use and Drop-in tabs. One line of malware (add_filter('show_advanced_plugins', '__return_false')) makes both tabs disappear. And a regular plugin can remove itself from the main list with the all_plugins filter, while still loading just fine.

So list the folders, not the screen. Must-use plugins first:

Terminal window
ls wp-content/mu-plugins/

“No such file or directory” is fine: no must-use plugins. Every file that is there should be something your host or a known plugin put there (managed hosts like to add their own).

Then the drop-ins, which sit loose in wp-content:

Terminal window
ls wp-content/*.php

index.php is normal. Every name from the drop-in list above should belong to your caching plugin or your host.

Then the plugins WordPress actually loads, straight from the database:

Terminal window
wp db query "SELECT option_value FROM wp_options WHERE option_name = 'active_plugins'"

The answer is a serialized PHP array, ugly but readable: a:2:{i:0;s:19:"akismet/akismet.php";...}. Every entry should match a plugin you recognize. Then compare it with what’s on disk:

Terminal window
ls wp-content/plugins/

Every folder there should be a plugin you meant to install.

4. Code that runs before WordPress, or on a timer

This is where reinfection lives. You delete the shell, go to bed, and by morning it’s back in the same folder. Something put it back.

auto_prepend_file. PHP can be told to run a file before every script on the site. Look in all the places that setting can hide:

Terminal window
grep -r auto_prepend_file --include='*.ini' --include='.htaccess' .

Good result: nothing. Wordfence sets this on purpose for its firewall (wordfence-waf.php). Anything else pointing at a file you don’t recognize is a problem.

wp-config.php. Open it and read it top to bottom. It should be settings: database credentials, keys and salts, a few define() lines. Watch for include or require of an odd path, a wall of encoded text, or code before the opening comment block.

Every .htaccess, not just the root one. A favorite trick is a redirect that only fires for visitors who arrive from Google, so the owner, typing the address directly, never sees it:

Terminal window
grep -rlE 'HTTP_REFERER|HTTP_USER_AGENT' --include='.htaccess' .

It prints every .htaccess that treats visitors differently depending on where they came from or what browser they use.

Security and cache plugins write these conditions too, so read the rules, don’t just count matches.

Scheduled jobs. Both kinds. WordPress’s own first:

Terminal window
wp cron event list

Bad: a hook with a random-looking name like wp_x9f3k_check that no plugin of yours explains.

Then the server’s, for the user the site runs as:

Terminal window
crontab -l

“No crontab” is fine. Bad: a line that runs curl ... | sh or wget from a domain you don’t know. That’s a reinfection machine.

Surprised Pikachu meme. Me: deletes the web shell. Also me: never checks cron. The shell: is back by morning. Pikachu stares, mouth open.

5. Spam that only Google sees

The SEO spam hack is built so the owner never notices. You get your normal pages. Googlebot gets the same site with thousands of pharmacy and casino pages bolted on. Your visitors only meet it when they click a search result.

Check what the outside world sees:

  1. Search Google for site:example.com and scroll. Pages in a language you don’t publish, or URLs that look like /?p=cheap-..., mean trouble.
  2. Open Security & Manual Actions in Google Search Console. Google will often tell you before your client does.
  3. Ask your own server for a page while pretending to be Googlebot. This prints only the lines that differ between what you get and what Googlebot gets:
Terminal window
diff <(curl -sL https://example.com/) <(curl -sL -A Googlebot https://example.com/)

Then the same for a visitor arriving from a Google result:

Terminal window
diff <(curl -sL https://example.com/) <(curl -sL -e https://www.google.com/ https://example.com/)

A few changed lines are normal: nonces and timestamps differ on every load. Bad: links, scripts or a whole different page that only one side gets. (Cleverer malware checks Google’s real IP ranges, so a clean result here is a good sign, not proof.)

Then look inside the database for injected scripts:

Terminal window
wp db search '<script' wp_posts wp_options

It shows every <script tag stored in posts and settings.

Some hits are legitimate: analytics snippets, chat widgets, a page builder’s stored HTML. You’re looking for scripts loading from domains you’ve never heard of, or String.fromCharCode and atob( doing their best to hide a URL.

While you’re in wp_options, confirm the two addresses WordPress believes it lives at:

Terminal window
wp db query "SELECT option_name, option_value FROM wp_options WHERE option_name IN ('siteurl', 'home')"

Both should be your own domain. Same reason as before: plain SQL, so a must-use plugin can’t filter the answer.

Three doors bots knock on, hacked or not

These aren’t signs of a breach. They’re how a clean site collects junk, hands bots half of what they need to log in, or leaves its database password lying around. Check them while you’re here.

Comment spam that never touches your form

Bots don’t fill in your comment form. They skip the page and POST straight to the endpoint the form sends to, so a captcha or honeypot that only lives in the form’s JavaScript never runs.

There are two endpoints. The classic one, wp-comments-post.php, accepts anyone when comments are open. The REST one, /wp-json/wp/v2/comments, refuses anonymous comments by default. Make sure yours still does. This is one of the two commands that can change something: if the endpoint is open, it posts a real test comment.

Terminal window
curl -s -d 'post=1&author_name=test&author_email=test@example.com&content=test' https://example.com/wp-json/wp/v2/comments

The answer you want is a 401 with "code":"rest_comment_login_required". If it creates a comment instead (go delete it), something switched on the rest_allow_anonymous_comments filter. Find out what, and turn it off unless you know why it’s there.

Then see how much has already landed:

Terminal window
wp comment count

It prints how many comments are approved, waiting, and in spam. Thousands waiting means the bots found you.

Next, list the approved ones that contain a link:

Terminal window
wp comment list --status=approve --search=http --fields=comment_ID,comment_author,comment_author_url

Spam links that got approved are SEO spam too, just the low-effort kind.

The fixes:

  • Not using comments? Close them in Settings → Discussion for new posts, close them on old ones too (this one changes data: wp db query "UPDATE wp_posts SET comment_status = 'closed'"), and block the endpoint at the server, the same way the checklist blocks xmlrpc.php.
  • Using them? Put a server-side filter in front, like Akismet or Antispam Bee. They check the request itself, so they catch bots that never loaded the page.

Admin usernames handed out for free

WordPress gives your usernames away in at least five places, and password-guessing bots read all of them before they start. Try them on your own site:

Terminal window
# REST API: look for "slug" - one per user who has published a post
curl -s https://example.com/wp-json/wp/v2/users
# Author archives: the "location:" line ends in /author/<username>/
curl -sI 'https://example.com/?author=1'
# RSS feed: the author's display name on every post
curl -s https://example.com/feed/ | grep creator
# oEmbed: "author_name" and "author_url" for any page
curl -s 'https://example.com/wp-json/oembed/1.0/embed?url=https://example.com/'
# Core sitemap (WordPress 5.5+): one author archive URL per user
curl -s https://example.com/wp-sitemap-users-1.xml

Each of these is a plain read. You can also just open the URLs in a browser.

The slug and the /author/ part of the URL are the user’s user_nicename. WordPress makes that from the login, so for most sites it is the login. If the feed shows a display name equal to the login too, every route gives the same answer.

A username isn’t a secret, and hiding it doesn’t replace a strong password and 2FA. It does take away half of every guess, and it costs five minutes:

  • Give each admin a nickname and display name that isn’t their login (Users → Profile → Display name publicly as).
  • Change the nicename, which the profile screen doesn’t show: wp user update 1 --user_nicename=alex-k.
  • Hide /wp/v2/users from logged-out visitors with your security plugin, as in the checklist’s REST API section.
  • Drop the users sitemap if you don’t need author pages in search:
add_filter( 'wp_sitemaps_add_provider', function ( $provider, $name ) {
return 'users' === $name ? false : $provider;
}, 10, 2 );

The WordPress security check tries the first two from the outside and lists any usernames it gets back.

Files you didn’t mean to publish

Your web server sends .php files to PHP and serves everything else as a plain download. wp-config.php is safe because it runs instead of being shown. Make a copy called wp-config.php.bak and it’s a text file with your database password in it, one request away.

Drake meme from the attacker’s side. Drake turns away from wp-config.php, which PHP runs and shows nothing. Drake points approvingly at wp-config.php.bak, plain text with the database password inside.

Copies like that appear without anyone deciding to make them:

  • Text editors leave them behind: nano writes wp-config.php.save when it crashes, vim keeps .wp-config.php.swp while a file is open, emacs saves wp-config.php~.
  • A quick “let me back this up first” makes wp-config.php.bak, wp-config.old, or backup.sql in the web root.
  • define('WP_DEBUG_LOG', true) writes every PHP error to wp-content/debug.log, with server paths, plugin names, and sometimes the data that caused the error.
  • Deploying with git pull leaves a .git folder. If the server serves it, anyone can download your code and its whole history, including any password that was ever committed.

Bots ask for these exact names on every site they scan. Ask first:

Terminal window
for f in wp-config.php.bak wp-config.php.save wp-config.php~ .wp-config.php.swp wp-config.old backup.sql wp-content/debug.log .git/HEAD .env; do echo "$(curl -so /dev/null -w '%{http_code}' https://example.com/$f) $f"; done

It prints a status code next to each name. Anything that isn’t 403 or 404 needs a look. Some themes answer missing pages with a 200, so open the URL before you panic: .git/HEAD starts with ref: refs/heads/, and a config backup starts with <?php.

That list only covers the usual names. On the server, look for the rest:

Terminal window
find . -maxdepth 3 | grep -E '\.(sql|gz|zip|bak|old|save|swp|log)$|~$'

Good result: nothing. Bad: a database dump, an archive of the site, or a log sitting where a browser can reach it.

And the deploy leftover:

Terminal window
ls -d .git

“No such file or directory” is what you want.

The fixes:

  • Move backups and dumps out of the web root, or delete them.
  • Keep the debug log, but outside the site: define('WP_DEBUG_LOG', '/home/user/logs/wp-debug.log'); works since WordPress 5.1. Leave it off on production when you’re not debugging.
  • Block dotfiles and backup extensions at the server, as a net for next time. For Apache, in the root .htaccess:
RedirectMatch 404 /\.(?!well-known)
<FilesMatch "\.(bak|old|orig|save|swp|sql|log)$|~$">
Require all denied
</FilesMatch>

The first line hides .git, .env and every other dotfile but leaves .well-known alone, because Let’s Encrypt needs it to renew your certificate. On nginx, location ~ /\.(?!well-known) { deny all; } does the same job.

If a wp-config copy was public, block it and then assume someone already has it. Change the database password and rotate the salts.

Found something?

It’s almost never just one thing. A hidden admin usually came with a shell, and the shell usually came with a cron job to put it back. Deleting the file you found and calling it a day is how the site gets reinfected on Thursday.

The short version of cleanup:

  • Change every password: WordPress users, database, hosting panel, FTP/SFTP, and SSH.
  • Rotate the salts so every stolen login cookie dies at once: wp config shuffle-salts.
  • Revoke application passwords you can’t account for.
  • Find the way in. Check your web server’s access logs around the user_registered date of the rogue admin and the change times of the shell files. It’s usually an outdated plugin.
  • When in doubt, restore from a backup older than the earliest evidence, then update everything before putting it back online.

And once it’s clean, the WordPress security check shows what a bot sees from the outside, and the security checklist covers the boring fixes that keep the next one out.

Comments

Comments live in GitHub Discussions (via giscus), so you need a GitHub account to join in.

I’ll chirp when there’s a new post

One email per new post, usually on a Friday. Nothing else, and you can unsubscribe anytime.

Prefer a feed reader? There’s an RSS feed.

© Alex Kovalev. All rights reserved. · Privacy Policy