A WordPress install that nobody watches gets taken over in a pattern so consistent it may as well be a product. I worked through one recently, start to finish, and the sequence is worth writing down because the interesting part is not the intrusion. It is how thoroughly the WordPress admin interface hides the result from the person who owns the site.
The install was a four page brochure site running a current WordPress with WooCommerce, on shared hosting. A contact form, some copy, and a commerce plugin added years earlier for a product idea that never shipped and was never removed. That profile, a small marketing site with an old plugin inventory and nobody assigned to it, is most of what gets hit.
What was actually on the server
The compromise did not look like a compromise from the dashboard. It looked like four posts and a clean recent-activity panel.
Querying the REST API instead returned twenty-three published posts. Nineteen of them belonged to somebody else, and they fell into three groups.
An injected user account. A username nobody created, sitting in the author list with publishing rights. Over three days it published five posts in Russian and German advertising offshore betting sites. Standard gambling affiliate spam, written for crawlers rather than humans, rotated across thousands of compromised hosts to pass link equity. Nobody visiting the homepage would ever have seen it.
Twelve cloaking posts published under the real administrator account. Titles of x and cache, and every one backdated to 1 January six years earlier. That backdating is the detail worth stealing. WordPress orders the admin post list by date, so posts stamped years in the past sink below the fold where nobody scrolling for recent activity will find them. The dashboard was not lying, exactly. It was sorting honestly and the attacker knew how.
A defacement, last. A post appeared at a .htm path with a crew name, a line about security being an illusion, and a Telegram handle. That is somebody tagging a box after the monetization phase, which usually means access had been resold or the original operator had extracted what value there was and moved on.
So the sequence runs: access at some unknown point, an injected user for the spam campaign, cloaking pages published with administrator credentials or a forged session, then a public tag once the SEO value was spent. The spam ran for days before the defacement. The defacement sat there for the better part of a week before anyone noticed.
The part that is usually unknowable
On a site like this, the initial access vector is almost never recoverable, and I want to be direct about that rather than construct a plausible story.
Shared hosting, no centralized log shipping, access logs rotated on the host’s schedule, and the relevant window gone. No file integrity monitoring. No alerting on user creation, which is the single control that would have caught the injected account within minutes of its appearing. A plugin inventory with several candidates and no evidence to distinguish among them.
That absence of evidence is the actual finding. It is why the honest answer to “how did they get in” is usually “I cannot tell you,” and why the honest answer to “are they still in” is “I cannot prove otherwise.” Both answers are unacceptable on a system that matters, which is the argument for logging you will never read until the one day you need it.
Why cleaning is the wrong instinct
The reflex is to clean the install. Find the injected files, the modified core files, the database rows, the scheduled tasks, the dropped users, and the persistence mechanism you did not think of. You can do this. On a host with no reliable record of what changed, you will never be certain you finished, and the cost of being wrong is that the whole cycle runs again in a month with the same starting position.
For a brochure site the better answer is to stop running the application. Static HTML has no PHP execution, no database, no admin login, no plugin update cycle, no XML-RPC, and no REST API. Content lives in version control, builds to flat files, and the deployment target has no writable path the web server will execute. There is nothing left to re-compromise and the maintenance obligation goes to zero instead of continuing forever.
WordPress is a reasonable choice for plenty of sites. The complaint is narrower: a four page brochure site was carrying the security and maintenance cost of a full application platform in exchange for an editing convenience used perhaps twice a year. That is a bad trade, and almost nobody who made it has ever gone back and looked at it.
If the site genuinely needs WordPress, the controls worth insisting on are all cheap: automatic core and plugin updates, an allow-list of plugins with a named owner for each, alerting on user creation and role change, file integrity monitoring, log shipping off the host, administrative access behind MFA, and PHP execution disabled in the uploads directory.
The thirty second check
Run this against any WordPress site you have responsibility for. The REST API is enabled by default and it will tell you the truth when the dashboard does not.
curl -s "https://example.com/wp-json/wp/v2/posts?per_page=100&_fields=id,date,slug,author,title&orderby=id&order=asc"
Pipe it through jq if you have it. What you are looking for:
- Any post ID you do not recognize.
- Any
authorvalue that is not an account you created. Then pull/wp-json/wp/v2/users?per_page=100and check the list against the people who should have accounts. - Dates clustered oddly, and dates far in the past on posts nobody wrote. The backdating is the tell.
- Slugs like
x,cache, or anything ending in.htm, which WordPress does not naturally produce.
Also pull https://example.com/wp-sitemap.xml and compare its URL count against the number of pages you believe exist. A mismatch of nineteen is difficult to explain away.
The check takes less time than reading this paragraph, and it is worth running across every site you have any responsibility for in one sitting rather than one at a time. The results are rarely uniform.
Maintenance debt
The lesson generalizes well past WordPress. Every organization has systems in this category: something spun up for a reason that made sense, still running, still exposed, and owned by nobody. A test environment with production data. An old reporting tool. A domain pointed at a host somebody set up years ago and left. These accumulate quietly and they never appear on the risk register, because the risk register gets written by people thinking about the systems they remember.
Maintenance debt is a security control failure with a delay on it. The control that fails is not a firewall rule or a password policy. It is that nobody was assigned to look, so nobody did, for years, until somebody else looked instead.
The fix is unglamorous and it is an inventory with an owner column, where every row carries a name or a scheduled date to shut the thing down. Rows with neither are the ones that end up in somebody’s Telegram channel.