SECCON Beginners CTF 2023に初心者チームで出た
経緯
いつもボードゲームや謎解きをして遊んでいるグループでSECCON Beginners CTF 2023にチームで参加する人を募集していたので参加してみた。
CTF初心者チームだったが、全員(元)競技プログラマなのでチームメイトが強かった。ちなみに調べたら誰も今年AtCoderに出てなかった。
僕はreversingカテゴリの問題をずっとやっていた。 バイナリの知識がほとんどなく、調べながらやっていたら自力で2(+1)問解けたので、考えたことを書く。
Half
問題概要
バイナリファイルが与えられる。
解法
とりあえずバイナリエディタが必要な雰囲気を感じたのでStirlingをダウンロードする。
与えられたバイナリをStirlingで見ると、テキストエリアにflagが表示されているのでこれを入力すればAC。
この問題はやほーbbさんが僕より先に解いていた。
ちなみにバイナリのテキストから可読部分を抽出するにはstringsコマンドを使えばいいということを後の問題を解きながら知った。
$ strings half (略) Enter the FLAG: %99s%*[^ Invalid FLAG ctf4b{ge4_t0_kn0w_the _bin4ry_fi1e_with_s4ring3} Correct! (略)
Three
問題概要
バイナリファイルが与えられる。
解法
さっきと同じ方法でやろうとすると、flagが明示的にtextとして保存されていないことがわかる。この時点で僕の知識を超えているが、せっかくなので色々調べていた。
とりあえずStirlingでテキスト部分を眺めていると"libc.so.6"とか"GLIBC"とかいう文字列が見えるので、C言語のコードをコンパイルした実行ファイルだということに気づく(さっきの問題もそうだったのだが気づいてなかった)。実行してみると、標準入力で入れたflagが正解かどうかを判定してくれるプログラムのようだった。
あらかじめ買っておいたセキュリティコンテストチャレンジブックを読んでいたらバイナリ解析に使えるいろいろなツールが載っていたので、試してみることにした。
最初にIDA Free版で逆アセンブルをしてみた。validate_flagという関数があることは分かったが、アセンブリを1時間くらい眺めていても結局flagの正誤判定をどうやっているのかが分からなかった。
チームのdiscordを見ると、drafearさんがGhidraで逆コンパイルができるらしいという情報を(コンテスト前に)貼ってくれていたので、Ghidraを入れる(ここでJDKのバージョンを合わせるのに若干時間がかかる)。 与えられたバイナリをGhidraで逆コンパイルしてみると、validate_flagの中身をコード形式で見ることができた。
undefined8 validate_flag(char *param_1) { char cVar1; size_t sVar2; undefined8 uVar3; int local_c; sVar2 = strlen(param_1); if (sVar2 == 0x31) { for (local_c = 0; local_c < 0x31; local_c = local_c + 1) { if (local_c % 3 == 0) { cVar1 = (char)*(undefined4 *)(flag_0 + (long)(local_c / 3) * 4); } else if (local_c % 3 == 1) { cVar1 = (char)*(undefined4 *)(flag_1 + (long)(local_c / 3) * 4); } else { cVar1 = (char)*(undefined4 *)(flag_2 + (long)(local_c / 3) * 4); } if (cVar1 != param_1[local_c]) { puts("Invalid FLAG"); return 1; } } puts("Correct!"); uVar3 = 0; } else { puts("Invalid FLAG"); uVar3 = 1; } return uVar3; }
これによると、flag_0, flag_1, flag_2という3つの配列に入った文字を1文字ずつ繋ぎ合わせてflagを作っていることがわかった。それぞれの配列に入っている値を見ながら頑張ると
ctf4b{c4n_y0u_ab1e_t0_und0_t4e_t4ree_sp1it_f14g3}
という文字列が得られるので、これを入力すればAC。
ちなみにこの問題だけで2時間くらいかかっていた。
Poker
問題概要
バイナリファイルが与えられる。
解法
与えられたバイナリを実行してみると、インディアンポーカーの勝敗を予想するゲームが始まる。1,2のいずれかの値を入力すると1Pと2Pのどちらが勝ったか(引き分けもある)が表示され、当たっていればポイントがもらえる。連続で当て続ければポイントが増えていくが、1回でも外すと最初からになるようだった。
問題ページのフレーバーテキストには「みんなでポーカーで遊ぼう!点数をたくさん獲得するとフラグがもらえるみたい!でもこのバイナリファイル、動かしてみると...?実行しながら中身が確認できる専門のツールを使ってみよう!」とあるので、点数をいっぱい獲得すればいいんだなということがわかる。
とりあえずIDAとGhidraで逆アセンブル、逆コンパイルをしてみたが、眺めていてもよくわからない。
この時点で「予想を入力する前から内部で勝者が決まっていて、実行しながら変数を参照できるようなツールを使えばカンニングができる」という問題なのではないかと予想していて、バイナリを実行しながらメモリの状態を見られるようなツールを探していたがうまくいかなかった(Windowsマシンを使っていて、バイナリをWSL2で実行していたのだが、こういう状況でメモリの状態を見られるツールがあるのかどうか結局よくわかっていない。)
疲れたので、この時点で一回諦めて寝た。
起きてからもよくわからなかったので、やけくそになってポーカーゲームで同じ数字を連打して遊んでいたらやたらと点数が取れることに気づく。この時点で、勝敗が時間で決まっているんじゃないかという予想が立つ。 そこで本に載っていたltraceコマンドで見ながらゲームを実行してみると、UNIX時間(秒)が勝敗のシードとして使われていそうだということがわかった。
1秒以内なら勝敗が同じになることが分かったので、ファイルから標準入力するコマンドを使って1を大量に流し込んでみたところ、簡単に点数を増やすことができた。しかし何回やっても98点を取った時点でプログラムが落ちてしまい、99点以上は一回も取れなかった。
このことを頭に入れた状態でもう一度逆コンパイルされたコードを見てみると、こんな関数を見つけた。
undefined8 FUN_00102262(void) { undefined4 uVar1; int local_10; int local_c; local_c = 0; FUN_001021c3(); local_10 = 0; while( true ) { if (0x62 < local_10) { return 0; } FUN_00102222(local_c); uVar1 = FUN_00102179(); local_c = FUN_00101fb7(local_c,uVar1); if (99 < local_c) break; local_10 = local_10 + 1; } FUN_001011a0(); return 0; }
これによると、99点を取ればflagがもらえるが、99回入力すると強制的にreturn 0;する処理が入っていることが分かった。ここでreturnするのをやめさせることができれば99点が取れそうだ。よって、Stirlingを使って0x62(98)を0x63(99)に書き換えて実行してみたところ、ゲームが終了することなくflagをゲットできた。
[?] Enter 1 or 2: [+] Player 1 wins! You got score! ================ | Score : 98 | ================ [?] Enter 1 or 2: [+] Player 1 wins! You got score! ================ | Score : 99 | ================ [?] Enter 1 or 2: [+] Player 1 wins! You got score! [!] You got a FLAG! ctf4b{4ll_w3_h4v3_70_d3cide_1s_wh4t_t0_d0_w1th_7he_71m3_7h47_i5_g1v3n_u5}
結果
チームとしては、24位/778チーム だった。これはチームメイトが協力して難しい問題を解いていたためで、僕はあまり関係なく自分の興味あることをやっていたという感じだった。CTFをやったのは初めてだったけど、かなり面白いと感じた。競技プログラミングを始めたときの感覚に近いかもしれない。
これからもちょっとずつ過去問を解いたりしてみようかな、という気持ちになった。