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

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)EventEvidenceKind
2026-10-16 20:45Install completed, one administratorwp_users row, ID 1Observed
2026-10-16 20:46Second administrator createduser_registered = 20:46:41; wp_capabilities = a:1:{s:13:"administrator";b:1;}Observed
2026-10-16 20:46mu-plugin writtenmtime 14:46:45 on 0-uptime.php; wp plugin list status must-useObserved
2026-10-16 20:47POST to a PHP file in uploadsaccess log [200]: POST /wp-content/uploads/2026/10/health.phpObserved
before 20:45Entry pointno artefact dates anything earlier; log starts hereInferred

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/uploads were executed by the web server.
  • No integrity baseline existed for plugin, theme and mu-plugins files, 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 actionableActionable
Review who has administrator accesswp user list --role=administrator output added to the monthly review; owner: platform; date: 2026-11-01
Improve log monitoringAlert 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 reliableBefore 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 passwordsRequire 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 uploads is web server configuration. WordPress writes the file; the server decides whether to execute it. The proof of completion is a 403 or 404 for 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.

Lab 40 minutes

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:

FindingClassEvidence
Alert on role changes and user creation outside the deploy windowexistinglesson 15's rules three and four name the hooks
Alert on PHP under wp-content newer than the last deployexistingrule five; the lab mu-plugin appeared in exactly that output
Schedule wp core verify-checksumsexistingexits non-zero and names the file after one appended line
Deny PHP execution in uploadsmissinga POST to a dropped PHP file returned 200 and its output
Baseline plugin and mu-plugin hashes off-hostmissingcore 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.