こんにちは、8ayacです🐝今回は「ISC BugHunt101 CTF 2020」で作問した3問のWriteupを書きました。全部Webです。
ISC BugHunt101 CTF 2020とは
ISC BugHunt101 CTF 2020は、筆者の通う学校の生徒向けにプライベートで開催したCTFです。最近、同校の生徒を対象に「バグハント入門」というテーマのオンライン講義を行う機会があり、その一環で開催しました。講義は合計400分で、最後の100分には各々チームを作ってCTFに挑戦してもらいました。参加者のほとんどは、それまでCTFに触れたことがなかったと思います。
作った問題のリポジトリ
作った問題のソースコードや設定ファイルは、以下に置いてあります。自分でもデプロイできるようになっているので、実際に解いてみたい人はご活用ください。
https://github.com/8ayac/iscbughunt101ctf
[Web Easy (17/40teams solved)] SQLi101
問題文
Can you login?💉
http://sqli101.chall.8ayac.com/
Attachment: app.py
問題アプリの概要
配布されたapp.pyを読むと、アプリの機能は以下のようにまとめられます。
GET /: トップページPOST /login: ログイン機能- POSTパラメータ
passwordの値を使ってログイン処理を実行する - ログインに成功すれば、セッション
flagの値にflagがセットされる。
- POSTパラメータ
GET /logout- セッション
flagの値を削除して、トップページに遷移する。
- セッション
GET /admin- セッション
flagに値がセットされていれば、その値を表示する。 - 値がセットされていなければ、トップページに遷移する。
- セッション
実際に問題のトップページにアクセスすると、以下のようなログインページが表示されます。
最終目標の分析
機能を分析した結果、ログインに成功すればflagが手に入りそうだとわかりました。
ソースコードを読んでみると、ログイン処理にはis_admin関数が使われていることがわかります。
@app.route('/login', methods=['POST'])
def post_login():
password = request.form['password']
if is_admin(password):
session['flag'] = os.environ.get('FLAG')
return redirect(url_for('get_admin'))
else:
return redirect(url_for('get_index'))
is_admin関数は、以下のような関数です。
def is_admin(pw: str) -> bool:
conn = MySQLdb.connect(
host='db',
user=os.environ.get('MYSQL_USER'),
passwd=os.environ.get('MYSQL_PASSWORD'),
db=os.environ.get('MYSQL_DATABASE')
)
cursor = conn.cursor()
try:
stmt = f'SELECT * FROM users WHERE username="admin" AND password="{pw}";'
cursor.execute(stmt)
except MySQLdb.Error as e:
abort(500, description=repr(e))
finally:
conn.close()
return cursor.rowcount > 0
SELECT * FROM users WHERE username="admin" AND password="{pw}";というSQL文を実行して、一行以上のレコードが取得できればログインが成功するようです。正しいパスワードを知っていればそれを入力するだけでログインできますが、今回は与えられていません。そのため、何らかの別の方法でこのログイン処理を突破する必要がありそうです。
ということで、正規のパスワードを入力する以外の方法でログイン処理を成功させることが、最終目標になりそうです。
解法
最終的に実行されるSQL文は、POST /loginにおいて、POSTパラメータpasswordの値をf文字列でベースのSQL文に埋め込んで作られます(参考: app.py 53行目)。これが原因で、SQL Injectionによる攻撃が有効になっています。
今回は、例えばSELECT * FROM users WHERE username="admin" AND password="" OR "a"="a";のようなSQL文になるように、POSTパラメータpasswordを与えればよさそうです。このSQL文のWHERE句で指定した条件は、OR "a"="a"により必ず真になります。そのため、このSQL文が実行されて取得されるのは、usersテーブルの全レコードになり、ログイン処理が成功します。
実際にログインページのフォームに" OR "a"="aと入力してログインボタンを押すと、以下のようにFlagが表示されます。
Flag
bughunt101ctf{SQL_1nj3c710n_15_st1ll_0n3_0f_7h3_t0p_7hre4t5}
後記
入門者向けCTFでは定番のSQL Injection問題でした。
[Web Medium (3/40teams solved)] BuggyBase2
問題文
Do you remember another BuggyBase?🤔
http://buggybase2.chall.8ayac.com/
Attachment: app.py
問題アプリの概要
まずはトップページを見てみます。
試しにフォームにhogeと入力してEncodeボタンをクリックすると、hogeをBase64エンコードした値が出力されます。
出力された値をもう一度フォームに入れてDecodeボタンをクリックすると、hogeと出力されます。
どうやら、任意の文字列をbase64エンコード/デコードするアプリのようです。
実際に配布されたapp.pyを読むと、アプリの機能は以下のようにまとめられます。
GET /: トップページ- GETパラメータ
sの値をGETパラメータmodeの値に応じてBase64エンコードまたはデコードする。 - GETパラメータ
modeの値が指定されていない場合は、トップページにリダイレクトされる。 - GETパラメータ
modeの値にencodeかdecode以外が指定された場合は、エラーページを表示する。
- GETパラメータ
最終目標の分析
ソースコードからはどこにFlagがあるかがわからないため、ひとまず何か利用できそうな脆弱性を探してみます。
ソースコードを読んでみると、GET /において、GETパラメータmodeの値にencodeかdecode以外が指定された場合の処理内で、render_template_string関数が安全でない使われ方をしていることがわかります。
@app.errorhandler(500)
def internal_server_error(e):
mascot = random.choice(list('🐌🐛🦟🐜🐝🐞🦂🦗🦋🕷')) # just choose a mascot
return render_template_string(f'{mascot} < {e.description}'), 500
また、{e.description}には、ユーザがGETパラメータmodeの値に指定した文字列が入ります。これは、app.pyの20-21行目から読み取れます。
if mode not in ['encode', 'decode']:
abort(500, description=f'invalid mode ({mode=}) specified')
以上のことから、この機能にはXSSやSSTIに対する脆弱性がありそうだとわかります。今回はXSSを利用しても特に嬉しいこともなさそうなので、SSTIを用いたRCE(Remote Code Execution)を試すと良いでしょう。もしかすると、サーバ内にflagが書かれたファイルが隠されているかもしれません。
ひとまず、SSTIを用いてRCEを実行することを最終目標にすると良さそうです。
解法
例えば、{{cofig.__class__.__init__.__globals__['os'].popen(★).read()}}という文字列の★の部分をOSコマンドに置き換えることで、サーバ側でそのOSコマンドを実行させ、その結果を表示することができます。実際にls -laコマンドを実行してみると、flag.txtというファイルがあることがわかります。
ちなみに表示が見づらい場合は、ペイロードの頭に<pre>を、末尾に<!--を付けると見やすくなります。
さて、cat flag.txtというコマンドを実行させてflag.txtの内容を見てみると、flagが表示されました。
Flag
bughunt101ctf{Y0u_d1d_r3vi3wed_S3rv3r_51d3_73mpl4t3_1nj3ct10n!}
後記
この問題は、講義の中で一度詳細に扱ったbuggybaseの改題です。
元のbuggybaseと違う点は、ペイロードをインジェクションできるポイントです。元のbuggybaseでは、トップページのフォームにBase64デコードに失敗するような文字列を入力して、Decodeボタンを押せば、エラーメッセージの画面が表示されるようになっていました。一方、buggybase2では、エラーメッセージの画面を表示させるために、URLパラメータmodeを操作する必要があったため、その分少し難しかったかもしれません。講義で一度扱っていることもあり、難易度はMediumとしました。
[Web Hard (0/40teams solved)] SSRF101
問題文
I want to understand SSRF perfectly, you know?
http://ssrf101.chall.8ayac.com/
Attachment: app.py
問題アプリの概要
問題文に記載されたURLにアクセスすると、以下のようなページが表示されます。
inputURLと書いてあるので、試しにhttp://example.comと入力して、Submitしてみます。すると、以下のようにhostname is not included in the white listというエラーが表示されます。どうやら入力されたURLのホスト名は、予め用意された許可リストで検証されるようです。
では、配布されたapp.pyを見てみましょう。
from urllib.parse import urlparse
from urllib.request import urlopen
from flask import abort, Flask, render_template, render_template_string, request
app = Flask(__name__)
@app.route('/', methods=['GET'])
def get_index():
return render_template('index.html')
@app.route('/', methods=['POST'])
def post_index():
url = request.form['url']
whitelist = ['shemhazai', 'ssrf101', 'nginx']
if urlparse(url).hostname not in whitelist:
abort(500, description='hostname is not included in the white list')
return render_template('index.html', result=get_url_content(url))
@app.errorhandler(500)
def internal_server_error(e):
tmpl = 'Error: {{err}}'
return render_template_string(tmpl, err=e.description), 500
def get_url_content(url: str) -> str:
try:
res = urlopen(url)
return res.read().decode()
except Exception as e:
abort(500, description=e)
if __name__ == '__main__':
app.run()
実際に読んでみると、このアプリの機能は以下のようにまとめられます。
GET /: トップページ- URLを入力するフォームがある
POST /: URLを開いて表示する機能- POSTパラメータ
urlで指定したURLをurlopenで開き、そのレスポンスを表示する。 - 指定して開けるURLのホスト名は
shemhazai、ssrf101、nginxのいずれかにしなければならない(参考: app.py 18行目の許可リスト)。
- POSTパラメータ
解法(アプローチ)
Step1(http://{許可リストで許可されているホスト}/を指定してみる)
ひとまず、トップページのフォームにhttp://{許可リストで許可されているホスト}/のようなURLを指定してSubmitしてみます。httpスキームやhttpsスキームは、URIスキームとして一般的なので、ここから試してみます。
実際に各URLを試してみると、http://shemhazai/を指定した際に以下のようなヒントが得られます。
ヒントを読んでみると、どうやらflagはssrf101(ホスト名)の/this_is_secret.txtにあるようで、そのファイルを読み取れば良いみたいです。
Step2(/this_is_secret.txtの読み取り)
実際には、fileスキーム(file://)1を利用すれば、サーバ内のファイルを読み取ることができます。今回は、ssrf101内の/this_is_secret.txtを読み取りたいので、file://ssrf101/this_is_secret.txtというURLを開かせれば良さそうです。
実際にfile://ssrf101/this_is_secret.txtをフォームに入力してSubmitすると、以下のようにflagが表示されました。
Flag
bughunt101ctf{Y0u_c4n_us3_SSRF_t0_p3rf0rm_4_L0cal_F1l3_1nclu510n}
後記
SSRFの基本原理は講義の中でも少し触れていました。fileスキームを使ったLFIの手法には触れていませんでしたが、Google検索で見つけられる情報を使うようにしました。例えば、作問時に「SSRF read file」とGoogle検索して一番上に出てきた記事には、以下のような記述がありました。
i was able to read internal files from file:/// scheme.
講義で伝えていたGIYF(Google Is Your Friend)を実践すれば解ける難易度だと想定していましたが、見誤ったかもしれません。
作問にあたり注意したこと
入門者が取り組みやすいよう、次の点を意識しました。
まず、全ての問題はアプリ本体のソースコード(app.py)だけを配布して解けるように作りました。配布ファイルが多いと入門者は混乱すると考えたためです。ただ、Dockerfileやdocker-compose.ymlも配布した方が解きやすかったかもしれません。
また、アプリ本体のソースコードは必ず100行以内で実装するという制限も設けていました。これも入門者向けの工夫です(行数が少なければ簡単、とは限りませんが)。
あとは、全ての問題をローカルプロキシツールなしで、ブラウザがあれば解けるようにしました。講義内でローカルプロキシツールの使い方を説明していなかったためです。オンラインではトラブルシューティングのサポートが難しいと予想し、講義全体を通して利用しませんでした。
総評
実際のSolve数を見ても、参加者にとって難しい問題が多かったように感じます。脆弱性を発見できてもexploitの段階まで持っていけなかった方が、そこそこいそうだと考えています。講義では脆弱性を発見するための「考え方」にはよく触れた一方、実際にどう利用(悪用)されるのかは詳しく説明していませんでした。そのことが影響していそうです。
一方、「CTFをやったのは初めてだったが、解けたときが嬉しかった。」という声もいくつかありました。
準備期間は一週間未満で、作問以外にも問題サーバやスコアサーバの準備などを一人でやっていました。すべて初めての経験でしたが、特に問題なく運営できました。プライベートな開催だったことと、参加してくれた皆さんのマナーの良さに助けられました。
感想
今日で8コマ分全部終わった。ラストはCTF形式で演習をやってもらいました!自分が用意した環境で、自分が作った問題解かれるのは楽しいですね。(ところで、講義全体を通して、教える側としても色々と勉強させてもらったなあ🤔) pic.twitter.com/nn52CNXER8
— 8ayac(Yoshinori Hayashi) (@8ayac) July 8, 2020












