Stopping a hacked WordPress site being hacked again
Cleaning up and adding security plugins kept us maintaining a position. Rebuilding our own site as static pages removed what there was to attack.
You clean up the hacked WordPress site. You install the security plugins and keep everything updated. And you are still running a site where every plugin is somebody else’s code, working on your server with your database password. Every update is a race. Every folder that accepts uploaded files is a place where a file might be written and then run.
That is the argument you are always slightly losing. You can do all of it properly, give each site the least access it needs, run each site in its own separate process, block PHP from running in the uploads folder, check files on a schedule, and you are still defending a position, not holding one.
We had been doing exactly that across a set of sites, and doing it competently, after a painful education. A site that has been compromised accumulates injected addresses, thousands of them, indexed and linked from elsewhere. Then we rebuilt our own site, and the position stopped needing to be defended, because there was nothing left to attack.
What was actually going on
Most of these compromises last because the attacker can write to the server. A WordPress site needs a database, a login page, plugins and places for files to be written, and each is a door. Patching them is a duty that never ends.
There is also a trap in the usual advice. These sites run on nginx, and nginx does not read .htaccess files at all. A great deal of WordPress security advice, and several popular security plugins, protect a site by writing rules into those files. On Apache that works. On nginx they are inert text in the folder they claim to protect. The plugin reports protection on, the file exists, and nothing is enforced.
That is how a folder of site backups, the sort of thing that holds a full copy of the database, can sit publicly readable for a long time while a dashboard says everything is fine. The lesson: verify that a protection is enforced by whatever actually serves the request, not that it was configured somewhere.
What we changed
We rebuilt the site as a folder of HTML, CSS, images and two XML files served by nginx. That does not reduce the attack surface; it removes most of it:
- no database, so nothing to inject into, no password in a config file and no dump to steal
- no PHP running anywhere in the site’s folder, so an uploaded file is a file, not a program
- no admin login, so nothing to brute force and no password to leak
- no plugins, so a flaw found in a popular extension is somebody else’s incident
The last point is the quiet one: the site cannot be written to by a web request at all. Remove the ability to write and a compromise has nowhere to live. The next publish overwrites the whole folder from a build that came from version control.
Every request for a PHP address returns a real “not found”, deliberately, and will keep doing so. That is not suspicion of a language. It is about what the old era left behind: several thousand previously-injected addresses. If those started answering again because a helpful rule caught them, the spam would come back to life in search results. So unknown addresses must keep returning a genuine 404, and PHP must not run even by accident.
It also changed how we operate. Publishing became a swap: the new build is unpacked beside the live folder, checked in place, and two commands exchange them. Rolling back is the same two commands in the other order, and the previous version is still there. Backups became trivial, because the site is the build and the build comes from the repository. The thing to back up is the enquiry database, a far smaller problem than a CMS, its uploads, its database and whatever it has collected since 2019. There is no patching schedule for the website any more, because nothing in it executes.
What it did not fix
We gave up three things, and they are real.
Non-technical editing: there is no admin screen. Content lives in text files in version control and publishing is a commit and a build. For a team that writes its own material that is an improvement. For an organisation where marketing has to publish without engineers, it is a genuine cost and should be priced as one.
Anything that needs a server. The contact form is now a small service of our own, running as its own user with a restricted profile, instead of a plugin. That is better, but it is something we now own and maintain.
Instant changes: a typo fix is a build of about ten seconds and a swap, so in practice a minute, not zero.
The pattern, for anyone running a site that keeps getting hit
Ask what your site actually needs to write to and whether it needs a database at all. If it is mostly read and occasionally changed, by people comfortable with a pull request, that describes a company site and most product pages, and it does not describe a newsroom.
If it really is a content operation, with many authors, frequent publishing, scheduled posts and non-technical staff, WordPress is the right tool and the answer is to run it properly. That means per-site isolation, checking files against the official release rather than scanning for known patterns, and treating updates as a duty rather than a notification.
Where this ends up
Deciding which of those two a system actually is, and then moving it in stages while the business carries on running on it, is the work described under enterprise modernisation.