Labs

CVE-2026-63030: WordPress Core Pre-Auth Admin Takeover ("wp2shell")

Docker lab Account TakeoverSQL Injection

Updated July 28, 2026 · Written by PWNMI — see About.

Most WordPress compromises come through a plugin, which is exactly why this one is worth taking seriously: CVE-2026-63030 chained with CVE-2026-60137 lives in WordPress core itself, requires no plugin, no authentication, and no user interaction — just two requests to a stock install. CISA lists it as actively exploited in the wild, and it carries a CVSS score of 9.8.

Affects WordPress 6.9.0–6.9.4 and 7.0.0–7.0.1 for the full chain. Environment and exploit script from vulhub's own WordPress lab, tracing to sergiointel/wp2shell-poc.

What you'll practice: understanding how a parsing desync between two internal data structures can silently redirect a request to the wrong handler, following a multi-stage exploit chain that uses blind SQL injection not just to read data but to actually forge application state, and confirming a claimed account takeover the only way that actually counts — logging in yourself, independently, and landing on a real authenticated page.

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.

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

This starts WordPress 6.9.4 with MySQL 8.0; WordPress installs itself automatically on first boot. Give it a minute, then reach it on its own IP:

IP=$(docker inspect $(docker compose ps -q web) --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')

Understanding the vulnerability

WordPress's REST API supports "batch" requests — bundling several sub-requests into one call. WP_REST_Server::serve_batch_request_v1() tracks two parallel internal arrays as it processes each sub-request: $matches (which route and handler matched) and $validation (whether that sub-request's parameters passed validation). When a sub-request fails to parse, its error gets appended to $validation — but without a corresponding entry landing in $matches. The two arrays quietly fall out of sync, and a later sub-request in the same batch can end up dispatched using the route and handler that were actually matched for a different sub-request entirely.

That alone is a logic bug. It becomes a SQL injection primitive when combined with a second, separate issue in WP_Query: the author__not_in parameter is only cast to integers when it arrives as an array — a plain string value gets interpolated directly into the generated NOT IN (...) SQL clause. Nesting one batch request inside another lets an attacker send GET /wp/v2/categories?author_exclude=<payload>. The categories endpoint doesn't even register an author_exclude parameter, so nothing validates or strips it — and the route-confusion bug causes this request to actually run under the posts endpoint's handler, which does map author_exclude straight into the vulnerable author__not_in variable. The result: pre-authentication, blind SQL injection, no login required anywhere in the chain so far.

From there, the exploit doesn't just read data with the injection — it writes it. It seeds a few real WordPress posts, then uses the SQLi to return forged post rows via UNION SELECT, which WordPress caches as if they were genuine WP_Post objects. Structured correctly, these forged objects masquerade as a pending Customizer "changeset." Publishing that changeset briefly makes WordPress call wp_set_current_user() with an existing administrator's ID while saving it — and two nested POST /wp/v2/users requests riding along in that same window pass their capability checks, creating a brand new, fully legitimate administrator account.

Exploitation

The lab includes poc.py from vulhub, covering the full chain end to end. Start with its non-destructive check mode — it only calibrates a timing oracle and creates nothing:

python3 poc.py http://$IP:80 --check
[+] Target is vulnerable (baseline: 0.024s, delayed: 0.437s, SQL delay: 0.4s)

A clean, consistent ~0.4-second delay matching exactly the requested SLEEP duration is real signal, not network jitter. Now run the full chain:

python3 poc.py http://$IP:80

The script walks through each stage — seeding posts, extracting the table prefix and an admin's user ID via blind SQLi, publishing the forged changeset — and finishes by printing a new administrator's credentials.

Don't stop at the script's own "verified" message. Confirm it yourself, independently, with a completely fresh session:

JAR=/tmp/jar.txt
curl -s -c $JAR "http://$IP/wp-login.php" > /dev/null
curl -s -c $JAR -b $JAR -w '%{http_code}\n' -X POST "http://$IP/wp-login.php" \
  --data-urlencode "log=<printed-username>" \
  --data-urlencode "pwd=<printed-password>" \
  --data-urlencode "wp-submit=Log In" \
  --data-urlencode "redirect_to=http://$IP/wp-admin/" \
  --data-urlencode "testcookie=1"

A 302 is WordPress's own successful-login signal. Then, with that same session, request a page only a real administrator can reach:

curl -s -c $JAR -b $JAR "http://$IP/wp-admin/users.php" | grep -o "<title>[^<]*</title>"

200, title Users ‹ ... — WordPress, and the exploit-created username actually present in the rendered list — that's the real proof. Not a script's claim, an independently-obtained authenticated session that lands exactly where a genuine administrator would.

Cleanup

This is the richest cleanup case on this site, because the chain deliberately builds real, persistent WordPress objects to work at all: the three oembed_cache posts used to carry the forged data, the published Customizer changeset that triggered the privilege check, and — the one that actually matters — the new administrator account itself. All of it needs to go, and in a specific order: remove the account's elevated access before you delete supporting objects, so there's no window where cleanup itself is racing against whatever the account could still do.

# from an authenticated real-admin session (not the forged account):
wp user delete w2s_<random> --path=/var/www/html --allow-root --yes
wp post delete <oembed-cache-post-id-1> <oembed-cache-post-id-2> <oembed-cache-post-id-3> --path=/var/www/html --allow-root --force

(wp-cli commands shown since that's the cleanest way to do this from inside the container; the same actions are available through wp-admin/users.php and wp-admin/edit.php if you'd rather do it through the UI.) The forged Customizer changeset itself doesn't need separate removal — once it's published, WordPress treats it as a normal (if strange) settings-save event, not a standing object with its own persistence beyond the posts backing it.

On a real engagement, this is exactly the kind of finding where you document the created account's ID and creation timestamp before deleting it — the client's own audit trail needs proof of what was created and when, not just your word that you cleaned it up.

Tear the lab down when you're done:

docker compose down -v

Lessons worth keeping

  • State desynchronization is its own vulnerability class. Neither array here was individually wrong. The bug is that two data structures meant to stay aligned quietly didn't, and nothing detected the drift before it mattered. Anywhere a system tracks two related pieces of state in parallel is worth asking: what happens if one updates and the other doesn't?
  • Blind SQL injection isn't just a read primitive. This chain uses SQLi to write forged application state that a completely different subsystem (the Customizer) later trusts and acts on. Don't scope a SQLi finding as "just data exposure" without checking what else in the application reads from that same table.
  • The only real proof of an account takeover is using the account. A script reporting success, a database row existing, or a "user created" API response are all claims. Logging in yourself, independently, and reaching a page only that role can reach is the actual verification — everything else is a hypothesis.

Next step

For another real, KEV-listed CVE verified the same rigorous way, see CVE-2025-54068 (Laravel Livewire) or CVE-2026-34197 (Apache ActiveMQ). For the concepts behind the isolation pattern used here, see Docker for Security Labs. For more on the backdoor-account technique this chain ends with, and the discipline of tracking what you'd need to revert, see Maintaining Access.