Skip to content
System nominalUTC --:--:--Articles 080
MODULE 06 Lesson 17 / 18 45 MIN

Incident response

Containment and Recovery on a Compromised Site

The order of operations after a WordPress compromise: taking the site offline, preserving evidence, rotating credentials, and verifying the recovery.

A compromise is not a bug you fix. It is a state the site is in, and the way out is a sequence rather than a set of good ideas applied in whatever order the morning allows. The order exists because some steps destroy the information the later steps depend on. Delete the backdoor and you have no idea what it was for; rebuild the site and you have no idea whether the new one is reachable the same way.

There is one more constraint that shapes everything else: your attacker may still be reading. A web shell runs as the web server user, and the web server user can read wp-config.php. Every command you run from the server is visible to anything that can read the same files.

Order beats speed in the first hour

Seven steps, in this order, and the first is not the one people reach for:

StepDone whenWhy it is here
Stop the bleedingThe site serves a maintenance page or is blocked at the edgeNothing new is created while you work
PreserveAn off-host copy of files, database snapshots and logs existsEvery later step modifies the evidence
RotateSalts, database password, admin passwords and external keys are newAccess that was bought with the old credentials stops working
ScopeYou can state the earliest plausible entry pointIt decides which backup is usable
Remove or rebuildEither every indicator is gone, or the site is a known-good treeThe actual repair
VerifyA written list of checks passes twice, days apartRemoval is a claim; verification is a test
DiscloseThe people who are owed a notification have itThe clock starts earlier than the investigation ends

The tempting shortcut is to skip from "found something" to "deleted it", because the deletion feels like progress. It is the one step that cannot be undone, and the backdoor everybody found is rarely the only one.

Take the site offline before you delete anything

"Offline" does not have to mean taking the DNS record away for a day. WordPress ships its own maintenance gate, and you can use it directly. The file is PHP that assigns a timestamp, and the front end answers 503 while it is fresh:

printf '<?php $upgrading = %s; ?>' "$(date +%s)" > .maintenance
$ curl -s -o /dev/null -w 'GET / -> %{http_code}\n' http://127.0.0.1:8123/
GET / -> 503

$ curl -s http://127.0.0.1:8123/ | grep -o 'Briefly unavailable[^<]*'
Briefly unavailable for scheduled maintenance. Check back in a minute.

That is the same file WP_Upgrader::maintenance_mode() writes before an update, and the check in wp_is_maintenance_mode() reads the timestamp out of it and ignores the file once the value is more than ten minutes old. An empty .maintenance file does nothing at all, which I confirmed the slow way by creating one with touch and watching the front page answer 200. Refresh the timestamp while you work, or accept that the gate reopens itself.

What makes the maintenance file the right first move rather than a stopgap is what it does not block. With the gate up, the front end returned 503 while wp option get siteurl still ran and printed the site URL. Visitors are outside; you are inside with the tooling you need to collect evidence. An edge block or a firewall rule gives you the same split when the site is behind one, and it also stops the attacker's own callbacks, which the maintenance file does not.

Preserve what containment is about to destroy

Capture before rotation, not after. Rotation changes the session tokens and salts the evidence is made of, and cleanup deletes the artefacts. The filesystem copy is a tar because tar keeps the modification time; measured on the same file, the original and the tar copy both read 14:46:45 while a cp copy read 14:48:01. Losing that ordering costs you the timeline in lesson 18, which is built entirely out of mtimes and log timestamps ordered against each other.

The database needs whatever consistent snapshot your engine provides, and the reason is worth measuring rather than trusting. On the SQLite install I used, the delete of the attacker's account lived in a 585,072-byte write-ahead log while the database file itself stayed at 442,368 bytes and still contained the account. A copy of the file alone listed two users; the live database listed one. Use mysqldump --single-transaction, sqlite3 … ".backup", or the host's snapshot feature, and immediately record the size and hash of what you captured so you can tell later whether the evidence is intact.

Logs belong in the same bundle, copied rather than moved, and from the place they are actually written rather than the place you assume. The web server's access log, the PHP error log and any application log the site writes are three different files on most hosts; lesson 15 put the audit trail outside the document root for this reason. Copy all three before you rotate anything: a rotated credential appears in the log as a new login, which gives you a clean boundary, because everything after that line happened while you were watching.

Rotate every credential the site trusted

Rotation is not one action. Each credential stops a different thing, and knowing which is which is what keeps you from promising more than the change delivers.

The salts in wp-config.php are the cheapest lever with the widest effect. I issued a valid logged_in cookie for the compromised account, rotated the eight key and salt constants, and asked WordPress to validate the same cookie again:

validate after issue: 2
validate after salt rotation: false

Every existing login cookie is invalidated by that change, and so is every nonce, which matters because nonces are how WordPress proves intent on an action the attacker may be holding open in a browser session. What the rotation does not do is invalidate stored password hashes. The same account's hash still returned true from wp_check_password() after the salts changed, because WordPress hashes passwords independently of the salts. Rotating salts logs everybody out; it does not change a single password, so you still have to reset the passwords you do not trust. wp user session destroy <user> --all is the targeted version when one account is the problem and you do not want to log out every editor.

The rest of the list is longer than the WordPress install:

  • The database user's password, changed at the database server and then in wp-config.php. If the attacker read the configuration file, this is the credential they used to reach the tables directly.
  • Every administrator's WordPress password, set by someone other than the person who owns the account, since the reset email is the one channel you cannot verify from inside the site.
  • Hosting, SSH, SFTP and control panel credentials, plus any deploy key the site published or a CI job holds. Code execution on the web server is a short walk from the account that deploys to it.
  • Plugin and service keys: payment gateways, transactional email, CDNs, analytics, backup storage. An attacker who reads the options table has these; they are the assets that stay valuable after the site is cleaned.
  • Anything the site shares with another site. A shared hosting account or a shared database credential means the incident is bigger than the site you were called about.

Date the intrusion before you choose a backup

Restoring a backup is only a recovery if the backup predates the intrusion. A backup taken after the attacker arrived restores the attacker, and it does so with the confidence of a completed restore, which is worse than no restore at all. I reproduced the whole failure in the lab. With the rogue account present, I took a consistent snapshot, deleted the account, confirmed the site had one administrator, and restored the snapshot:

ID | user_login  | user_registered
1  | ana         | 2026-10-16 20:45:55
2  | support_ops | 2026-10-16 20:46:41

The account came back with its original registration timestamp, which is the useful part: the restored site now contains the evidence of its own re-infection, and if nobody looks for it the next report is that the fix did not work.

Test the backup before you restore it. Standing up a copy and running the lesson 16 queries against it takes minutes: an administrator you cannot account for, a mu-plugin nobody shipped, a default_role that is not subscriber. A backup that fails those questions is not a recovery point, and if every backup you hold fails them the choice moves to the next section.

How far back you go is bounded by the oldest indicator you can date. If the account was created on the 3rd and the log only goes back to the 1st, the window starts before the log does and you are guessing. Log retention, which lesson 15 set at thirty days of full fidelity, is exactly what buys you a bounded answer here.

Cleaning in place and rebuilding from clean cost different things

Cleaning in place means removing the indicators and keeping the site: delete the unexpected accounts, remove the foreign files, restore core and plugin files from releases, reset the options that should not have changed, purge every cache in front of PHP. It costs hours. It preserves content, SEO and the layout everybody knows, which on a content site is the difference between an incident and a business event.

The catch is what it assumes. Cleaning in place is only sound when you have a complete inventory of what changed. The inventory comes from a known-good baseline: release checksums for core, the plugin repository for plugin files, a file-integrity baseline for everything else. Without one, you are removing what you happened to find, and the persistence you did not find survives the cleanup. It also leaves every unknown in place: an edited plugin file that still matches its upstream hash is a contradiction, but an edited database row in a custom table is not something a checksum was ever going to notice.

Rebuilding from clean means a fresh WordPress, fresh plugin installs from their sources, and a content migration from the compromised database, imported through the WordPress APIs or SQL that never carries executable configuration. It costs a day and some breakage. It is the only defensible option when you cannot establish the initial vector, because a rebuild discards the attacker's foothold by discarding everything, including the persistence you failed to find. The content migration is where the risk moves to: wp_posts can carry a stored script tag, an image file can carry a payload for the next plugin that parses it, and custom tables can carry whatever the old code allowed.

A middle path is worth naming as a compromise: rebuild the code, keep the content, and treat every custom table as data you read before importing it. What you give up is the ability to say the site is clean; what you keep is the content. Record which of the three you did, including the parts you could not verify.

Verification is a list you run, not a feeling you have

After a clean-in-place recovery in my lab, the verification pass looked like this:

$ wp core verify-checksums
Success: WordPress installation verifies against checksums.

uploads PHP:      0
mu-plugins:       0

ID | user_login
1  | ana

option_name        | option_value
default_role       | subscriber
users_can_register | 0

option_value
a:0:{}

Six checks, each of which can fail on its own: core hashes, no PHP in uploads, no mu-plugin files, only the administrator accounts you can name, the two options back at their defaults, and no plugin active except the ones you chose. Write the list down before you run it, so the list is not shaped by what you already expect to find. Then run it again in a week, and again after the next deploy, because a backdoor that relies on a cron event, a transient, or a scheduled task you did not find will not reappear until the condition it waits for does.

One check belongs outside the site: purge every cache in front of PHP, including the plugin page cache and the CDN. A cached page keeps serving injected content after the database and the theme are clean, and it looks exactly like a site that is still compromised. The same applies to the object cache, where a cached option survives a database change and serves the old value back to PHP.

Disclosure starts when you know, not when the investigation ends

If personal data was involved, the notification clock started when the site owner became aware of the incident, not when the investigation produced a tidy conclusion. A statement that is honest about what is known and what is not is more useful than a complete one that arrives after the deadline, and the obligation belongs to whoever controls the data rather than to the security engineer.

Two consequences for the technical work: preserve the evidence chain, because a notification may be audited, and record who had access to it. Do not let the desire for a finished post-mortem hold the notification, and do not let the notification end the investigation; the two run in parallel from the day of awareness.

Lab 40 minutes

Take the compromised install from lesson 16 to a verified recovery, writing down the order as you go.

Capture the filesystem and a consistent database snapshot. Put the maintenance gate up and confirm a visitor gets 503 while a WP-CLI command still runs. Rotate the salts and show that a cookie you issued before the rotation no longer validates. Then remove the backdoor, restore a backup you have first tested for the same indicators, and run the verification list.

Finally, answer in one sentence: if the only backup you held had been taken after the intrusion, which step would have saved you, and which would have hidden the problem?

Show the solution and the reasoning

The order is the answer, so write it down as commands before you run any of them:

# 1. offline for visitors, still usable from the shell
printf '<?php $upgrading = %s; ?>' "$(date +%s)" > .maintenance
curl -s -o /dev/null -w '%{http_code}\n' https://example.test/    # 503

# 2. preserve
tar czf /var/evidence/files.tgz wp-content wp-config.php
mysqldump --single-transaction --routines --triggers db > /var/evidence/db.sql
sha256sum /var/evidence/files.tgz /var/evidence/db.sql

# 3. rotate salts in wp-config.php, then
wp user session destroy ana --all

# 4. inventory, 5. remove or rebuild, 6. verify
wp core verify-checksums
find wp-content/uploads -name '*.php' -print

The maintenance file has to be refreshed while you work, or you will find the site serving 200 to whoever is still triggering the backdoor. That is the reason the gate is a first move rather than a set-and-forget: it buys minutes, not hours.

Rotating the salts is the step people expect to do more than it does. It kills sessions and nonces and leaves passwords alone, which I confirmed by validating the same cookie before and after (user ID, then false) and by checking the stored hash with wp_check_password() afterwards (still true). Reset the administrators' passwords separately, and set them yourself rather than emailing a reset link, because the mailbox may be part of the incident.

On the backup question: the answer is that scoping saves you and the test saves you, because a snapshot taken after the intrusion is a snapshot of the compromise. The restore itself does not fail; it succeeds and puts the account, the option and the injected file back, and the site looks recovered. The only way you catch that before your users do is to mount the backup somewhere isolated and run the detection queries against it, then compare the result with the earliest indicator you could date. If the backup fails the test, you do not have a recovery point, and the honest move is to rebuild the code and migrate the content while treating every custom table as data that has to be read before it is imported.

The verification pass is deliberately the same list you wrote at detection, which is the part that makes it repeatable by somebody else. Two runs on separate days matter more than one thorough run: the artefacts that hide from a single pass are the ones waiting for a condition.

Progress

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