Hardening a site
Logging That Tells You Something
A log nobody reads is a disk quota problem. What to record, what a compromised WordPress looks like in the access log, and the few things worth alerting on.
The hardened site from the first two lessons in this module now has a log file outside the web root, debug logging on, and automatic updates enabled. Three weeks later, someone asks a question the log cannot answer: did an administrator account get created last Tuesday night? The log contains 40,000 entries, most of them from a plugin that fires a notice on every request, and nobody has read any of them.
Logging fails in two directions. Write too little and you cannot answer the question. Write everything and you have answered nothing while spending disk and attention. The way out is to start from the questions, not the events. "Did an account change happen without a deploy" is a question. "Log all admin activity" is a wish.
A log answers a question or it is noise
Four questions cover most of what you will ever need on a WordPress site. They are worth recording because each has a small, bounded set of events that answer it, and because the answers are cheap to keep.
| Question | Events that answer it | Noise to exclude |
|---|---|---|
| Is someone guessing passwords? | wp_login_failed with username and source IP |
Successful logins by known editors, XML-RPC pingbacks |
| Did authority change? | set_user_role, profile_update, user creation and deletion |
Password resets the user requested themselves |
| Did configuration change? | update_option for a named allowlist of options |
Every option; transients; cron; session tokens |
| Did code change? | activated_plugin, deactivated_plugin, switch_theme, file mtime on PHP files |
Plugin updates you triggered yourself after a deploy |
The fourth question is the one WordPress answers least well on its own. The hooks tell
you a plugin was activated; they do not tell you a PHP file was written into
wp-content/uploads/ by an upload handler that should not have allowed it. File
modification detection belongs outside WordPress, on the filesystem, and lesson 1 already
showed why the naive version fails: whatever writes the file also owns it.
All four hooks are real, and their signatures are stable. wp_login_failed
receives the raw username, set_user_role receives the user ID, the new role
and the old roles, and update_option receives the option name, the old value
and the new value. That last one is a trap for two reasons: it fires for options that
change constantly, and both values are arbitrary serialised data.
WP_DEBUG_LOG is an error log, not an audit log
People discover WP_DEBUG_LOG and reach for it as a general-purpose sink,
because it is already there and it writes without any setup. It has three properties that
make it a poor audit log and a fine error log. It lives inside wp-content by
default. Nothing rotates or truncates it. And it records PHP notices and warnings, which
on a WordPress site with a few plugins means thousands of lines a day that are nobody's
fault and nobody's problem.
If you use it for error logging, use it deliberately:
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_DISPLAY', false );
define( 'WP_DEBUG_LOG', '/var/log/wp/site-error.log' );
An absolute path outside the document root is the whole point. With the boolean form,
the log lands at wp-content/debug.log, and a request to that URL returns the
file as text/plain with no WordPress involvement at all. If you inherited a
site where that is true, the log has been readable for as long as it has existed, and it
contains absolute paths, plugin names, and occasionally a database error with the
connection string in it.
For the audit events from the previous section, write to the same place with your own
logger, and prefer the system log over a file you own. On a host that hands you syslog,
PHP's openlog() and syslog() put your lines where the rest of the
machine's logs go, with the host's rotation, permissions and shipping:
function m5_audit( $event, array $context = array() ) {
static $open = false;
if ( ! $open ) {
openlog( 'wordpress', LOG_PID | LOG_NDELAY, LOG_AUTH );
$open = true;
}
$line = $event;
foreach ( $context as $key => $value ) {
$line .= ' ' . $key . '=' . m5_log_value( $value );
}
syslog( LOG_WARNING, $line );
}
error_log() is not the enemy, but it has one argument: the message. You
cannot set a severity, you cannot attach structured fields, and the destination depends on
a php.ini setting you did not choose. A real logger gives you levels, so that an
authentication failure and a PHP deprecation notice can be filtered differently, and
sending your audit trail to syslog or to the same infrastructure as your application logs
is what lets a search span both.
User input writes its own lines unless you stop it
This is the failure that makes a log actively dangerous rather than merely useless. Every line in a log implies a structure. If a single field can contain a newline, that field can create a line of its own with whatever structure the attacker wants.
A failed-login handler is the classic case, because the username comes straight from the request. The naive version:
add_action( 'wp_login_failed', function ( $username ) {
error_log( 'login_failed user=' . $username . ' ip=' . $_SERVER['REMOTE_ADDR'] );
} );
Now submit the login form with a username containing a newline. I used WordPress 7.0.7 on PHP 8.5.6 and the login form directly:
curl -b 'wordpress_test_cookie=WP Cookie check' \
--data-urlencode 'log=ana
[16-Oct-2026 03:00:00 UTC] SUCCESS: admin logged in from 10.0.0.9' \
--data 'pwd=wrong&wp-submit=Log+In&testcookie=1' \
https://example.test/wp-login.php
$ od -c wp-content/debug.log | tail -4
0000140 l o g i n _ f a i l e d u s e r
0000160 = a n a \n [ 1 6 - O c t - 2 0 2 6
The \n is in the file. A grep for SUCCESS finds a line that no
WordPress code wrote, and a human reading the log at 2am has no way to tell it from a real
entry. The attacker has not gained access; they have corrupted your evidence, which is
worse than useless during an incident because it points your investigation at a fabricated
login from a fabricated IP.
The fix is to make every value safe for a single-line format before it is written. Encode what must be preserved and discard what cannot be encoded:
function m5_log_value( $value ) {
$value = is_scalar( $value ) ? (string) $value : wp_json_encode( $value );
$value = str_replace( array( "\r", "\n", "\t" ), array( '\r', '\n', '\t' ), $value );
return substr( $value, 0, 200 );
}
The substr() is not decoration. Without a length cap, a 4 MB username
becomes a 4 MB log line, and enough of those fill the disk on a host that has one. Cap
every field, including the ones you believe a user cannot reach.
Retention is a decision about disk, privacy and evidence
An unbounded log is a scheduled outage. WP_DEBUG_LOG never rotates; a
file-based audit log you wrote never rotates either. On a busy site with a chatty plugin,
the debug log can grow faster than the database. The fix is logrotate or a log handler
that ships lines off the machine, plus a maximum size at which old lines are dropped.
Decide the window before you need it, because three forces disagree. Forensics wants months: an incident that started as a slow account takeover is rarely visible on the day you notice it. Privacy wants days: authentication logs contain personal data, and the regimes you might be under do not let you keep it forever because it is convenient. Disk wants whatever is cheapest. A common compromise is 30 days of full-fidelity application logs shipped off the host, plus 12 months of daily aggregates that keep the counts but not the rows.
Two rules make that defensible. Keep logs where the application cannot rewrite them, because the first thing a competent attacker does with file access is edit the log. And make sure the log itself is not the leak, which means permissions, a location outside the document root, and no secrets, tokens or full card numbers in a log line. If you find yourself logging a token to debug an integration problem, log its first six characters and a hash.
A compromised WordPress looks like a pattern, not a line
No individual entry proves anything. Authenticated users 404 too, and a plugin activation at 3am is a deploy that ran late. What identifies a compromise is a combination across two logs, and the access log is the one most people never look at.
- A spike in 404s to
wp-login.phpis either a broken redirect or a scanner, and the difference is the response code distribution: scanners produce a long tail of unique paths and low traffic per path. - POST requests to a file that should only ever be read. A
POSTto an image inwp-content/uploads/, or to a.txtfile, is a webshell phoning home. Upload directories should not appear in the access log with a POST method at all. - A new administrator created at 3am. Correlate the application log
line for
set_user_rolewith the access log line for the request that caused it. If the triggering request came from an IP that has never logged in before, that is the finding. - An option that changed without a deploy. Record deploys in the same timeline as the log. An option write five minutes after a deploy is expected; the same write on a Tuesday at 04:12 with no deploy is not.
- File mtimes that disagree with the release. A PHP file in
wp-content/plugins/modified after your last deploy, where your deploy did not touch it, is the strongest single signal available.
The shortest useful alert set is five rules, and all five are expressible in a shell script or a log product's query language:
- More than 20 failed logins for one IP in 10 minutes.
- More than 5 failed logins across 5 or more distinct usernames from one IP in 10 minutes.
- Any
set_user_roleor user creation event outside a deploy window. - Any
activated_pluginorswitch_themeevent outside a deploy window. - Any PHP file under
wp-content/with an mtime newer than the last recorded deploy.
Rules 1 and 2 are the same attack at two speeds, and 3 to 5 are the persistence that follows a successful one. Note what is not on the list: a generic "suspicious activity" detector, a total-requests threshold, or anything that fires on 404 volume alone. Every rule above either names a specific event or a rate with a specific unit, which is what makes it possible to tune without turning it off.
Write a small mu-plugin that logs three events from the table in the first section: failed logins, role changes, and plugin activations. Send them through one function that escapes newlines and caps each field at 200 characters. Route them to a file outside the document root.
Then attack your own log with the login form: submit a username containing a newline followed by a plausible forged line, and confirm the forged text appears as literal characters in the file rather than as a new line. Paste both the attacking request and the resulting log line into your notes.
Finally, write the five alert rules above as shell commands against your access log, and run them against a week of real traffic. Record how many times each one would have fired.
Show the solution and the reasoning
The mu-plugin needs a single sink function, because that is the only way to guarantee
every field passes the same escaping. Three separate error_log() calls with
hand-built strings is how the injection comes back six months later.
add_action( 'wp_login_failed', function ( $username ) {
m5_audit( 'login_failed', array( 'user' => $username, 'ip' => m5_client_ip() ) );
} );
add_action( 'set_user_role', function ( $user_id, $role, $old_roles ) {
m5_audit( 'role_change', array(
'user_id' => $user_id,
'role' => $role,
'old' => implode( ',', (array) $old_roles ),
) );
}, 10, 3 );
add_action( 'activated_plugin', function ( $plugin ) {
m5_audit( 'plugin_activated', array( 'plugin' => $plugin ) );
} );
The escaping function is the interesting part, and the reasoning is that you cannot
clean a line after the fact. Once the newline is in the file, no grep can tell your line
from the attacker's. Escaping at write time means the forged content is still there, but
it is visibly \n inside one long line, which a reader can see and a parser
can handle. Capping the length is the other half: an unescaped field that is merely long
is a disk problem, and log injection is a long-field attack that never needs a newline if
the parser has a line-length limit of its own.
m5_client_ip() deserves a warning rather than code. REMOTE_ADDR
is the only value the client cannot set, and it is the address of whatever is in front of
your site, which may be a load balancer or a CDN. X-Forwarded-For is
attacker-controlled unless a trusted proxy overwrites it, so a log line built from that
header records whatever the attacker typed. Log REMOTE_ADDR always, and log
the forwarded value in a separate field marked as unverified.
For the alert rules, expect the login rules to fire on any site with a public login page, because credential stuffing is not personal. That is fine. The value of running them against a week of real traffic is to see the baseline, so that the first genuine spike is obvious rather than indistinguishable from Tuesday. The rules that will fire zero times are the interesting ones: 3, 4 and 5 are quiet until something is wrong, and each needs a documented deploy window so that a legitimate release does not train you to ignore it.
Official reference
The WordPress developer reference for what this lesson uses.
Progress
Mark this lesson done and it stays marked in this browser.