Skip to content
System nominalUTC --:--:--Articles 080
MODULE 06 Lesson 16 / 18 40 MIN

Incident response

Detecting Compromise in a WordPress Site

Read a WordPress filesystem, database and access log for signs of intrusion, capture the evidence before cleaning, and know where detection stops being proof.

Someone tells you the site is sending spam. Or the host emails about outbound connections to an address nobody recognises. Or a customer says a page redirected them to a pharmacy. You have an hour before the next decision, and the decision is whether this is real.

Detection is not a scan that returns true or false. It is three lists and a clock. The filesystem holds what was written, the database holds what was granted, and the access log holds what was requested. Each one is cheap to read and each one lies in a different way. What you are building is a window: the earliest moment something unexplained happened and the latest moment it could still be happening.

The default WordPress install is unusually quiet. It has twelve tables, a handful of options, and no login history at all. That quiet is the problem, because every investigation starts from an absence and has to reconstruct the question the site never recorded the answer to.

Three lists and a clock are the whole of detection

The filesystem answers what exists and when it was written. The database answers who has authority and what configuration changed. The access log answers what was requested from outside. Nothing else is needed on day one, and reaching for a malware scanner before reading these three lists wastes the first hour on someone else's signature database.

Order matters for a practical reason: the filesystem checks read and never write, and a database query is safe only if you write it that way. A wp command that looks harmless can write. wp cron event run executes scheduled jobs, and any command that fires init gives every plugin a chance to update an option. Read the filesystem first, then the database, then the logs, and treat every command that boots WordPress as capable of changing what you are about to measure.

The filesystem is the cheapest list and the noisiest

Start with core, because core has a release stamp. In the release archive, every .php file under wp-admin and wp-includes carries an older mtime than wp-includes/version.php, so one find compares the whole tree against the release:

find wp-admin wp-includes -name '*.php' -newer wp-includes/version.php -print

On a clean 7.0.7 tree that prints nothing. After I appended one comment line to wp-includes/pluggable.php, it printed exactly one path, and wp core verify-checksums agreed with it:

$ find wp-admin wp-includes -name '*.php' -newer wp-includes/version.php -print
wp-includes/pluggable.php

$ wp core verify-checksums
Warning: File doesn't verify against checksum: wp-includes/pluggable.php
Error: WordPress installation doesn't verify against checksums.

mtime alone cannot tell you what changed, only that something did. When you need the bytes, diff against the release you claim to be running. The tarball for your version is a few seconds of download and needs no extraction:

tar xzf wordpress-7.0.7.tar.gz -O wordpress/wp-includes/pluggable.php \
  | diff - wp-includes/pluggable.php
3447a3448,3449
>
> // m6c: core file edited by hand at 03:12

The same stamp comparison works for the rest of the tree, but it stops being clean. A fresh 7.0.7 install with the SQLite drop-in already produced fifteen hits under wp-content before anybody attacked it, because the plugin and the drop-in were written after the core files were extracted. That is not a reason to abandon the check. It is the reason to run it while the site is healthy and keep the output, so that on the bad day you are reading a difference instead of a discovery:

find wp-content -name '*.php' -newer wp-includes/version.php \
  -not -path 'wp-content/uploads/*' \
  -not -path 'wp-content/cache/*' \
  -not -path 'wp-content/upgrade/*' -print

Two directories deserve their own line in the report. mu-plugins loads on every request, before normal plugins, and no admin screen lists it, because the Plugins screen builds its table from get_plugins(), which scans WP_PLUGIN_DIR and never WPMU_PLUGIN_DIR. WP-CLI does list it, which is why the command-line is the right place to look:

$ wp plugin list --fields=name,status,file
name                        status    file
akismet                     inactive  akismet/akismet.php
hello                       inactive  hello.php
sqlite-database-integration inactive  sqlite-database-integration/load.php
0-uptime                    must-use  0-uptime.php
db.php                      dropin    db.php

Only PHP files at the top level of mu-plugins are loaded. A file dropped in mu-plugins/deep/helper.php defines nothing and does nothing, which I confirmed by defining a constant in each and asking WordPress which one existed: top-level true, nested false. Attackers know this, so a subdirectory in mu-plugins is either a hosting provider's packaging or somebody who did not test. And wp-content/uploads should contain no PHP at all. No upload handler has a legitimate reason to put an executable file there, so any hit is a finding until the plugin that owns that path explains itself.

The database has no timestamps, so you bring your own baseline

Authority is a row in wp_usermeta, not a flag on the user. An account is an administrator because the serialised array in wp_capabilities says so, which is why the useful query joins the two tables instead of trusting wp_users on its own:

SELECT u.ID, u.user_login, u.user_email, u.user_registered
FROM wp_users u
INNER JOIN wp_usermeta m ON m.user_id = u.ID
WHERE m.meta_key = 'wp_capabilities'
  AND m.meta_value LIKE '%administrator%'
ORDER BY u.user_registered;

The client I ran that in prints one row per line with pipe separators; read the values, not the layout.

ID | user_login  | user_email           | user_registered
1  | ana         | ana@example.test     | 2026-10-16 20:45:55
2  | support_ops | support@example.test | 2026-10-16 20:46:41

Read that output the way you would read a diff. The value of the query is not the list of administrators, which you already know, but the registration timestamp next to each one. A row whose date you cannot attach to a deliberate act is the finding. The same trick works for relationships nobody normalises: the serialised capability array for the account above is a:1:{s:13:"administrator";b:1;} and its wp_user_level is 10, and searching for those two strings finds privilege that a plugin granted through an API you did not expect to be reachable.

Options are harder, because wp_options has no modification time and no timestamp of any kind. A change is invisible in the table itself. Two options are worth checking by name on every incident, because both turn a small mistake into an open door: default_role and users_can_register. Measured values after someone set them in my lab:

option_name         | option_value  | autoload
active_plugins      | a:1:{i:0;s:14:"m6c-loader.php";} | on
default_role        | administrator | on
users_can_register  | 1             | on

The autoload column is the trap for anyone writing a query from memory. On this 7.0.7 install the distinct values are on (100 rows), off (11) and auto (17). The string yes appears nowhere in the column even though it is still the declared default in the schema, so WHERE autoload = 'yes' returns an empty set and hides exactly the options an attacker would attach code to. Use WHERE autoload IN ('on', 'auto-on', 'auto').

For active_plugins, the serialised value is also the trap in the other direction. It is a PHP array whose every string carries its own byte count, so an entry edited by hand is a corrupted option. I set the length to 13 instead of 14 for m6c-loader.php and WordPress reported every plugin as inactive, while wp plugin activate died with a TypeError: in_array(): Argument #2 ($haystack) must be of type array, string given from wp-admin/includes/plugin.php. Worse, wp option update serialises whatever you hand it, so passing an already-serialised string stores it twice. The repair is to let an API build the value: wp option update active_plugins '["m6c-loader.php"]' --format=json, which produces a:1:{i:0;s:14:"m6c-loader.php";} and restores the plugin list.

Since the table cannot tell you when a value changed, the change has to be recorded when it happens. That is lesson 15's update_option hook earning its keep: an allowlist of options, each write logged with the old and new value, turns an incident from a guessing game into a grep. Without it you diff today's wp_options against the last export you have and count yourself lucky if one exists.

The access log records what WordPress never wrote down

WordPress core keeps no record of failed logins. The install has twelve tables and none of them is a log, which is why an access log is not a nice-to-have during an incident. Two patterns in it are worth more than everything else in the file.

The first is a POST to something that should only ever be read. A request method is not a hint; it is the difference between a file being fetched and a file being executed and fed input. With the PHP built-in server in front of my lab install, a POST to a PHP file that had been dropped into wp-content/uploads/2026/10/ returned 200 and the marker the file printed, and the log line is unmistakable:

[Fri Oct 16 14:47:48 2026] 127.0.0.1:50370 [200]: POST /wp-content/uploads/2026/10/health.php
[Fri Oct 16 14:47:48 2026] 127.0.0.1:50371 [200]: POST /wp-content/uploads/2026/10/notes.txt

The second is volume against one endpoint. Five failed logins look like this, and the status code tells you nothing, because WordPress answers a failed login with the same 200 as a successful page load:

[Fri Oct 16 14:47:48 2026] 127.0.0.1:50372 [200]: POST /wp-login.php
[Fri Oct 16 14:47:49 2026] 127.0.0.1:50373 [200]: POST /wp-login.php
[Fri Oct 16 14:47:49 2026] 127.0.0.1:50374 [200]: POST /wp-login.php
[Fri Oct 16 14:47:49 2026] 127.0.0.1:50375 [200]: POST /wp-login.php
[Fri Oct 16 14:47:49 2026] 127.0.0.1:50376 [200]: POST /wp-login.php

Identical lines, five in one second. On a site with a public login page that is also what a normal Tuesday afternoon looks like, so the count is only meaningful against a baseline. What makes the pair of patterns useful is that they fail in opposite directions: a login spike is common and mostly harmless, and a POST to uploads is rare and almost never harmless.

Capture first, because every cleanup step is irreversible

The instinct on finding a backdoor is to delete it. Delete it and you have destroyed the only copy of the attacker's code, its mtime, and the chance to answer how it got there. Before anything is removed or changed, take two snapshots and put them somewhere the site cannot reach.

mkdir -p /var/evidence/$(date +%Y%m%dT%H%M%S)
tar czf /var/evidence/files.tgz wp-content/mu-plugins wp-content/uploads \
  wp-config.php wp-includes/pluggable.php
sha256sum /var/evidence/files.tgz
ba3c81bbb5ba4ad25bc56623ee7253104f5cc40af3d6590a5c400ffd8492e31c  /var/evidence/files.tgz

tar preserves the modification time, which is the whole point; a plain copy resets it to now and destroys the ordering you are about to build a timeline from. I copied one mu-plugin with cp and again through tar and the difference is visible in ls -l: the original and the tar copy read 14:46:45, the cp copy read 14:48:01.

The database needs its own method, and it is not a file copy. On a live MySQL install that means mysqldump --single-transaction; on the SQLite install I used, sqlite3 … ".backup". Doing it the lazy way shows you why. This lab runs in WAL mode, and after I deleted the attacker's account the main database file on disk was still 442,368 bytes and still contained the account, because the delete lived in a 585,072-byte write-ahead log beside it. A copy of the main file read ana, support_ops; the live database read ana. A backup taken by copying the file is not a backup, and during an incident it is worse than none, because you will hand it to a restore and believe the result.

A clean sweep is a narrowed window, not an acquittal

Every check in this lesson has a legitimate way to fire. Managed hosts drop their own mu-plugins and object caches. A plugin update rewrites files and moves mtimes. A contractor in another timezone creates an account at 03:00 and the date looks wrong until you check the timezone. My fifteen-hit baseline on an untouched install is the general case, not an accident.

Two limits are worth stating before anyone signs off. The first is that logs are evidence you did not control. A failed-login handler that writes the request field directly lets an attacker create a line that reads like a successful login; I reproduced that with a newline in the login form, and the forged line matched grep '^\[16-Oct-2026 21:10:00 UTC\] SUCCESS' as a line of its own. Usefully, the same test showed the argument WordPress hands to wp_login_failed is already run through sanitize_user(), which collapses whitespace, so a logger that writes the hook argument logged the forged text as harmless characters inside one line. Which of the two you get depends on your code, and that is a reason to read your logger before trusting its output.

The second limit is the one that matters most. A competent attacker writes nothing obvious: they preserve mtimes, edit logs, remove their own mu-plugin between uses, and hold authority through an existing session rather than a new account. Detection tells you the earliest unexplained event and the checks you ran clean. It does not tell you the site is clean, and no honest report should say that it does.

Lab 35 minutes

Compromise a WordPress install you own, then detect it without cleaning it.

Plant four things: an administrator account named like a colleague, a mu-plugin at the top level of wp-content/mu-plugins, an appended line in a core file, and a change to default_role. Add a PHP file inside wp-content/uploads for good measure.

Now write the incident note. For each of the five artefacts, record the command you ran, the output line that identified it, and the exact time it was created. Then produce one deliberate false positive and show how you would rule it out.

Show the solution and the reasoning

Five artefacts, four commands, and the order is the argument. Filesystem first, because those commands read and never run WordPress:

# core, against the release stamp
find wp-admin wp-includes -name '*.php' -newer wp-includes/version.php -print

# everything else, minus the directories that churn on their own
find wp-content -name '*.php' -newer wp-includes/version.php \
  -not -path 'wp-content/uploads/*' -not -path 'wp-content/cache/*' \
  -not -path 'wp-content/upgrade/*' -print

# PHP where uploads put files
find wp-content/uploads -name '*.php' -print

# authority, straight from the tables
wp db query "SELECT u.ID, u.user_login, u.user_registered FROM wp_users u
  INNER JOIN wp_usermeta m ON m.user_id = u.ID
  WHERE m.meta_key = 'wp_capabilities' AND m.meta_value LIKE '%administrator%'"

The mu-plugin is the one that needs an extra step, because find lists it among fifteen legitimate hits on an untouched install. The discriminator is not the mtime, it is the name and the load path: wp plugin list labels it must-use, and wp eval 'var_dump( defined( "MY_CONSTANT" ) );' proves whether it is loading. Reading the file is allowed; executing it is not something you need to do.

For the intentional false positive, the most realistic one is a plugin update. Install a plugin, update it, and the mtime check fires across its directory. The way you rule it out is by comparing against the deploy record: a plugin whose files are all newer than the last release but whose version matches the last deploy is maintenance, and the same directory with a file that no version ever shipped is not. That comparison is only possible if a deploy timestamp exists somewhere outside your memory, which is why lesson 15 put the deploy window in the same timeline as the log.

The part most people get wrong is the database. Run the queries through wp db query or a read-only user, and do not reach for a wp command that boots the full application until you have captured the evidence. If you find an unexpected account, note its user_registered value before you touch it and check wp_usermeta for session_tokens: an attacker with a live session is not removed by deactivating their account alone, and the session record is the evidence that tells you so.

Progress

Mark this lesson done and it stays marked in this browser.