July 23, 2026
WP2Shell (CVE-2026–63030): The WordPress Core Bug That Let Anyone Become Admin
On July 17, 2026, a security researcher named Adam Kues (from Searchlight Cyber’s Assetnote team) publicly disclosed a vulnerability chain…

By Mr. Spider
11 min read
On July 17, 2026, a security researcher named Adam Kues (from Searchlight Cyber's Assetnote team) publicly disclosed a vulnerability chain nicknamed wp2shell.
A completely anonymous person on the internet with no username, no password, no special tools, and no plugins installed can take full control of a default WordPress website.
That's what makes this different from every other WordPress security headline you've read. Usually, WordPress hacks happen because someone installed a bad plugin or used a poorly coded theme. This time, the bug lives inside WordPress Core, the engine that runs every single WordPress site on the planet.
Within hours of the patch being released:
- A public proof-of-concept exploit appeared on GitHub
- Security vendors confirmed attackers were already exploiting it in the real world
- Attackers were planting web shells (backdoor programs) on compromised servers
- CISA (the U.S. Cybersecurity and Infrastructure Security Agency) added it to their Known Exploited Vulnerabilities catalog on July 21, 2026
The Basics: Key Information at a Glance
Before we dive deep, here's everything you need to know at a glance:
- Name → wp2shell
- CVE IDs → CVE-2026-63030 (REST API batch route-confusion) + CVE-2026-60137 (SQL injection)
- Type → Pre-authentication Remote Code Execution (RCE) chain
- Component affected → WordPress Core (not a plugin or theme)
- Authentication needed → None
- User interaction needed → None
- Discovered by → Adam Kues, Searchlight Cyber / Assetnote
- Disclosed publicly → July 17, 2026
- Severity → Critical
- Added to CISA KEV → July 21, 2026
Which WordPress Versions Are Affected?
Fully Vulnerable (Both Bugs = Full Server Takeover)
- WordPress 6.9.0 through 6.9.4
- WordPress 7.0.0 through 7.0.1
If your site runs any of these versions, an attacker can go from zero access to full server control with a single HTTP request.
Partially Vulnerable (SQL Injection Only)
- WordPress 6.8.0 through 6.8.5
These versions have the SQL injection bug but not the route confusion bug that makes the full attack chain work. Still serious. Still needs fixing.
Not Affected
- WordPress 6.7.x and below
What's the Fix?
WordPress released three emergency security patches on the same day (July 17, 2026):
WordPress even took the unusual step of forcing automatic updates on affected sites even ones that had auto-updates turned off. That's how serious this was.
Bottom line: Update to 7.0.2, 6.9.5, or 6.8.6 right now.
Background: What You Need to Know Before Understanding the Attack
Before we walk through the attack, let's explain a few WordPress features that are involved.
What Is the WordPress REST API?
WordPress has a built-in system called the REST API. It's a way for apps and websites to talk to WordPress using standard web requests (HTTP). For example:
GET /wp/v2/posts— give me a list of blog postsPOST /wp/v2/users— create a new user
Some of these endpoints are public (anyone can read posts). Others are protected (you need to be logged in as an admin to create users). WordPress checks your identity and permissions before processing each request.
What Is the Batch Endpoint?
Since WordPress 5.6, there's a special endpoint called /wp-json/batch/v1. It's a convenience feature that lets you bundle multiple API requests into a single HTTP call.
Instead of making 5 separate requests to get posts, create a user, and check settings, you can send all 5 at once in one "batch" request, and WordPress processes them all and returns all the results together.
Internally, WordPress keeps two lists to manage this:
- A list of the sub-requests (what the user asked for)
- A list of the handlers (the code that processes each request, including permission checks)
These two lists are supposed to stay perfectly aligned. Request #1 goes with handler #1, request #2 with handler #2, and so on.
What Is SQL Injection?
SQL injection is one of the oldest and most dangerous web security bugs. It happens when user input (something a visitor types or sends) gets inserted directly into a database query without proper cleaning.
Think of it like this: imagine a form that asks for your name, and the website uses that name in a database query like:
SELECT * FROM users WHERE name = ' userInput ';SELECT * FROM users WHERE name = ' userInput ';If someone types Sql Injection payload as their name, the query becomes:
That semicolon ends the first query and starts a new one → one that deletes your entire user table. SQL injection lets attackers read, modify, or delete data in your database.
What Is a Web Shell?
A web shell is a small piece of code (usually PHP) that an attacker places on your server. Once in place, it lets them run any command on your server through a web browser → like opening a terminal on your machine from anywhere in the world.
How the Attack Works:
Now that you understand the building blocks, let's walk through the entire attack.
Step 1 — Break the Mailroom (Route Confusion)
The analogy
Imagine a mailroom clerk with two clipboards. Clipboard A has the list of letters (requests). Clipboard B has the list of departments (handlers) each letter should go to.
The clerk matches them up:
- Letter 1 → Accounting
- Letter 2 → HR
- And so on.
Now imagine one letter is badly addressed → it can't be read. The clerk puts it in the "error" pile. But here's the bug: the clerk forgets to cross that letter off Clipboard A. Now Clipboard A has one extra entry compared to Clipboard B. Every letter after the bad one slides up one slot on Clipboard B → and ends up in the wrong department.
What's really happening
The attacker sends a single batch request to:
/wp-json/batch/v1/wp-json/batch/v1with several sub-requests inside it.
One sub-request is deliberately malformed → its URL is crafted so WordPress can't parse it properly, and it gets logged as an error. Because of the bug (CVE-2026–63030), WordPress fails to keep the two internal lists in sync. After the error, every subsequent sub-request gets processed using the wrong handler → including the wrong permission checks.
Why this matters
A request that should have been blocked (because it requires admin login) now gets processed by a public handler that doesn't check permissions. The attacker just walked past a locked door by tricking the security guard into watching the wrong hallway.
Step 2 — Smuggle Malicious Input Past Security (SQL Injection)
The analogy:
Now that the attacker's request is being processed by the wrong handler, it arrives at a security checkpoint that checks IDs. But because of Step 1, the wrong checkpoint is running → the one that doesn't check IDs at all. The attacker walks right through with a suitcase full of contraband.
What's really happening:
- Normally, when a request reaches the WordPress "posts" endpoint, its parameters are checked against a strict list of allowed formats.
- For example, a parameter called
author__not_inmust be a clean array of numbers (like[1, 2, 3]), representing user IDs to exclude from results. - Because of the route confusion, the attacker's request reaches this endpoint without going through the schema validation.
- This means the attacker can send
author__not_inas a raw text string instead of an array of numbers. - Inside WordPress's query engine (
WP_Query), there's a second bug (CVE-2026-60137): the code only sanitizes this value if it's already formatted as an array. - Since the attacker's value is a string (not an array), it skips sanitization entirely and gets pasted directly into the SQL query.
Here's what that looks like in code:
// WordPress expects something like this:
// author__not_in = [1, 2, 3]
// Result: AND wp_posts.post_author NOT IN (1,2,3)
// The attacker sends this instead:
// author__not_in = "1) AND 1=0 UNION ALL SELECT ... -- -"
// Result: the raw string goes straight into the SQL query
$where .= " AND{$wpdb->posts}.post_author NOT IN ({$author__not_in})";// WordPress expects something like this:
// author__not_in = [1, 2, 3]
// Result: AND wp_posts.post_author NOT IN (1,2,3)
// The attacker sends this instead:
// author__not_in = "1) AND 1=0 UNION ALL SELECT ... -- -"
// Result: the raw string goes straight into the SQL query
$where .= " AND{$wpdb->posts}.post_author NOT IN ({$author__not_in})";- The actual payload the attacker sends looks like this:
author__not_in=1) AND 1=0 UNION ALL SELECT <23 columns> -- -
per_page=-1author__not_in=1) AND 1=0 UNION ALL SELECT <23 columns> -- -
per_page=-1- The
per_page=-1disables WordPress's query splitting, ensuring the full injection executes. - Now the attacker can make the database return any data they want → including fake WordPress posts that don't actually exist.
Step 3 — Plant Fake Data in Memory (Object Poisoning)
The analogy:
The attacker just told the database to return fake records. WordPress takes those records and converts them into internal objects → like a clerk filling out forms based on what the database said. The problem is, WordPress then caches those objects in memory for the rest of the request. It won't check the database again. So once the fake objects are in memory, every part of WordPress that needs those "posts" gets the fake version instead of the real one.
What's really happening:
- When
WP_Queryruns, it takes the rows returned by the database and converts them intoWP_Postobjects. - These objects are then cached in memory for the rest of the request.
- Any code that later asks WordPress "give me post #123" gets the cached object → which, thanks to the SQL injection, is the attacker's forged version.
- The attacker controls everything about this fake object: its status (published, draft, etc.), its type (post, page, revision, etc.), and its parent post (which other post it belongs to).
- These internal fields are normally impossible for an anonymous visitor to set.
Step 4 — Trick WordPress Into Saving the Fake Data (oEmbed Write-Back)
The analogy:
A fake record sitting in a filing cabinet inside someone's head doesn't help the attacker. They need that fake record to be physically filed in the real filing cabinet (the database). So they find a way to trick the office into re-saving everything from memory back to disk.
What's really happening:
- The attacker abuses oEmbed → a WordPress feature that automatically turns URLs (like YouTube links) into rich previews and caches those previews.
- When oEmbed thinks a cached preview is outdated, it re-saves the related post, pulling data from the in-memory cache.
- Since the in-memory cache now contains the attacker's forged object, oEmbed faithfully saves the attacker's fabricated status, type, and parent fields into the real database.
- The fake data is now permanent.
Step 5 — Steal an Admin's Identity (Nested Saves and Identity Hijacking)
The analogy:
The attacker now has fake files in the real filing cabinet. But they need to do something only an administrator can do → create a new admin account. They can't do that as an anonymous person. So they set up a clever chain reaction: they create a file that, when opened, forces the office manager to temporarily pretend to be the CEO. While the manager is "being the CEO," they issue an order that wouldn't normally be allowed.
What's really happening:
This is the most complex part of the chain. Here's what happens:
- The attacker deliberately creates a "parent loop" → a fake post that claims to be its own parent (its own ancestor). This is an impossible state that shouldn't exist.
- WordPress has a built-in safety routine that detects and repairs these loops. When it finds the loop, it triggers nested save operations → one save happening inside another.
- The first save publishes a forged "Customizer changeset." Customizer changesets remember which user originally created them. When published, WordPress briefly acts as that user. The attacker forges the changeset to point to a real administrator's user ID.
- While WordPress is momentarily "wearing the admin's identity," the second, nested save is engineered to trigger WordPress's REST API loader again → starting a brand-new internal request while the fake admin identity is still active.
Step 6 — Create a New Admin Account
The analogy:
Remember, the attacker sent a batch of requests at the very beginning. One of those requests was "create a new admin user." The first time WordPress processed it, it was correctly rejected → "sorry, you're not logged in, you can't create users." But now, thanks to Step 5, that same request gets re-processed while WordPress thinks the current user is a real administrator. This time, the permission check passes. The new admin account is created.
What's really happening:
- The batch request the attacker sent at the start included a
POST /wp/v2/userssub-request to create a new administrator account. - It was rejected the first time (401 Unauthorized).
- But inside the nested save from Step 5, WordPress re-evaluates requests while the admin identity is active.
- The user creation now succeeds → WordPress returns
201 Created, and the attacker now has a legitimate administrator account they can log in with.
Step 7 — Take Over the Server (Remote Code Execution)
The analogy:
The attacker now has an admin key to the building. They walk in, go to the server room, and install their own equipment. From this point on, they have complete control.
What's really happening:
- Once you have administrator access to WordPress, getting full server control is straightforward and well-documented.
- WordPress admins can install and activate plugins.
- A plugin is just PHP code.
The attacker:
- Creates a small plugin that contains a web shell (a PHP file that accepts commands and runs them on the server)
- Uploads it through the WordPress admin panel
- Activates the plugin
- Now has arbitrary command execution on the server
From here, they can read any file, access the database directly, pivot to other systems on the network — complete compromise.
The name "wp2shell" comes from this:_ WordPress to Shell → going from a WordPress URL to a server shell in one attack chain._
How to Check If Your Site Is Vulnerable
There are three approaches, from quick to thorough.
1. Check Your Version Number (Fastest)
Go to your WordPress admin panel and look at the version in the footer or at Dashboard → Updates.
- If you're on 6.9.0–6.9.4 or 7.0.0–7.0.1: you're vulnerable to the full chain.
- If you're on 6.8.0–6.8.5: you have the SQL injection bug.
- If you're on 6.7.x or below: you're not affected.
Caveat:_ Some themes, reverse proxies, or WAFs can hide or change the displayed version. This is a quick check, not a definitive one._
2. Test the Batch Endpoint (More Reliable)
The vulnerable endpoint is reachable in two ways. Test both:
# Test the "clean" URL
curl -s -o /dev/null -w "%{http_code}\n" <https://your-site.com/wp-json/batch/v1>
# Test the query-string version (often missed by WAFs)
curl -s -o /dev/null -w "%{http_code}\n" "<https://your-site.com/?rest_route=/batch/v1>"# Test the "clean" URL
curl -s -o /dev/null -w "%{http_code}\n" <https://your-site.com/wp-json/batch/v1>
# Test the query-string version (often missed by WAFs)
curl -s -o /dev/null -w "%{http_code}\n" "<https://your-site.com/?rest_route=/batch/v1>"If either returns anything other than 404 or 403, the endpoint is exposed and your site may be vulnerable.
3. Time-Based Blind SQL Injection Test (Most Accurate)
This method tests whether the SQL injection is actually reachable by measuring response time:
# Normal request (baseline)
curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" \
"<https://your-site.com/?rest_route=/batch/v1>"
# Request with SLEEP(5) injected
curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" \
"<https://your-site.com/?rest_route=/batch/v1>"# Normal request (baseline)
curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" \
"<https://your-site.com/?rest_route=/batch/v1>"
# Request with SLEEP(5) injected
curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" \
"<https://your-site.com/?rest_route=/batch/v1>"The detection relies on this SQL fragment:
(SELECT 1 FROM (SELECT SLEEP(5))x)(SELECT 1 FROM (SELECT SLEEP(5))x)If the request with SLEEP(5) consistently takes 5 seconds longer than the baseline, the injection point is reachable and your site is vulnerable.
Proof of Concept
A working POC exploit is available on GitHub for security researchers and penetration testers:
Repository: https://github.com/Icex0/wp2shell-poc
Check if a Target Is Vulnerable
./wp2shell.py check <http://127.0.0.1:8080/>./wp2shell.py check <http://127.0.0.1:8080/>
The wp2shell.py check command verifies that the target WordPress instance is vulnerable by detecting the route-confusion behavior associated with CVE-2026-63030.
The output confirms the vulnerability after analyzing the batch endpoint and matching the expected indicators of the flaw.
Deploy a Shell on a Vulnerable Target
./wp2shell.py shell http://127.0.0.1:8080/ -i./wp2shell.py shell http://127.0.0.1:8080/ -i
The wp2shell.py shell command demonstrates successful exploitation by creating an administrator account and deploying a web shell on the vulnerable WordPress site.
After authentication, an interactive shell is established, confirming remote command execution on the target server.