Why Blocking the Entry Point Was Not Enough
The first round of hardening blocked the REST batch endpoint, patched Core and locked a handful of entry files per site. That closes the door the attacker used, but it leaves three gaps:
- Premium plugins can’t be verified. Elementor Pro, Slider Revolution and similar plugins have no published checksums, and the sites had no licences to update them. On one server, shells had been sitting inside them since February.
- Ten locked files leave the rest open. A webshell that still works can write a new PHP file next to the locked ones, or edit any plugin.
- The site’s own PHP user can write its code. That is what lets WordPress update itself, and it is also what lets a webshell rewrite the site.
So the goal changed from “find every bad file” to “make the code unwritable by anything that runs on the website”.
What Gets Locked
On Linux, chattr +i makes a file or folder immutable. Nobody can change, delete or rename it, root included, until root removes the flag. On a folder, it also stops new files from being created inside it. A small script, wp-lock.sh, sets the flag per site on:
- The webroot folder itself and every file directly in it, including
wp-config.php,.htaccess,index.phpandrobots.txt. wp-admin/andwp-includes/.wp-content/plugins/,themes/andmu-plugins/.- The
wp-contentdrop-ins:index.php,advanced-cache.php,object-cache.phpanddb.php.
These stay writable, because WordPress needs them at runtime: uploads/, cache/, languages/ and the WP Rocket config folder.
wp-lock.sh lock <app|all>
wp-lock.sh unlock <app>
wp-lock.sh status all
On the second server, this replaced a lock on ten files per site. Those ten files, watched with auditd, had already done their job: the watches showed hundreds of blocked write attempts per minute from the site’s own PHP user, which is how I found the code that kept reinfecting index.php.
No PHP from Writable Folders
The folders that stay writable are where an attacker would drop a new file, so NGINX refuses to run PHP from any of them. One rule per site covers everything in wp-content except plugins and themes, which are locked instead:
location ~* /wp-content/(?!plugins/|themes/)[^?]*\.(php[0-9]?|phtml|phar|pht|phps)(/|$) {
deny all;
}
One site had its own location ^~ /wp-content/uploads/ block. A ^~ prefix match skips every regex location, so the rule above never applied there, and that block needed its own deny inside it. The install script probes a PHP path under uploads on every site afterwards, and each must answer 403.
Living with a Locked Site
The lock is meant to stop changes, and it does, including mine. Every update, plugin install and deploy now needs a person in the loop:
wp-lock.sh unlock <app>
# update, install or deploy
wp-lock.sh lock <app>
Unlocking leaves a marker file, and the daily report alerts when a site has stayed unlocked for more than 24 hours, so a forgotten unlock gets noticed within a day.
Tools that assume they can write broke in instructive ways:
- An installer failed safely. A migration that moved every site’s mail settings into a must-use plugin stopped at the first install, because
mu-plugins/was immutable. Every site rolled back with no leftovers. - WordPress hung forever. The next attempt reached
wp plugin delete. Withplugins/immutable, WordPress fell back to asking for FTP credentials and waited for an answer that never came. The tool now lifts and restores the lock itself, runs every WP-CLI call under a timeout, moves plugins out instead of deleting them, and runs the same restore on Ctrl+C as on a failure. - Locking touches every file. Changing the flag updates each file’s
ctime, so the first report after a lock listed thousands of “PHP files written” on every site. The content was unchanged, and it happens once after every unlock and lock.
On the first server, every site also has DISALLOW_FILE_MODS, so the dashboard no longer offers plugin installs and updates that would fail against the lock anyway.
Protections That Never Ran
Locking everything meant checking every must-use plugin on every site, and two of them had been doing nothing.
Must-use plugins that never loaded
After the first cleanup, the mu-plugins folders were owned by root. RunCloud’s file ACLs keep the site’s PHP user from reading root-owned files, so WordPress skipped them without an error. On one server the batch-endpoint block had not loaded on 14 sites since the cleanup. On the other, the plugin that routes mail through Amazon SES had not loaded on seven sites, and their mail went out without it. Changing the owner back to the site user fixed both, and the folders stay locked.
A block that another plugin undid
The must-use plugin from the wp2shell note returned 401 for anonymous batch requests from a rest_pre_dispatch filter at priority 0. ACF Pro hooks the same filter at priority 10 and returns null, which threw the 401 away. On every site with ACF Pro, the block did nothing. The fix was to register it last:
add_filter('rest_pre_dispatch', function ($result, $server, $request) {
if (strpos($request->get_route(), '/batch/v1') !== false && !is_user_logged_in()) {
return new WP_Error('rest_forbidden', 'Authentication required for batch API.', ['status' => 401]);
}
return $result;
}, PHP_INT_MAX, 3);
Afterwards all 20 sites on that server answered 401 to an anonymous POST to /wp-json/batch/v1. The NGINX rule from that note only blocks the rest_route= form, so on those sites the /wp-json/batch/v1 path had no working block at all.
A Daily Report That Always Sends
The weekly checksum email from the first cleanup only wrote when something failed. I replaced it with one script, shared by both servers, that runs from root cron every morning and always sends a Telegram message, either “ALL OK” or “ATTENTION” with the full report attached. A silent morning then means the check itself is broken.
Per site, it checks:
- The homepage status, and the homepage as Googlebot for spam words and unexpected Japanese text, because cloakers only show spam to bots.
- Core checksums and the
index.phphash. - The lock state: partly unlocked, or unlocked for more than 24 hours.
- The mail must-use plugin and its config file.
- Administrator-level users from a raw database query, against a baseline.
Across the server, it checks:
- The opcache size per PHP version, because a container rebuild resets it.
- New or changed PHP in writable folders, shell signatures, must-use plugins, drop-ins and
.htaccessallow-lists, against a baseline. - wordpress.org plugin checksums with
--strict, which also reports added files. - PHP files written since the last run.
- A live probe that PHP under
uploadsstill answers403. - Attacker indicators in the access logs.
The bot token lives in a root-only file, never in the script. --no-send runs the check without Telegram, and --init accepts the current state as the new baseline after I have verified a change. Its first real run found an obfuscated loader in a languages/ folder that every earlier sweep had missed.
- Cron, every morning One script, one config per server
- Per site: status, Googlebot view, checksums, lock, mail, admins
- Server: opcache, baselines, strict plugin checksums, NGINX probe, logs
- Telegram, every day
- ALL OK A quiet day still sends a message
- ATTENTION Report attached, every alert explained
Keeping False Alarms Rare
A report that cries wolf gets ignored, so I traced every alert in the first week to its cause and fixed either the site or the check:
| Alert | Cause | Change |
|---|---|---|
| Spam word on two sites | “speCIALISt” contains “cialis” | Spam words match whole words only |
| Japanese text served to Googlebot | A Japanese language school’s real name | That site skips only the Japanese test; the spam-word test still runs |
| Wordfence firewall files changed | Wordfence rewrites them on every sync. They start with exit() and __halt_compiler(), so nothing after that line runs |
Checked for that first line instead of a hash. The real rules file is still hashed |
A changed .htaccess in wp-content |
An image plugin rewrote its own WebP rules during a routine self-check | Re-baselined with --init, not excluded |
| “opcache ok” on the first run | The script had raised the limit in an ini file PHP did not load | Verified in the running PHP, not in the file |
The .htaccess case was the one I thought about longest. Excluding that path would have silenced the alert, but that watch is exactly what catches an attacker’s .htaccess. Accepting the verified file as the new baseline keeps the check, and the next change to it alerts again.
Limitations
- Uploads and the database stay writable. The lock protects code. NGINX stops PHP from running under
uploads, and the report reads admins from the database, but injected content in a post is outside both. - Root can still unlock. The lock defends against the website, not against someone with root on the server.
- Updates need a person. That is the point, but it also means security updates wait for someone to run unlock and lock.
- It is a compensating control. The unlicensed premium plugins are still unpatched. The lock and the report make that risk smaller, and only replacing or licensing those plugins removes it.
Lessons
Lock the whole code base
With a few files locked, a working webshell can still add a new one. With the whole code base immutable, it can’t add or change any PHP.
Test that each protection runs
Two of mine had been doing nothing, one because of file ownership and one because of another plugin’s hook. Probe each layer from outside, separately.
Register security filters last
On a shared hook, the last callback wins. PHP_INT_MAX keeps another plugin from undoing the result.
Build tools that expect a locked server
Anything that touches code has to lift and restore the lock itself, and every WP-CLI call needs a timeout.
Always send the report
A message every day proves the check ran. Re-baseline a verified change instead of excluding the path, so the check that matters keeps working.
With the lock and the morning report in place, I no longer recheck these servers by hand. The unlicensed plugins still need to be replaced or licensed.