Sultan Kautsar Get a quote

Locking and Watching WordPress

Cleaning a compromised server is one job. Keeping it clean is another, especially when it runs old WordPress sites with premium plugins that nobody can update or check against an official copy. After the wp2shell cleanup, I wanted a setup where even a working webshell could not change or add a single line of PHP, and where I would hear about anything odd the next morning.

This note covers the lock I put on every site's code, the NGINX rule that goes with it, what the lock broke, two protections it turned out had never run, and the daily report that watches all of it.

8 min read

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.php and robots.txt.
  • wp-admin/ and wp-includes/.
  • wp-content/plugins/, themes/ and mu-plugins/.
  • The wp-content drop-ins: index.php, advanced-cache.php, object-cache.php and db.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. With plugins/ 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.php hash.
  • 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 .htaccess allow-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 uploads still answers 403.
  • 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.

  1. Cron, every morning One script, one config per server
  2. Per site: status, Googlebot view, checksums, lock, mail, admins
  3. Server: opcache, baselines, strict plugin checksums, NGINX probe, logs
  4. Telegram, every day
    • ALL OK A quiet day still sends a message
    • ATTENTION Report attached, every alert explained
The daily check on both servers.

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.

Related Notes

  1. Cleaning Up a wp2shell Compromise

    How an unauthenticated WordPress Core exploit spread across seven sites on one RunCloud server, the signs it leaves behind, the cleanup recipe I repeated on each site, and the changes that stop it from happening again.

  2. Two Servers, 50 Sites, One Bug

    How wp2shell, a WordPress Core exploit, reached about 30 old WordPress sites across two RunCloud servers, what the attacker left behind, how I cleaned up and contained it, and the hardening I now recommend.

  3. Spam That Outlives the Hack

    Why Google kept almost a million gambling spam URLs after the WordPress sites were clean, and how I mapped them from access logs, answered them with a tested 410 rule, and told Search Console to drop them.

  4. When the Server Fell Over

    How a Facebook crawler flood took every WordPress site on a shared server down, why a full opcache looked like an attack, and the caps, crawler caching and checks that keep one site from sinking the rest.

Next step

Have a project in mind? Let's talk.

Tell me what you have and what you need. I'll usually reply within one working day with how I'd approach it.