— Article — № 019

019 —Security

Spotting a hacked WordPress install: a 5-minute audit

You have FTP, the database password, and maybe five minutes before the client wakes up. Here is the audit that gets you to a verdict, in roughly that order.

Brass machinist calipers on cream paper template with pencil tick marks and red wax checkmark, dark oak surface.
Hero · staged still№ 019

The Slack ping lands at 23:41. A client's WordPress site is throwing a Google Safe Browsing warning, the agency lead is on a flight at 06:00, and you have the FTP creds and the database password sitting in 1Password. You don't have time to clone the site locally. You have maybe five minutes before the client texts.

This is the audit I run, in roughly this order, on any WordPress site I suspect is compromised. It assumes shell or FTP access, MySQL access, and nothing else. No Wordfence subscription, no malware scanner running, no fresh backup. Just the live install and a terminal.

The two files attackers touch first

Start with the file system. In my own clean-up work, the vast majority of WordPress compromises leave traces in two places: wp-config.php and .htaccess. Read both. Don't grep them, read them top to bottom.

In wp-config.php you're looking for anything above the opening <?php tag (PHP that runs before the standard config), anything that requires a file outside the WordPress install, or any base64_decode, gzinflate, eval, or str_rot13 calls. A clean wp-config has none of those. If you see a one-line obfuscated payload near the top of the file, you have your answer.

In .htaccess, the signature is usually a conditional RewriteRule that fires only for search-engine referrers or specific user agents. The classic version looks like this:

RewriteCond %{HTTP_REFERER} (google|bing|yahoo|yandex) [NC]
RewriteCond %{HTTP_USER_AGENT} !(bot|crawl|spider) [NC]
RewriteRule .* http://malicious-domain.tld/redirect.php [R=302,L]

The site looks fine when you visit it directly. Only visitors arriving from a search result get redirected. That asymmetry is the whole point of the attack: it keeps the site looking healthy to the owner while monetising the SEO traffic. Apache's mod_rewrite documentation covers the conditional syntax if you want to confirm what each directive does.

Four SQL queries against the live database

Now the database. Open MySQL and run these four queries against the WordPress schema. Each one targets a different attacker persistence trick.

1. Admin users you didn't create

SELECT ID, user_login, user_email, user_registered
FROM wp_users
ORDER BY user_registered DESC
LIMIT 20;

Look for accounts registered in the last 30 days that nobody on the team remembers creating. Cross-reference with wp_usermeta for users whose wp_capabilities meta value contains administrator. Attackers often create a second admin so that revoking the first one (or rotating the original admin password) doesn't lock them out.

2. Active plugins and scheduled tasks

SELECT option_value FROM wp_options WHERE option_name = 'active_plugins';
SELECT option_value FROM wp_options WHERE option_name = 'cron';

Both results are serialized PHP arrays. Skim the active plugins list. Anything you don't recognise, anything with a generic name like wp-cache-helper or seo-tools, anything in a directory that doesn't match a plugin on the official WordPress.org plugin directory, gets a hard look. Then check wp-content/plugins/ on disk: most legitimate plugins ship with a readme.txt, a languages folder, a build directory. A single anonymous PHP file in its own slug folder is a red flag.

The cron option lists every scheduled hook WordPress knows about. Attackers love wp-cron because it lets them schedule a payload that fires hourly with no visible admin UI. Hook names like wp_update_check_v2 or anything you can't trace to a plugin you recognise, treat as suspicious until proven otherwise.

3. Injected post content

SELECT ID, post_title FROM wp_posts
WHERE post_content LIKE '%<iframe%'
   OR post_content LIKE '%display:none%'
   OR post_content LIKE '%eval(base64%';

Spam link injections often live inside published posts, hidden behind a display:none div or an off-screen iframe. The post reads fine to a human visitor; only search engines index the hidden anchor text.

The mu-plugins folder and the uploads directory

Two folders WordPress will execute code from that most owners forget about: wp-content/mu-plugins/ and (when misconfigured) wp-content/uploads/.

Must-use plugins load automatically with no entry in the admin UI. There is no "deactivate" button. If mu-plugins/ exists and you didn't put a file in it, read every PHP file in there. It's the cleanest persistence mechanism an attacker can pick.

The uploads directory should only contain media. If you find a .php file under wp-content/uploads/, something is wrong. Run this from the WordPress root over SSH:

find wp-content/uploads -name "*.php" -type f

A clean install returns nothing. Any output is either a vulnerability (an upload form that didn't validate file extension) or an already-placed web shell. As a stopgap, drop an .htaccess file into the uploads directory containing php_flag engine off, then investigate the entry point.

Core file integrity in 30 seconds

If WP-CLI is on the server, this one command tells you whether any core file has been modified versus what shipped from WordPress.org:

wp core verify-checksums

The command compares every file in wp-admin/ and wp-includes/ against the official checksums. A clean install reports Success: WordPress installation verifies against checksums. A compromised one lists the exact files that don't match. That output, combined with what you found in wp-config.php and .htaccess, is usually enough to know whether you're cleaning up or rebuilding from a backup.

What to do with the findings

Five minutes gets you to a verdict, not a fix. If two or more of the checks above came back dirty, the site is compromised and the only safe path is to restore from a backup taken before the earliest suspicious timestamp, then patch whatever let them in. If everything came back clean and the site is still flagged, it's worth reading the server's mail queue and the last 48 hours of access logs before you assume Google is wrong.

When we built Pier, the chat editor we make for any legacy site, we ran into this exact flow often enough that we wired the audit straight into the chat: point it at an FTP and a MySQL connection, ask whether the site is compromised, and it runs the file reads and the SQL queries above against the live install. Every file it touches goes into version history, and the same MySQL editor view lets you diff a row or a file against last week's state if you do end up cleaning.

The smallest thing to do today, even on a site you trust: read your own wp-config.php and your own .htaccess top to bottom, once. If you don't recognise every line, you have homework.

— Questions —

Can I run this audit without shell access?

Most of it, yes. The file reads work over SFTP, and the SQL queries work in phpMyAdmin or Adminer. Only the wp-cli checksum step needs shell or a managed-host equivalent.

How fresh does the backup need to be?

Older than the earliest suspicious timestamp you found. If wp_users shows a rogue admin registered on May 1 and your only backup is from May 5, you're restoring a compromised site.

Checksums pass but Google still flags the site. Now what?

Check the mail queue and the last 48 hours of access logs. The compromise may be in a plugin (not core), or the flag may be a stale Safe Browsing entry from a previous incident.