I wrote a version of this a few days ago and called it a SQL injection cleanup. I need to correct it, because the vulnerability now has a name, two CVE numbers, and a worse classification than I gave it. wp2shell is not a SQL injection. It is an unauthenticated remote code execution chain, and that distinction changed the job. A read-only SQL injection leaves its evidence in the database. Remote code execution can leave a file on disk. Here is the corrected, longer version, including the filesystem sweep I went back and ran once I understood what I was actually looking at.
It is a chain, and the second link is the scary one
wp2shell is two separately tracked bugs that are individually survivable and jointly critical.
- CVE-2026-60137 is a SQL injection in
WP_Querythrough theauthor__not_in/author_excludeparameter. On its own it is read-only, and it affects WordPress 6.8 through 7.0.1. - CVE-2026-63030 is a route and handler confusion in the REST API
/batch/v1endpoint, affecting 6.9 through 7.0.1. It lets an unauthenticated request slip past the authorization check that would normally stop it.
Chained, the batch confusion smuggles the SQL injection past the schema that would have sanitized it, and a read-only database bug becomes remote code execution with no account and no user interaction. It is fixed in 6.8.6, 6.9.5, and 7.0.2. WordPress shipped 7.0.2 on July 17 and turned on forced automatic updates, which tells you how they rated it.
Targeted is not breached
The alert everyone got said, in effect, “your site may have been targeted.” That sentence does a lot of quiet damage, because most people read “targeted” as “hacked.” They are not the same word. A campaign like this sprays the same request at thousands of sites. Being sprayed at is the default, not the emergency. The emergency, if there is one, is whether the payload actually did anything, and you do not get to guess at that. You go and look.
There is a platform detail worth stating up front. These sites run on a host whose containers are immutable, so the classic end goal of this attack chain, dropping a persistent web shell into the code, does not survive a container restart and would show up against version control anyway. That closes the scariest door. As I will get to, it does not close every door.
Patch first, then actually look
Order matters. Before any investigation, every site went onto a patched release (6.8.6, 6.9.5, or 7.0.2, depending on its branch) so the hole was shut. An audit of an unpatched site is a photograph of a moving target. One site turned out to still be behind on its branch, and that reordered its whole priority list: for that one, patching was not step three of the cleanup, it was step zero.
The fingerprints in the database
Most of the sites were genuinely clean. One told a different story, and it told it in three linked records.
The centerpiece was a forged customize_changeset post. Customizer changesets are normally transient drafts; this one was marked publish, authored by a user ID that does not exist on the site, and backdated to 2020-01-01 00:00:00. That timestamp is a tell. It appeared on exactly two rows in the entire database and nowhere else. The second was a stray post with post_type = request and post_status = parse, neither of which is a real WordPress value, carrying the same 2020 timestamp. Inside the changeset was an injected nav_menu_item pointing at https://example.invalid/, hung off a post ID that does not exist in the posts table at all.
Here is roughly what I ran against each database, read only, to surface all of this:
-- admins, and any account created since the campaign began SELECT ID, user_login, user_email, user_registered FROM wp_users WHERE user_registered >= '2026-07-17' ORDER BY user_registered DESC; -- hidden admin capability granted directly in usermeta (role-table bypass) SELECT user_id FROM wp_usermeta WHERE meta_key = 'wp_capabilities' AND meta_value LIKE '%administrator%'; -- the SQLi marker SELECT COUNT(*) FROM wp_postmeta WHERE meta_value LIKE '%example.invalid%'; -- non-standard post statuses and forged changesets SELECT post_status, COUNT(*) FROM wp_posts GROUP BY post_status; SELECT * FROM wp_posts WHERE post_date = '2020-01-01 00:00:00';
I also checked for orphaned usermeta, throwaway admin usernames in the prefix_hex shape these campaigns like to generate, untracked files in the version-controlled plugin directories, and autoloaded options carrying eval, base64_decode, or gzinflate signatures. All clean.
A probe, not a payload
The example.invalid detail is the most reassuring thing in the whole investigation, and it is easy to miss. .invalid is a reserved top level domain under RFC 6761. It resolves nowhere, on purpose, forever. An attacker who wanted to do damage would have pointed that menu item at a real malicious URL. Pointing it at a guaranteed dead address is what you do when you are testing a capability, not exercising it. The whole cluster reads as “can I write to this database?” answered yes, and then nothing further.
That is genuinely good news, but read it precisely. It says the write path worked. Once I knew the underlying bug was remote code execution and not a read-only injection, “the write worked” stopped being something I could shrug at, because the same capability that writes a database row can write a file.
A database audit is not enough when the bug is RCE
This is the part my first writeup got wrong by omission. I treated it as a database problem, audited the database, and moved on. But remote code execution can write files, and a database audit will never see a file. So I went back and swept the filesystem on every site.
Two things mattered on this host specifically. The code directories are immutable and version controlled, so anything dropped there both fails to persist and shows up against Git, which my earlier untracked-file check already covered. But wp-content/uploads is a persistent, writable volume that is not in version control. That is the one place a web shell could land and stay. So that is where I looked.
The sweep, per site:
- Every PHP file under uploads, flagged if it was modified during the incident window or if it contained any of the usual execution primitives (
eval,assert,base64_decode,gzinflate,shell_exec,system,passthru, and friends). wp core verify-checksumson every site, to catch any modified or added core file.
It threw a lot of noise before it threw a verdict, and the noise is worth understanding because it will trip you up too. Modern themes compile their templates into PHP under uploads. In my case a Sage theme writes compiled views into uploads/cache and uploads/storage/framework/views, so a naive “recently modified PHP in uploads” check lights up with dozens of hash-named files that are completely legitimate and freshly rewritten on every render. The discriminator is content, not timing. Once I filtered to files that actually contained execution primitives, or that landed outside the known compiled-template directories, the list collapsed to two files across every site, and both were real: an All In One Security settings file that opens with __halt_compiler() so its stored data never executes, and an empty WP All Import functions stub. Core checksums passed everywhere. No shell, no modified core.
Rotating the right thing
Cleanup on the one injected site was small and surgical: delete the two forged posts and the one orphaned meta row, then confirm the fingerprint queries all returned zero. No database restore, which would have cost days of real content to remove three junk rows.
The credential response is where I see people reach for the wrong tool. The reflex after any WordPress incident is “rotate the salts.” But the salts live in wp-config.php, a file on disk, and a SQL injection reads the database, not the filesystem. The salts were never in reach. What was in reach is the password hashes in wp_users. So the fix that actually maps to the exposure is resetting passwords. Rotate the salts too, because it invalidates every existing session, but know which one is covering which risk. On a managed host there is usually a one-command way to rotate the environment’s keys and salts without editing config or deploying; use it.
SSO sessions in the database
One of these sites authenticates through a SAML single sign-on layer built on SimpleSAMLphp. Single sign-on usually feels like it takes credentials off the table, and mostly it does: the actual password is entered at the identity provider and never touches the site. But I went looking for where SimpleSAMLphp stores its session state, and on this site it was configured with a SQL store, in the same database. Which means the SSO session records were sitting in a table a database-level attack could read.
That is worth internalizing as a general point. “We use SSO” does not automatically mean “no session data in the database.” Check where your service provider keeps its sessions. In this case every stored session had already expired, and the key rotation invalidated the rest, so the practical exposure was closed. But the blast radius was larger than “just WordPress users,” and I would not have known that without looking.
The one thing neither audit can tell you
So the database is clean and the filesystem is clean. What neither can tell me is whether the injection read anything, or whether an exploitation attempt even reached the vulnerable code before I patched. Reads do not leave rows, and a request that ran and returned does not leave a file.
That evidence lives in the web server access logs, and for this vulnerability it is refreshingly specific. The published high-fidelity indicator of a successful hit is an HTTP 207 or 200 Multi-Status response to the /batch/v1 endpoint, frequently paired with a user agent string that literally contains wp2shell or rezwp2shell. Those are host-side logs, and on a platform with rotating, per-container logs, the complete incident window is a host artifact I cannot fully retrieve on my own. So I did the only honest thing: handed the host the exact signature to grep for and asked them to check.
What I actually concluded
Across every site I look after: most never touched, one injected with an inert capability probe and since cleaned, one that needed patching before anything else, all now on fixed releases, credentials rotated, sessions invalidated. Database clean, filesystem clean, core intact. No evidence of persistent compromise anywhere. One open question, whether any request read data during the window, handed to the party who can actually answer it.
The lesson I am keeping is the one my first draft missed. Classify the bug before you scope the audit. I called it a SQL injection, so I audited a database, and I was auditing the wrong surface for half the risk. It was remote code execution, which meant the disk was in scope too. Incident response is not knowing everything. It is checking the surface that actually matches the threat, being specific about the boundary of what you know, and refusing to turn “I found no evidence of X” into “X did not happen.” Those are still different sentences.
Leave a Reply