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:
| Step | Done when | Why it is here |
|---|---|---|
| Stop the bleeding | The site serves a maintenance page or is blocked at the edge | Nothing new is created while you work |
| Preserve | An off-host copy of files, database snapshots and logs exists | Every later step modifies the evidence |
| Rotate | Salts, database password, admin passwords and external keys are new | Access that was bought with the old credentials stops working |
| Scope | You can state the earliest plausible entry point | It decides which backup is usable |
| Remove or rebuild | Either every indicator is gone, or the site is a known-good tree | The actual repair |
| Verify | A written list of checks passes twice, days apart | Removal is a claim; verification is a test |
| Disclose | The people who are owed a notification have it | The 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.
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.