こんにちは、二項しいぷです。
突然ですが、皆さんが中二病の頃に憧れていたハッカーは誰ですか?
本記事では
- CTFの概要
- 初参加してみた感想
- writeup(各問題の解法と感想)
を記載します。
なお、writeupは順次追加します。今はコンテスト中に自分が解いた3問のみです。
ここになければないですね
[reversing] Half, Three
[pwnable] rewriter2
CTFとは
情報セキュリティ技術を競う競技です。「ハッキングコンテスト」「セキュリティコンテスト」などとも呼ばれています。
情報セキュリティという言葉が指す範囲がそもそも幅広いですが、CTFでは更に実際に解析や攻撃ができる水準の深い理解と高度な技術力が問われ、とにかく範囲が広くて深いです。
バイナリファイルの解析や現代暗号の解読のようなサイエンス寄りの内容もあれば、Webアプリケーションのハックやネットワークのパケット解析のような業務に近いジャンルもあり、一般にハッキングと言われて想像するような、運営サーバ上で任意コードを実行できるように乗っ取るジャンルもあります。今年は生成AIのハックを題材とした出題もありました。
上級者向けのものではAttack&Deffense形式という、対戦相手のコンピュータへの侵入と自分のコンピュータの防衛の応酬で競い合う大会もあり、まさにアニメやドラマで描かれるハッカーそのものですね。
より詳しいジャンル概要はwikipediaやCTF初心者が考えるCTF入門や各CTF書籍をご覧ください。
今回はSECCONが年1で開催している国内最大級の初中級向けCTFに参加しました。
SECCONというのは、NPO日本ネットワークセキュリティ協会(JNSA)内から始まった有志ボランティア組織らしいです。
チーム紹介(SheepTF)と目標
CTFは要求されるスキルの幅が膨大すぎるので、基本チーム戦です。Twitterでメンバーを募って7人チームで参加しました。チーム名は、メンバーの4匹が羊アイコンであることからSheepTFです。
チームの特徴として、全員がCTF初参加の競技プログラマーです。AtCoderのレートは青色3、水色2、緑色2であり、初参加の中ではアルゴリズム力、数学的思考力、実装力にアドバンテージがあります。一方で学生と新卒が過半数を占めており、エンジニアリング経験は不足しています。中学生や大学1年生もいるため、コンピュータサイエンスの総合的な知識が要求される問題にも苦しみそうです。
自分は社会人3年目SEで、情報科学で修士号を取っています。各ジャンルのよく読まれている技術書(結城浩暗号本、徳丸本、マスタリングTCP/IPなど)は読んでいて、C言語やアセンブリ言語にも抵抗はないです。
今回の参加の予習として、『入門セキュリティコンテスト』を読み、CpawCTFの全ての問題と、SECCON Beginners CTF 2022で200チーム以上に解かれた問題を埋めておきました。
チーム内ではジャンルの得意苦手はない方で、webは得意そうなチームメイトがいたのと、cryptoは競プロerと相性良さそうなので、自分はreversingとpwnableを担当することにしました。
どこまで通用するか楽しみです。
目標としては、昨年の解いたチーム数を参考にして
- 200チーム以上が通している問題は解く
- 100~199チームが通している問題を粘って解けたら嬉しい
- 50~99チームが通している問題は厳しい
- 49チーム以下しか解けない問題は無理
という感じです。
結果と感想
28問中11問解けて、778チーム中108位(13.9%)でした。
自分はreversingの「Half」「Three」とpwnableの「rewriter2」を正解しました。
チームとしては、200チーム以上が解けている問題は全て解け、(95)100-199チームが解けている問題は3/5解け、目標通りです。チームとしても個人としても今の実力相応のデキだったと思います。
感想としては、やはり「何となく知っている」と「実装できる」の間には大きな差があるということを実感させられました。
50チーム以上が通している問題は、手法自体は知識として知っていたり方針を立てることは出来たりするのですが、取り組んでみるといくつもの小さな壁にぶつかります。しかもその壁のほとんどは、非本質なものではなく本質的な理解不足を露呈させるものです。
例えば「rewriter2」は5分でやりたいことは分かって、そこから10時間掛かりました。このあとで具体的に「rewriter2」を解く過程でどう考えてどこで詰まったかを記載します。
とはいえ典型的で教育的な問題が並んでいるコンテストだと思うので、継続的に練習すれば(8割くらいは)解けるようになるかなと考えています。今回自分が取り組まなかったcryptoやwebあたりから、ジャンルごとに知識を深めていこうと思いました。
コンテスト開催ありがとうございました!
というわけで、CTFは難しいですが面白くて勉強になります。セキュリティのスペシャリストやスーパーハカーを目指すのでなくても、コンピュータとソフトウェアに関する理解を楽しみながら深めることができます。
なお、前節で記載したように、一定の前提知識と予習があった上でチームで28問中11問解けるくらいのコンテストで、一切の準備なしで参加するとなかなか楽しさが分かりにくいかもしれません。
CTFの概観や書籍案内としては『入門セキュリティコンテスト』が分かりやすいです。この時点では写経しなくていいと思います(今ではそのままでは上手くいかないツールや、Python2のコードが混ざっているため)。手を動かす最初の一歩としては常設コンテストのCpawCTFが適しています。
チームメイト
CTF初心者の競プロerで構成された他チーム(20位)
writeup
6月いっぱいは公式アナウンスから登録して問題にアクセスできるみたいです。その後はgithubで公開されると公式discordで言っていました。
以下、取り組んだ問題ごとの感想です。なお詳しい解説は各作問者が作問意図や類題とともに上げてくれているので、そちらを参照するとよいと思います。
本記事では「何を考えていたか」「どこで嵌ったか」というところを中心に残しておきます。
[reversing] Half (599 team solved)
コンテスト中に解きました。
表層解析を知っていますかという問題ですね。
stringsコマンドを叩くとflagが表示されます。stringsだけで解けるというのはreversingのHello, World!!らしく、最近CTFを初めて既に5回くらい見ています。
[reversing] Three (295 team solved)
コンテスト中に解きました。
これは表層解析だけではよく分からないので、逆アセンブルしてみます。さらにGhidraというリバースエンジニアリングツールにはデコンパイル機能もあり、一応人間が読めるくらいのC言語にしてくれます。
これくらいならそのまま読めそうですが、より複雑なコードでも変数名変更などもサポートしているので読みやすくできます。フラグ配列も以下のように読めるので、これをコピペして取り出せば解けそうです。
なお、上手く取り出す方法は分かりません。自分は手で丸ごとコピペして正規表現置換をするかスクリプトを書いて文字列処理していますが、上手く整形して取り出して爆速シェル芸ができるかもしれません。
あとはデコンパイルしたコードを書き換えて、入力値と比較する代わりに配列の値を出力するようにすればフラグゲットです。
フラグ抽出コード
#include <bits/stdc++.h>
using namespace std;
vector<int> flag_0 = {99, 0, 0, 0, 52, 0, 0, 0, 99, 0, 0, 0, 95, 0,
0, 0, 117, 0, 0, 0, 98, 0, 0, 0, 95, 0, 0, 0,
95, 0, 0, 0, 100, 0, 0, 0, 116, 0, 0, 0, 95, 0,
0, 0, 114, 0, 0, 0, 95, 0, 0, 0, 49, 0, 0, 0,
95, 0, 0, 0, 52, 0, 0, 0, 125, 0, 0, 0};
vector<int> flag_1 = {116, 0, 0, 0, 98, 0, 0, 0, 52, 0, 0, 0, 121, 0, 0, 0,
95, 0, 0, 0, 49, 0, 0, 0, 116, 0, 0, 0, 117, 0, 0, 0,
48, 0, 0, 0, 52, 0, 0, 0, 116, 0, 0, 0, 101, 0, 0, 0,
115, 0, 0, 0, 105, 0, 0, 0, 102, 0, 0, 0, 103, 0, 0, 0};
vector<int> flag_2 = {102, 0, 0, 0, 123, 0, 0, 0, 110, 0, 0, 0, 48, 0, 0, 0,
97, 0, 0, 0, 101, 0, 0, 0, 48, 0, 0, 0, 110, 0, 0, 0,
95, 0, 0, 0, 101, 0, 0, 0, 52, 0, 0, 0, 101, 0, 0, 0,
112, 0, 0, 0, 116, 0, 0, 0, 49, 0, 0, 0, 51, 0, 0, 0};
bool validate_flag(string argS) {
char cVar1;
if (argS.size() != 49) {
puts("Invalid FLAG");
return 1;
}
for (int i = 0; i < 49; i++) {
if (i % 3 == 0) {
cVar1 = (char)(flag_0[(i / 3) * 4]);
} else if (i % 3 == 1) {
cVar1 = (char)(flag_1[(i / 3) * 4]);
} else {
cVar1 = (char)(flag_2[(i / 3) * 4]);
}
cout << cVar1;
// if (cVar1 != argS[i]) {
// puts("Invalid FLAG");
// return 1;
// }
}
cout << endl;
puts("Correct!");
return 0;
}
int main() {
string tmp = string(49, 'a');
validate_flag(tmp);
return 0;
}
これ295チームも解けるものなんだ?と思いましたが、hexdump -C ThreeなどでASCII文字として表示させて画像の位置が怪しいとにらみ、大会のフラグprefixとthreeという問題タイトルからエスパーしてctf4b{……になるように読んでいくことでも正解を得られるようです。
これはスキュタレー暗号という有名な古典暗号で、復号は適当なサイトを使うと楽そうです。『名探偵コナン』でも出てきましたね。コナンが「あゆみちゃんはいつもげんきでやさしいおんなのこ」という文字を鉛筆に巻いて書いていました。
[pwnable] rewriter2 (95 team solved)
コンテスト中に解きました。
実行結果とCソースコードから、「スタックバッファオーバーフロー攻撃でリターンアドレスを書き換えてwin関数を呼び出したいけど、Canaryで脆弱性緩和されていて、それをどうにかする」問題と認識します。
念のためchecksecで確認すると、たしかにCanary foundらしいです。
さて……。
BOF攻撃の概要は基本情報でも出てきますし、関数のリターンアドレスがスタックに載っていて云々というのも計算機科学の入門書や『プログラムはなぜ動くのか』あたりに載っていると思います。自分はCanaryの概要も知っていました。
しかしCanaryをハックする方法なんてこれまで人生で一度も考えたことがありませんでした。まぁビギナーコンテストのeasy枠なら、CTFでは常識なのでしょう。すごい世界。セキュリティの専門的な論文を調べずとも、「CTF canary pwn」などでググれば出てきそうということです。出てきました。
今回は記事の1つ目の方法が使えそうです。
1回目の送信で40文字+改行コードを送ることでカナリアの先頭の\x00を\x0aで上書きしてカナリアをリークさせ、2回目の送信ではリークしたカナリアをそっくり真似るようにBOF攻撃をする方針が立ちました。
うんそれっぽい。ここまで5分です。
実際に40文字+改行を送信してみましょう。
うん?スタックの値は想定通りになっていて、何らかの漏洩にも成功していますが、文字化けしています。
ところでローカルではcanaryを隠しておく必要がないので、src.cを弄って表示するようにしておきます。
やはりスタックは想定通りの状態で、おそらくカナリアも表示されていて、印字不能文字だから文字化けしているという感じですね。
印字不能文字の入出力について考えます。1回の入力ならecho - e "\x00" | ./a.outのようにすればよいですが、今回は2回入力したいです。Pythonのpwntoolsというライブラリが良さそうなので使ってみました。
公式ドキュメントやCTFの回答コードを見ながら作ると、「1回目のリクエストでカナリアをリークさせて、2回目でカナリアを含むようにスタックバッファオーバーフローさせ、戻り値のアドレスを任意の値に書き換え、しかもカナリアが死なない(stack smashing detectedが起きない)」ようになります。これでほぼ完成……?
戻り値のアドレスは、win関数のアドレスにする必要があります。
「最初に戻り値に格納されているアドレスとの差が固定だったりするのかなぁ」と考えながらgdbをガチャガチャ動かしてみてもよく分かりません。
ところで問題名がrewriter2ということはrewriterがあるということなので早々にググっていたのですが、その問題では戻り値のアドレスは静的に埋め込んでいました。そういえばrewriter2もASLR(Address Space Layout Randomization)がオフなので、同じことができそうです。
gdbを起動しp winでアドレスを取得し、リトルエンディアンであることに注意して埋め込んで、実行。
やったー!Congratulation!!
4時間半掛かっちゃったけど、なんとか21時前(AtCoder Beginners Contest前)に終わりました。
あとはlsしてcat flag.txtあたりで取り出して送信するだけですね。
……ん?Segmentation fault?
ここからが地獄の始まりです。AtCoderのコンテストが終わって23時半頃からCTF再開。
Segmentation faultはローカルでもサーバでも起こります。
さて、win関数は無理やり呼び出した関数なので、win関数から戻るタイミングでSegmentation faultが起きるのはいかにもありそうです。
しかしその前にsystem("/bin/sh");でシェルが起動していないのはよく分かりません。win関数は引数を持たないので引数周りのトラブルでもないはずですし。
pwntoolsのインタラクティブ移行の周りで上手くいっていない可能性を疑い、しばらく調べたり実験しました。main関数内でsystem("/bin/sh");をしてpwntoolsのインタラクティブモードに移行すると期待通りシェル操作ができたので、インタラクティブ移行は問題なさそうです。正当な関数呼び出しやsystem以外では発生しないので、「無理矢理win関数にジャンプした上でsystemを呼び出していること」と関連していそうですね。
いろいろ調べてると、スタックのアラインメントに違反していたみたいです。アラインメントという概念自体は計算機科学のどこかで知ったはずですが、こういうところで繋がってくるんですね……。
飛ばすアドレスはwin関数の先頭じゃなくても目的は達成できるので、ズラして呼び出すことで無事シェルが起動。サーバのシェルが起動して任意のコマンドが実行できるようになるのは、まさにハッキング成功って感じでこれは最高の体験ですね。
攻撃コード
from pwn import *
# context.log_level = "DEBUG"
# p = process("./rewriter2")
p = remote('rewriter2.beginners.seccon.games', 9001)
# カナリアの先頭1バイトを上書き
a40 = b"a" * 40
p.sendline(a40)
# カナリアの取得
p.recvuntil(a40)
canary = p.recv(8)
print(canary)
# win関数へのアドレス
tail = b"\xca\x12\x40\x00\x00\x00\x00\x00"
# カナリアを変えないようにバッファオーバーフロー攻撃
head = a40 + b"\x00" + canary[1:] + (b"\x00" * 8)
p.send(head + tail)
# インタラクティブ起動
p.interactive()
アラインメントの話は、公式discordのwriteup.pwnableチャンネルでも話題に出ていますし、検索すると日本語でもいくつか引っかかるので、pwnの罠あるあるだったみたいです。呼び出す先のアドレスをズラす以外に、リターンアドレスをいい感じに設定することでも回避できるそうです。
さて、Congratulation表示から5時間半、延べ10時間掛けてなんとか正解できました。
これが「BOF攻撃の緩和策としてのCanaryを知っている」と「Canaryがあっても攻撃可能な最も単純なシチュエーションであれば、実際にハッキングできる」の距離でした。
もう1つの(総当たりで順次突き止める)攻撃も手法は一目で理解できましたし、もう少し捻ったシチュエーションでも方針は立ちそうですが、おそらく取り組んでみると同じくらい何度も壁にぶつかるでしょう。コンピュータについて色々と分かってきた気でいたけどまだまだ理解の浅い個所がたくさんあるということを自覚させられた、素晴らしい問題でした。ありがとうございました。
なお、本問題のキレイな解法についてはアラインメント周りの話を含めて以下の記事が整理されています。