Earlier this summer I got a message from a client I had deployed some changes for a few weeks before: someone hacked our site. They could not log into the admin, and over FTP they had found files that meant nothing to them.
My stomach dropped. I do not handle their maintenance, but what if I had broken something during that deployment? And there was nothing obvious to blame on that site: a custom theme, only a handful of plugins and all of them up to date, WordPress on a version that looked perfectly recent. When none of the usual suspects fit, you are left with an uncomfortably short list.
Spoiler: it was not my change. But the road to that answer was instructive enough to write about.
Because at that moment the site was running completely normally. And yet it held nine copies of one backdoor, two fake plugins and three admin accounts nobody had created. The only reason the login did not work was that the attacker had replaced the login page with their own version – blowing their own cover. Otherwise nobody would have noticed for a long time.
How did they get in?
It was not a plugin and it was not a weak password. It was a flaw in WordPress itself – in the very core that looked anything but outdated.
The site was running 6.9.4; the fix landed in 6.9.5. The entire difference between a vulnerable and a patched site was one digit at the end – the one nobody looks at when glancing into the dashboard.
The flaw is tracked as CVE-2026-63030, nicknamed wp2shell, and it allowed arbitrary code to be run on the server without logging in. No password guessing, no phishing – just a correctly crafted request.
The logs confirmed that was the way in: they contained a series of requests with the characteristic pattern of that attack, and immediately afterwards those three foreign admin accounts appeared in the database.
The fixed versions came out on 17 July – which is also the moment the flaw became public knowledge. Within hours, attackers started probing for it automatically on anything they could find – and here they got through.
What was on the site
- nine copies of the same file manager, scattered across WordPress system folders, the theme, uploads and plugins, and named to look like part of WordPress
- two fake plugins – one posing as a well-known forum plugin, able to pull further code off the internet
- a file disguised as
.gitignore, a boring technicality nobody ever opens; forty kilobytes of foreign code inside - three administrator accounts nobody had created.
What to do after the cleanup
Even after you delete every planted file and remove the foreign admins from the database, you are still not done. That vulnerability let the attacker run arbitrary code on the server – so they could have calmly opened the config file and walked away with the database password, or stashed something somewhere I never found.
That is why it is worth replacing core and plugins with clean copies from the official source and changing every password – not just the admin one, but the database, FTP and SSH, the WordPress security keys, mail sending and the keys to everything connected to the site. What is left is the database, which cannot be swapped out and has to be checked as well.
And above all: no one-click plugin does this for you. It is hours of work and investigation. Time that goes entirely into getting the site back to where it already was.
Why the old version was still there
Looking back, something else surprised me more than the malware itself. The site was on 6.9.4 – but I had worked on it a few weeks earlier and locally I had the seven series. How come the live site was still on six?
The answer is simple: automatic updates were not switched on for that site. WordPress installs them by itself and most sites are patched before the owner even notices. Had they been running here, the site would have fixed itself within days of the patch and nobody would have heard about any of this.
So how come the newer version I had been working on did not make it to production?
I used All-in-One WP Migration: you do the work on a copy of the site, package the result and upload it to the live one. That package contains the database and the wp-content folder – theme, plugins and uploads, but not WordPress core itself.
And honestly – transferring the core would not have helped either. I was working on the newer series, but the flaw was in both: everything from 6.9.0 to 6.9.4 and from 7.0.0 to 7.0.1 was vulnerable. The only thing that could have saved that site was the security update that was not running on it.
So here is my recommendation: leave automatic security updates switched on. These are not the big releases that change things – they are the small numbers at the end that only close holes.
How to switch it on
By default WordPress installs security updates itself and you do not have to do anything. If someone switched them off, you turn them back on in wp-config.php:
define( 'WP_AUTO_UPDATE_CORE', 'minor' );
The minor value does exactly what you want: security and small releases install themselves, major ones stay up to you. (true enables the big ones too, false disables everything.)
It is also worth checking whether this is not sitting somewhere in that file:
define( 'AUTOMATIC_UPDATER_DISABLED', true );
That disables all automatic updates regardless of anything else, and it tends to be the most common reason a site never fixes itself.
And watch out for caching
Then there is a lesser-known trap. WordPress has no clock of its own – it runs scheduled tasks only when someone opens a page. And checking for updates is exactly such a task. But on a site with full page caching, the visitor gets ready-made HTML and PHP is never reached. So the check does not happen either.
The better the caching, the more rarely it runs – and on a well-tuned site the check might not happen for several days.
The fix is to stop relying on visitors and give it a regular nudge from the server. In your hosting control panel (or via crontab) you set up a task that asks for it on its own:
*/5 * * * * wget -q -O - "https://yoursite.com/wp-cron.php?doing_wp_cron" >/dev/null 2>&1
That solves it. WordPress now gets a nudge every five minutes regardless of whether anyone visited.
Optionally you can add this to wp-config.php as well:
define( 'DISABLE_WP_CRON', true );
That stops tasks from firing on page load, which is now pointless and only costs performance. It is not a requirement – the server cron works without it. The order is what matters: DISABLE_WP_CRON on its own, without a cron on the server, would disable scheduled tasks entirely – the exact opposite of the point.
What to take away
- Check that automatic security updates are actually running. Not that they should be – that they are. They can be switched off without anyone knowing.
- Updated plugins do not mean updated core – and neither does deploying from an up-to-date system.
- You will not spot a compromise by how the site looks. Here it surfaced by accident, because the attacker broke the login page. Otherwise the only way to catch it is if someone is looking.
The sites I manage are taken care of so their owners never have to worry about any of this. Version checks, updates, backups – everything runs quietly in the background.