The WordPress Attack That Needs No Login

You are reading this because your website runs on WordPress. You probably do not think about it much. It works, your content is there, and you trust the software to handle the heavy lifting. That trust is what makes today’s news so unsettling.

Security researchers have uncovered a flaw in WordPress core that lets an attacker take full control of a site without any login credentials, without installing plugins, and without changing any settings. The attack works on a default installation out of the box. You can hit it from anywhere on the internet and walk away with administrative access.

The vulnerability chain has two identifiers: CVE-2026-63030 and CVE-2026-60137. The security community calls it wp2shell. The name is not dramatic. It describes exactly what happens. An attacker gets a shell on your WordPress installation, and the code that does it lives entirely inside the core software.

This matters because WordPress powers a large share of the web. Millions of sites run on it without ever touching a line of code. The people who manage those sites often rely on third-party plugins to add functionality, and they assume that keeping those plugins updated is their main security job. wp2shell proves that assumption wrong. The flaw lives in WordPress itself, the part everyone installs and no one audits.

The good news is that the fix is simple. WordPress 7.0.2 and 6.9.5 contain the patch. If your site runs a vulnerable version, update it now. The bad news is that exploitation has already been observed in the wild, and many sites are still running unpatched versions.


The Architecture Problem

To understand how wp2shell works, you need to know how WordPress is structured. It ships in three layers.

WordPress Core is the base application. It handles routing, the query layer, user management, the REST API, and the admin dashboard. The WordPress project maintains this code directly.

Plugins and themes sit on top of core. They are third-party code that extends behavior. A large share of WordPress vulnerabilities have historically resided in plugins because they are written by many different developers with varying levels of expertise.

wp2shell is significant precisely because it lives entirely in core. It requires no plugins, no special configuration, and no authentication. An anonymous attacker can exploit a default installation running any affected version.

The attack chain starts at the REST API and ends with remote code execution through plugin upload. The bridge between those two points involves a request-handling bug, a SQL injection, and a series of clever manipulations of WordPress’s own object system.

CVE-2026-63030: Route Confusion in the Batch Endpoint

WordPress exposes a REST API under /wp-json/. Public routes like /wp-json/wp/v2/posts let anyone read published content. Protected routes like /wp-json/wp/v2/users require authentication and specific capabilities. An anonymous caller to the users endpoint gets a 401 Unauthorized response.

The batch endpoint at /wp-json/batch/v1 lets a client send several sub-requests in one HTTP call. The server parses each member, matches it to a route handler, runs permission checks, and executes the callbacks.

The vulnerable logic lives in WP_REST_Server::serve_batch_request_v1(). The function maintains three parallel arrays indexed by position.

The $requests array holds the parsed request objects, or a WP_Error if a member failed to parse. The $matches array holds the route-and-handler tuple for each request. The $validation array holds validation results.

The vulnerable code reduces to two loops:

foreach ( $requests as $single_request ) {
    if ( is_wp_error( $single_request ) ) {
        $validation[] = $single_request;
        continue; // No corresponding $matches entry was added.
    }
    $matches[] = $this->match_request_to_handler( $single_request );
}

foreach ( $requests as $i => $single_request ) {
    $match = $matches[ $i ];
    // Request i may now execute with a LATER request's handler.
}

The first loop walks through each request. When a member is a WP_Error, the code appends to $validation and continues, but it never adds a placeholder to $matches. From that point on, $matches is one element shorter than $requests.

The second loop walks $requests by index $i and reads $matches[$i]. Every valid request after the error reads the handler tuple that belongs to a later request.

Consider this concrete example. The $requests array contains three elements at indices 0, 1, and 2. Element 0 is a WP_Error. Elements 1 and 2 are valid requests A and B. The first loop produces $matches with only two elements at indices 0 and 1, corresponding to handlers for A and B.

The second loop processes index 1 (request A) and reads $matches[1], which is handler B. Request A executes with the wrong handler. The entire handler tuple shifts, including its permission callback, execution callback, and schema.

An unsanitized request can therefore reach a handler it was never validated for. The batch endpoint becomes a way to bypass permission checks by exploiting the index misalignment.

CVE-2026-60137: SQL Injection in WP_Query

Route confusion alone does not give an attacker code execution. They need a sink. The sink is WP_Query, the object that turns query variables into SQL and runs it against the wp_posts table.

REST controllers translate their public parameters into WP_Query variables. The posts controller exposes author_exclude and maps it internally to the query variable author__not_in. The REST schema says author_exclude is an array of integers, and under normal routing the request is validated against that schema before it ever reaches WP_Query.

Route confusion delivers the request to the posts handler without validating it against the posts schema. The author__not_in parameter can now arrive as a raw, attacker-controlled scalar instead of a clean integer array.

The vulnerable code in WP_Query looks like this:

if ( ! empty( $query_vars['author__not_in'] ) ) {
    // Sanitizing runs only if already an array. This is the flaw.
    if ( is_array( $query_vars['author__not_in'] ) ) {
        $query_vars['author__not_in'] = array_map(
            'absint',
            $query_vars['author__not_in']
        );
    }

    // Cast happens here: the raw string survives.
    $ids = implode( ',', (array) $query_vars['author__not_in'] );

    // SQL injection.
    $where .= " AND {$wpdb->posts}.post_author NOT IN ($ids) ";
}

The sanitization only runs if the value is already an array. A scalar string never reaches the array_map call, so it stays uncleaned. The implode cast converts it to a string and injects it directly into the SQL query.

An attacker can now perform boolean- and time-based blind SQL injection against the database. This is reachable pre-authentication through the batch endpoint route confusion.

From SQL Injection to Remote Code Execution

At this point the attacker can influence a SELECT, but MySQL does not execute shell commands. The attack needs to move from query manipulation to application control. The mechanism is object hydration.

When WP_Query runs a query, it converts each returned database row into a WP_Post object and stores those objects in the request-local object cache. After that, calls to get_post( $id ) return the cached object instead of reading the database again.

The SQL injection controls what the query returns. The WP_Post objects that WP_Query builds and then caches hold attacker-chosen field values. For the rest of that PHP request, any code that calls get_post() for those IDs receives the forged object and treats it as a real post.

The attacker needs WordPress to act on the forged data. They need legitimate core code that reads one of these poisoned objects and writes it back to the database. The write matters because it turns the attacker’s chosen fields into a saved record that later core logic treats as genuine, and every save fires WordPress’s post lifecycle hooks, which the rest of the chain relies on.

The oEmbed Write Path

oEmbed is the WordPress feature that turns a plain URL into rich embedded content. To avoid re-fetching that content on every page load, WordPress stores the generated embed HTML in the database and in its cache. The caching path is what the attack abuses.

When oEmbed thinks a cached embed is stale, it re-saves the post with just an ID and new content, and lets WordPress supply the remaining fields from get_post(). Since get_post() now returns the poisoned object, that step writes the attacker’s chosen status, type, and parent into the database.

From Poisoned Write to Administrator Creation

Writing a post does not create an administrator. The attacker needs two post-save side effects, and their timing is the catch. One save makes WordPress temporarily act as an administrator, but that identity switches on inside a publish routine and switches back the moment it ends. The save that re-enters the REST API must run inside that window. Two separate writes cannot do this, so the second save must be nested inside the first.

A stock safety routine supplies that nesting. The poisoned posts form forbidden parent loops, and WordPress repairs a loop by re-saving its posts, one repair running inside another. That nesting keeps the administrator identity active across the save that matters.

The first useful save publishes a Customizer changeset. A changeset remembers which user created each setting, and when it publishes, WordPress briefly switches its current user to that remembered user before saving. Normally that is safe, because the user was checked when the setting was created. Here the changeset is forged, so the remembered user is an administrator the attacker picked. For that moment, WordPress is running as an administrator.

The second useful save starts the REST loader. WordPress builds some internal event names by joining a post’s status and type, and core hooks its REST loader to an event named parse_request. By setting a poisoned post’s status and type so they join into parse_request, the attacker makes an ordinary save start the REST loader while the current user is still that administrator.

The REST loader starts handling a fresh request even though the original one was still running, and the new nested request shares the same current user. A request to create a user, which the attacker included in the original anonymous batch and which was first rejected with 401, is re-evaluated as the administrator and now succeeds with 201 Created. The attacker has created an administrator account without ever logging in.

Once the attacker has an administrator account, they can install plugins that allow remote code execution.

How the PoC Works

The proof of concept at https://github.com/Icex0/wp2shell-poc implements the full chain in Python. It does not require any third-party dependencies, which makes it easy to audit and understand.

The tool supports three commands: check, read, and shell.

The check command performs a non-destructive vulnerability scan. It prints passive WordPress markers and public version hints, then sends a benign batch marker probe. A vulnerable batch implementation returns HTTP 207 with the route-confusion marker pattern containing parse_path_failed, block_cannot_read, and rest_batch_not_allowed.

The marker probe works by exploiting the same index misalignment. The malformed /// request creates parse_path_failed. A /wp/v2/posts request acts as a batch-allowed spacer. A /wp/v2/block-renderer/... route is not batch-allowed but returns block_cannot_read if its handler is reached anonymously. On vulnerable builds, the parse error shifts the batch handler arrays out of step, so the spacer request is dispatched under the block-renderer handler. Fixed builds keep the arrays aligned, so this exact three-part pattern should not appear for the crafted probe.

The read command extracts data through SQL injection. By default, extraction uses the union technique, which forges a fake WP_Post row via UNION and reads its title back from the REST response. The payload uses the same /wp/v2/posts/999999 source route with orderby=none and per_page=500 so the fake row survives as a rendered post. One request reads one value.

The command falls back to error technique when the target reflects MySQL errors, leaking approximately 15 bytes per request through EXTRACTVALUE or UPDATEXML. The final fallback is blind technique, which performs a boolean binary search using about eight requests per character. It reads the posts collection X-WP-Total header as the true/false signal and needs no reflected value.

The shell command executes code. With administrator credentials supplied via --user and --password, it logs in and uses WordPress plugin upload behavior to deploy a webshell. Without credentials, it first runs the pre-auth SQLi-to-admin bridge, logs in as the generated administrator, then uploads the plugin shell.

The uploaded webshell is locked behind a random path and a per-run token. It is removed automatically when the session finishes. When the pre-auth bridge creates an administrator, that generated account is removed automatically after the shell session ends.

Detection and Mitigation

Detection guidance has been published by Elastic Security Labs and Eye Security. Their IoCs cover the HTTP request patterns used in the exploit chain, including the malformed batch requests and the SQL injection payloads.

The fix is straightforward. Update to WordPress 7.0.2 or 6.9.5 if your site is on the 6.9 branch. Until then, block both /wp-json/batch/v1 and the rest_route=/batch/v1 query parameter at the edge, or require authentication for the batch endpoint via the rest_pre_dispatch filter.

The affected version ranges are precise. WordPress 6.8.5 and earlier are not affected. Versions 6.9.0 through 6.9.4 and 7.0.0 through 7.0.1 are vulnerable. Sites running older or newer versions are safe.

Why This Matters Beyond WordPress

wp2shell demonstrates a pattern that extends far beyond any single platform. A request-handling bug in a core component can cascade through multiple layers of abstraction to produce remote code execution. The route confusion, the SQL injection, the object poisoning, the nested saves. Each step is small on its own. Together they form a chain that bypasses every intended security boundary.

The attack does not exploit weak passwords or misconfigured servers. It exploits the design of WordPress itself. The batch endpoint assumes that parsing errors do not shift handler indices. WP_Query assumes that REST schema validation always runs before query variable normalization. The oEmbed cache assumes that poisoned objects will never reach the database. These assumptions are reasonable in isolation. They break when chained together.

Security teams should treat this as a reminder that vulnerability research is increasingly about chains, not individual bugs. The days of patching a single CVE and calling the job done are over. Attackers combine flaws across components, and the combined effect exceeds the sum of its parts.

Update your WordPress installations. Audit your batch endpoint exposure. And remember that the most dangerous vulnerabilities are often the ones hiding in plain sight, inside the code you trust most.