Incident response
The Post-Mortem That Changes Something
Why most incident post-mortems change nothing, and how to write one with an evidenced timeline, a root cause, findings owned by systems and dated actions.
You have the evidence bundle, the site is back up, and the last task on the incident is the document. Three weeks later nobody has read it, the action list is a paragraph of good intentions, and the same class of incident arrives through a different door. The document was not the problem. The problem is that it was written to describe an event rather than to change a system.
A post-mortem that changes something has four properties, and a missing one of them is enough to make the rest inert: a timeline where every entry is dated by evidence, a root cause stated in one sentence, contributing factors that are more interesting than the root cause, and actions with a named owner and a date. Everything else is optional.
A post-mortem nobody reads failed before it was written
The usual failure is not laziness. It is that the document answers a question nobody
asked. "What happened" is history; "what will be different next time" is work. A document
that ends with "the team should be more careful" has no completion condition, so it can
never be done and nobody can be asked about it. A document that ends with "restrict PHP
execution in wp-content/uploads, owner: host config repo, date: the next
maintenance window, proof: a request to a PHP file in uploads returns 403" can be
finished and checked.
The second failure is blame in a system coat. A document that identifies a person without identifying the mechanism gives the reader one action: avoid being that person. The mechanism is the part that can be changed, and in a WordPress incident the mechanism is nearly always a missing control rather than a careless human: no second factor on the administrator login, no integrity baseline for plugin files, no alert when an option that holds a role value changes. Those are things a team can build.
Every timeline entry carries the evidence that dates it
The timeline is the only part of the post-mortem that cannot be reconstructed later, because it is built from artefacts that are being deleted while you write. Two columns are not enough. Add a third for the evidence, and a fourth for whether the row is observed or inferred:
| Time (UTC) | Event | Evidence | Kind |
|---|---|---|---|
| 2026-10-16 20:45 | Install completed, one administrator | wp_users row, ID 1 | Observed |
| 2026-10-16 20:46 | Second administrator created | user_registered = 20:46:41; wp_capabilities = a:1:{s:13:"administrator";b:1;} | Observed |
| 2026-10-16 20:46 | mu-plugin written | mtime 14:46:45 on 0-uptime.php; wp plugin list status must-use | Observed |
| 2026-10-16 20:47 | POST to a PHP file in uploads | access log [200]: POST /wp-content/uploads/2026/10/health.php | Observed |
| before 20:45 | Entry point | no artefact dates anything earlier; log starts here | Inferred |
The last row is the honest part of the exercise. When the earliest artefact is the account and the log does not go back further, the entry point is unknown and the row says so. Marking it inferred stops the document from asserting a cause it cannot support, which is the failure mode that gets a post-mortem quietly disbelieved.
Build the timeline from the four sources you captured: access log timestamps, file
mtimes from the evidence tar, database rows with dates such as
user_registered, and your own deploy record. Times in one timezone, always
UTC, because the timestamps in WordPress, the web server and the host's alerting are
written by three different systems and the first argument in the incident channel will be
about an hour of offset.
Root cause is one sentence; the factors are the work
Root cause answers "what condition, removed, would have stopped this specific incident". One sentence, no hedging, and it should be uncomfortable to write. In the lab incident the root cause is that an administrator account existed whose creation nobody authorised and nobody noticed, and the condition that made it matter was a login credential the attacker already held.
Everything else is a contributing factor, and those are the findings you can act on:
- PHP files in
wp-content/uploadswere executed by the web server. - No integrity baseline existed for plugin, theme and
mu-pluginsfiles, so the time to detect was unbounded. - The audit log was written into
wp-content, where it was readable over HTTP. - Role changes were not alerted on, only plugin activations.
- The newest backup predated the incident by eleven days, and nobody had tested whether an older one would restore.
Notice that the root cause alone gives you nothing to build. "An account was created" is true of every WordPress site. The list underneath is what a sprint can hold, and each row maps to a control that either exists or does not.
A finding is a change to a system, with an owner and a date
Write findings in the imperative and give each one a completion condition. The difference is testable:
| Not actionable | Actionable |
|---|---|
| Review who has administrator access | wp user list --role=administrator output added to the monthly review; owner: platform; date: 2026-11-01 |
| Improve log monitoring | Alert on set_user_role, user creation and any PHP file under wp-content newer than the last deploy; owner: on-call; date: 2026-10-30 |
| Make backups more reliable | Before any restore, mount the candidate backup in a container and run the three detection queries against it; owner: platform; date: 2026-11-08 |
| Educate staff about passwords | Require a second factor for every administrator login; owner: identity; date: 2026-11-15 |
The owner is the person who can complete the change, not the person who caused the incident, and the date is the date it will be done rather than the date it should be done. Where a finding needs budget or a supplier, the action for this round is the decision, with the name of the person who makes it. A finding with no owner and no date is a wish, and a document of wishes is the one nobody reads.
Module 5 already covers three of the usual findings
Before you write a new control, check what the hardening in module 5 would have caught. For the lab incident, the answer is specific.
The rogue administrator is set_user_role and user-creation traffic, and
lesson 15's alert rules three and four fire on exactly that outside a deploy window. The
mu-plugin is rule five: any PHP file under wp-content with an mtime newer
than the last recorded deploy. Both would have shortened the window from a day to
minutes. The modified core file is the one control that needs no custom code at all,
because wp core verify-checksums catches it and exits non-zero. The missing
piece is scheduling it and sending the failure somewhere a human looks:
0 6 * * * cd /var/www/site && wp core verify-checksums \
|| mail -s "core files changed on $(hostname)" oncall@example.test
What module 5 did not give you is equally specific, and it is worth being blunt about
one entry. DISALLOW_FILE_MODS is not a barrier against an attacker who has
command execution. Measured on 7.0.7 with the constant set, WordPress itself reported no
file modifications were allowed (wp_is_file_mod_allowed( "install_plugins" )
returned false), and wp plugin install health-check --force
installed the plugin anyway. The constant is consumed by one gate function in
wp-includes/load.php that a handful of wp-admin paths consult,
and WP-CLI writes through its own filesystem layer without asking. It stops an editor
clicking "Install" in the admin. It does not stop the shell.
The same care applies to the headers. send_frame_options_header() is
registered on login_init and on admin_init, and never runs on
the front end, which I verified by fetching wp-login.php and the home page
from the same install: the login response carried X-Frame-Options,
frame-ancestors and Referrer-Policy, and the home page carried
none of the three. nosniff is narrower still: admin-ajax and the REST server
send it, and a file served out of uploads does not. None of that would have
touched the lab incident, because a backdoor called over HTTP by a script does not care
about a browser policy. Reporting those headers as a detection control would be wrong,
and a post-mortem is a bad place to be wrong.
Two more existing controls deserve a note rather than a checkmark. The debug log
written by WP_DEBUG_LOG into wp-content was readable over HTTP
in my lab: GET /wp-content/debug.log returned 200 as
text/plain, with absolute filesystem paths in it. A log that leaks is a
finding in its own right. And the log is evidence only while it is intact: in the same
lab, a failed-login handler that logged the raw request field took a newline from the
login form and produced a forged SUCCESS line that grep found on its own,
while the same handler logging the wp_login_failed argument logged the same
input safely, because sanitize_user() collapses the whitespace before the
hook fires.
Some findings need something the site did not have
Four of the lab findings cannot be closed with a WordPress setting, and the honest post-mortem says so:
- PHP execution in
uploadsis web server configuration. WordPress writes the file; the server decides whether to execute it. The proof of completion is a403or404for a request to a PHP file in that directory, not a plugin setting. - An integrity baseline for plugins, themes and mu-plugins does not exist in core. Core hashes cover core. Everything else needs a manifest you generate while the site is healthy and store off the host, plus a daily comparison.
- A tested restore is a process, not a product. The measurement in lesson 2 of this module is the reason: a backup taken after the intrusion restores the intrusion, and the only way to know which side of the line a backup sits on is to mount it and run the detection queries against it.
- A second factor on administrator logins is often a hosting or identity feature rather than a WordPress one, which makes it a procurement action with a date rather than a code change.
Detection is the threat model read backwards
Module 1 asked what an attacker controls and what is worth protecting, and an incident
answers the same question from the other side. A PHP file in uploads is a
trust boundary crossed a layer below WordPress, the forged log line is an output problem,
the rogue account is an authorisation problem, and the file written outside it is code
execution. The control that would have caught each one was a capability check, an escape
at the right layer, a prepared statement's neighbour, or a log line someone decided to
keep on a quiet Tuesday, months before anyone needed it.
Write the post-mortem for the incident you created and recovered from in the previous two lessons. One page, four sections.
The timeline carries four columns: UTC time, event, evidence, observed or
inferred. Then one sentence of root cause and at least four contributing factors.
Then five findings, each with a mechanism, an owner and a date. Finally mark each
finding existing if something in module 5's hardening would have caught
it, or missing if it needs a capability the site never had, and give the
evidence for that classification.
Show the solution and the reasoning
The classifications matter more than the prose, so here is the mapping I would defend for the lab incident, with the measurement behind each one:
| Finding | Class | Evidence |
|---|---|---|
| Alert on role changes and user creation outside the deploy window | existing | lesson 15's rules three and four name the hooks |
Alert on PHP under wp-content newer than the last deploy | existing | rule five; the lab mu-plugin appeared in exactly that output |
Schedule wp core verify-checksums | existing | exits non-zero and names the file after one appended line |
Deny PHP execution in uploads | missing | a POST to a dropped PHP file returned 200 and its output |
| Baseline plugin and mu-plugin hashes off-host | missing | core checksums cover core only |
Two entries in that table are the trap. Do not classify
DISALLOW_FILE_MODS as an existing control against a rogue plugin: with it
set, wp plugin install still wrote to wp-content/plugins, so
a finding that leans on it is a finding that will not hold. And do not classify the
security headers as a containment control for a file in uploads; they are
not sent on the front end at all, so nothing about the response a webshell produces
changes because of them.
On the timeline, the test for whether an entry belongs is whether someone else could
reproduce the date from the artefacts. "Suspicious login activity around midnight" fails
that test. "20:47:48 UTC, five POSTs to wp-login.php, access log lines
50372-50376" passes it, and it also survives the argument about timezones that follows
every incident.
For the root cause, the useful discipline is to write it before the contributing factors and refuse to revise it while writing them. If the factors keep suggesting a different sentence, the first sentence was describing a symptom. In the lab the symptom is a rogue administrator; the cause is that creating an administrator required a credential the attacker had and nothing else, and no control observed the creation. The factors are what you can build this month.
Finally, date the actions from the day the document is finished, not from the day the incident was declared over. An action list written during an incident tends to carry dates nobody agreed to. If the person who owns a finding is not in the room when the post-mortem is written, the finding is not owned yet, and that is the first thing to fix.
Progress
Mark this lesson done and it stays marked in this browser.
Course complete
That is the last lesson of WordPress Security and Hardening. If the material was useful, the practice is what makes it stick: rebuild the final project from an empty directory without looking back at the lesson.