天才クールスレンダー美少女になりたい

チラシの表(なぜなら私はチラシの表にも印刷の上からメモを書くため)

部内イベント用CTFの作問をした話

私が所属するコンピュータ系サークルのTSGで、「TSG十種競技」というイベントが開催されました。

10の競技の総合成績でTSGの王を決める本イベント、私はCTF(web)の作問を担当しました。

問題

drive.google.com

# Hash of Flag

ハッシュ値元に戻らず。

## 詳しい説明

webサーバに隠されたフラグを盗んでください。

フラグの隠し場所は環境変数ファイルシステム上など、問題によってさまざまです。今回の場合、サーバの`/flag`にフラグの書かれたテキストファイルが配置されています。通常であれば、その内容を読むことはできません。しかし、このwebサービスには脆弱性があります。その脆弱性を利用し、フラグを盗んでください。

今回はwebサービスソースコード一式が与えられているので、ソースコードを読んで脆弱性を探しましょう。この問題において、読むべきファイルは`server.js`です。

Docker Compose用のファイルも配布されているので、配布ディレクトリで`docker compose up`を実行すればローカルでもwebサービスが動きます。必要であれば活用してください。

問題の本質ポイントはserver.jsで、さほど長くないので全体を載せます。サーバの/flagにあるテキストファイルを読み取ってください。

難しい問題ではないので、webに慣れてる人なら5分くらいで解けるんじゃないでしょうか。

const execCommand = require("node:util").promisify(require("node:child_process").exec)
const path = require("node:path")

const fastify = require('fastify')()
fastify.register(require("@fastify/static"), {
  root: path.join(__dirname, 'public'),
})

fastify.get("/hash", async (request, reply) => {
  const filename = request.query.file
  if (typeof filename !== 'string') {
    reply.code(400)
    return { error: 'Invalid request' }
  }
  if (filename.includes(`"`) || filename.length > 12) {
    reply.code(400)
    return { error: `Invalid filename: ${filename}` }
  }
  try {
    const { stdout } = await execCommand(`md5sum "texts/${filename}"`)
    return { hash: stdout.split(" ")[0] }
  } catch (e) {
    reply.code(500)
    return { error: e.stderr }
  }
})

const start = async () => {
  try {
    await fastify.listen({ port: 3456, host: "0.0.0.0" })
  } catch (err) {
    fastify.log.error(err)
    process.exit(1)
  }
}
start()


以下、ネタバレゾーン。















writeup: TSG decathlon CTF 2023 "Hash of Flag" · GitHub


問題自体の解説は全部writeupに書いたので、ちょっとした裏話をします。

フラグについて

TSGCTF{d0n'7_cry_0v3r_h45h3d_f14g}、つまりdon't cry over hashed flagです。「覆水盆に返らず」に相当する英語のことわざ"don't cry over spilt milk"のパロディですね。覆水盆に返らず、ハッシュ値元に戻らず。hashed flagはハッシュ化されたフラグを意味するつもりですが、英語的に正しいかは知りません。

writeupに書いたように、ディレクトリトラバーサルでフラグのハッシュ値を入手することができます。そこから正解に到達することはできませんが。まさに「嘆いても仕方がない」ということです。

作問

そもそも、「私ごときがCTFの作問をしていいのか」みたいな気持ちは正直ありました。未だに初心者脱出できてないですし。ただ、確かに私は新規性のある難しい問題を作れないのですが(なのでTSG CTFの問題は作れない)、今回は部内向けのお祭りということで引き受けました。

これはCTFer向けの大会ではありません。第一の目的は、普段あまりCTFをやらない人に楽しんでもらうことです。実際、もともと聞いていた要件は「そのジャンルの経験者なら15分くらいで解ける問題」でした。

しかし、初心者のことを考えると自明問題しか出せない気がする一方、経験者向けに一捻り入れたら初心者には難しすぎるとも思うんですよね。

悩んだ結果、後者に寄せることにしました。たとえ解けなくても、CTFって面白いと思ってほしいんですよね。あまりに自明な問題、たとえば工夫のいらないXSS一発って面白くなくないですか? もちろん慣れた人なら5分もせずに解けちゃうと思うんですが、それは私の作問能力の限界なので仕方ない。



私はCTFのweb問題のことを本格ミステリだと思っているので、ミステリ小説を書くつもりで作問しました。

まず、「webジャンルの攻撃手法まとめ」みたいな記事を読み、想定解としてコマンドインジェクションを使うことにしました。
コマンドインジェクションの例によく登場するのはセミコロンなどで複文にするケースですが、変数埋め込みやコマンド置換だってれっきとしたコマンドインジェクションです。盲点というほどの新規性はありませんが、一捻りはできますね。

ファイル名をダブルクオーテーションに入れて、バリデーションでクオートを弾けば、シェルのwordから抜け出すのは不可能になります。もう使えるのはコマンド置換($(command)とか`command`とか)だけ。

さて、コマンド置換で任意コマンドを実行できるとして、そこからフラグをどう入手するか。今回はエラーメッセージを利用しました。
フラグをcatして、その出力をファイル名に指定してコマンドを実行することで、File not found: FLAG{hogehoge}みたいなエラーを発生させることができます。このエラーをそのまま返せば、そこからフラグを入手可能です。なので、「コマンドを実行し、エラーの場合はstderrをそのまま返す」という実装にしました。

コマンド置換とエラーメッセージの利用、これで二段階の問題ができました。そこまで悪くないのでは? という気持ちです*1

最後に決めるべきは、何のコマンドを使うかです。とりあえずファイル名を指定して何かの処理を実行するコマンドならなんでもいいんですが、md5sumにしました。深い意味はありません。

セキュリティ

コマンドインジェクションというのは任意コマンドを実行できるということです。なので、たとえばフラグのファイルが削除されないような権限設定をしました。冷静に考えるとpwnを出題するときと同じことしてますね。あと、変なことされると嫌なのでクエリの文字数も12文字までに制限しました。制限した方が問題も面白くなるはずだし。

「ガワ」の時短実装レシピ

CTFのweb問題を作るなら、当然webサービスっぽいものを実装しなければなりません。問題の本質部分以外、今回だとフロントエンドなどにあまり労力をかけたくないですよね。バックエンドはさほど考えること多くないんですけど、フロントエンドは素朴な要件でも地味にJSの実装が面倒になりがち。あと、あまり殺風景な見た目だとダサい気もするのですが、CSSを書く労力も減らしたいところです。

そこで、JSの実装はpetite-vueを使いました。超軽量なvue、コンパイルのいらないsvelteみたいな感じで、雑にデータバインディングができるのが便利です。ちなみに、類似ライブラリにはalpine.jsがあります。

github.com

CSSはsakuraを利用しました。いわゆる「クラスレスCSSフレームワーク」の1つで、CSSを読み込むだけで見た目をいい感じにしてくれます。

github.com

劇的ビフォーアフター

感想

CTFの問題をちゃんと作るのは初めてで、学びがありました。

実際に参加した人から「かなり面白かった」や「気づいた瞬間脳汁ドバドバ」などの反応をもらったのが一番嬉しかったです。難易度はともかく、パズル的な面白さには結構力を入れたので。
解けなかった人にも楽しんでもらえたのも良かったです。

*1:面白いミステリって大抵2段階以上の仕掛けがあるんですよね。CTFでもそれは同じなんじゃないでしょうか。