A few days ago someone created an administrator account on this blog. Not by guessing my password, and not through an outdated plugin – through a hole in WordPress core itself. The reason it worked on my supposedly auto-updated site is a bit embarrassing, so I’m writing it down.
It started with a Wordfence email sent while I was asleep: “An admin user with the username wpenginebot was created outside of WordPress.” Email wpenginebot@wpengine.com – not me, not a plugin being clever. A brand-new administrator, created at 02:20.
What got in
The account was created by wp2shell, a pre-authentication remote code execution chain in WordPress core, published on July 17 and mass-exploited within a day. No plugin required, no login required. It chains two bugs: CVE-2026-63030 is route confusion in the REST API batch endpoint (/wp-json/batch/v1) – a crafted batch of requests desyncs the internal arrays so one sub-request runs in the wrong security context, skipping the authentication check. CVE-2026-60137 is a SQL injection reachable once past that gate. Together they let an anonymous attacker forge an admin account, and from there the intended endgame is uploading a malicious plugin for full code execution. The batch endpoint has shipped with every WordPress since 5.6, back in 2020.
| Disclosed | 17 Jul 2026, by Searchlight Cyber |
| Vulnerable versions | WordPress 6.9.0–6.9.4 and 7.0.0–7.0.1 |
| Fixed in | 7.0.2 / 6.9.5 / 6.8.6 |
| My version at the time | 7.0.0 – in range |
In my logs the first probe landed on July 18 at 18:20, from an Azure IP with User-Agent wp2shell. Then came hundreds of POSTs to the batch endpoint – 669 from a single address alone – still arriving days later as the scanning went internet-wide.
18/Jul 18:20 20.120.169.215 POST /wp-json/batch/v1 207 first probe, UA: wp2shell
20/Jul 02:17 91.202.233.61 POST /?rest_route=/batch/v1 207 x669 from this IP
20/Jul 02:20 91.202.233.61 POST /?rest_route=/batch/v1 207 admin "wpenginebot" created
22/Jul 01:27 45.79.167.23 POST /wp-json/batch/v1 207 still scanning, days later
HTTP 207 “Multi-Status” is the batch endpoint answering. On a vulnerable core, that response is the exploit landing.
“But I keep everything updated”
WordPress shipped the fix – 7.0.2 – on July 17, the same day the bug went public. I was on 7.0.0. The patch existed before the attack even started; it just never installed.
For weeks my site had been quietly failing its automatic updates with “the update cannot be installed because some files could not be copied.” My core files are owned by root as a hardening measure, and WordPress’s own updater runs as the web-server user, which can’t overwrite them – so every automatic update bounced off. I’d seen the nag emails and filed them under “deal with it later”. The patch sat there, available, for five days. And none of my OS patching mattered here – this lives in WordPress’s own code, not in anything apt will ever touch.
- 17 Jul – fix released, my site emails me: “7.0.2 is available, please update!”
- 18 Jul, 18:20 – first wp2shell probe. Still on vulnerable 7.0.0, the update keeps failing.
- 20 Jul, 02:20 – admin wpenginebot forged. Wordfence emails the alert.
- 22 Jul – patched to 7.0.2, account removed, updater fixed properly.
What saved me
And yet nothing actually happened. No defaced pages, no spam, no shell, no data touched. Partly luck, partly one old habit.
The attacker had a valid admin account and came back about sixteen hours later to use it. The whole session is nine seconds in my logs:
attacker 87.120.93.46 -- the whole session
18:55:13 POST /xmlrpc.php 403 blocked
18:55:13 GET /wp-admin/ 444 connection dropped
18:55:14 GET /wp-admin/ 444 connection dropped
18:55:15 GET /my-account/ 200 WooCommerce login page (public)
18:55:19 POST /my-account/ 302 authenticated... as a customer
18:55:20 GET /wp-admin/ 444 connection dropped
18:55:22 GET /wp-admin/ 444 dropped -- gave up
444 is nginx dropping the connection without a response. Years ago I restricted /wp-admin and /wp-login.php to my home IP address at the nginx level – anyone else gets the connection dropped. So the attacker had working admin credentials but no door to use them on. They logged in through the only public form left – the WooCommerce account page – landed on a customer profile with no admin powers, tried /wp-admin a few more times and gave up. The endgame of this exploit chain is “log into wp-admin and upload a malicious plugin”, and that’s exactly the step the IP restriction blocked.
The alarm that worked, and the one that didn’t
Three security plugins were watching this site. Wordfence caught it – it flagged the rogue admin and sent the email that kicked all of this off. Sucuri had the audit trail – the account creation and the successful login, timestamped, with the attacker’s IP. Jetpack saw nothing, despite brute-force Protect and “Verify your identity” both being switched on. That turns out to be structural, not a configuration mistake:
- Protect blocks brute force, not correct passwords. The attacker set their own password on the forged account and logged in cleanly on the first try. Zero failed attempts.
- The login came through the WooCommerce account form, not wp-login.php – a different authentication path that Jetpack’s identity check isn’t wired into.
- Even if it had fired, the verification email goes to the account’s own address – wpenginebot@wpengine.com. The attacker would simply confirm themselves.
Identity verification protects your account from someone else logging in. It does nothing when the attacker owns the account end to end.
Cleaning up
- Deleted the rogue admin, reassigning its content.
- Rotated every secret key and cleared all sessions – invalidating any cookie the attacker might still hold. Logs me out too; small price.
- Blocked /wp-json/batch/v1 at the nginx level. I don’t use it, and it’s the entire attack surface here.
- Firewalled the worst scanning IPs, and added a fail2ban jail that bans any IP touching the batch endpoint on the first hit – the probes haven’t stopped (100+ on cleanup day alone).
- Verified the rest: no shells on disk, no injected code in the database, no tampered settings, no other rogue users.
The actual root cause – the updater that couldn’t update – is now fixed properly. Core is on 7.0.2, and a small scheduled job applies future security releases as root, so the next emergency patch lands instead of bouncing off my own hardening.
Takeaways
A patch you can’t install is not a patch. I had automatic updates “enabled” and felt covered, while the mechanism silently failed for weeks and I trained myself to ignore the one signal warning me. Check that updates are landing, not just switched on.
Layers buy you time. The only reason a bad patch window didn’t turn into a bad week is a boring IP restriction I set up years ago and never thought about again.
I did the forensics and cleanup with an AI agent driving my terminal. The collapse of technical barriers I wrote about before cuts both ways – it’s what lets a botnet forge admin accounts across the internet in an afternoon, and it’s what let me reconstruct the whole timeline, confirm the blast radius and lock the door again in an evening.
Running WordPress 6.8–7.0? Update to 7.0.2 / 6.9.5 / 6.8.6 now, then check the update actually applied. Look for an admin you didn’t create, and for POSTs to /wp-json/batch/v1 in your access logs.
