こんにちは、NTT Security Japan コンサルティングサービス部で Incident Response サービスを提供している濱崎です。今回は 5/27~5/29 で開催された DEF CON CTF Qualifier 2023 で出題された kkkkklik の Writeup を書きたいと思います。リバーシング経験者向けの記事となっています。
大会終了後の kkkkklik の統計データは次の通りです。
提供されるファイルは kkkkklik.exe という 32bit 実行ファイル一つです。DEF CON CTF には珍しい Windows Binary の Reversing 問題です。最終的な獲得スコアが28 問中 23 番目に低い問題ということで初級問題ではありますが、私にとっては難しい問題でした。
import テーブルを見てみると MSVBVM60.dll という見慣れない DLL 一つとなっています。
ChatGPT によりますと、プログラミング言語 Visual Basic 6.0 のランタイムのようです。
これを頭に入れつつ、何をすれば次に進めるのか知るべく、debugger をプロセスにアタッチしてみます。アタッチ後、user code まで処理を進めますが制御が debugger に移りません。おそらく msvbvm60.dll 内で処理待ちが発生していると思い、ウィンドウをクリックしてみると制御を取れました。どうやらウィンドウをクリックすると 0x7804A0 の関数が実行されるようです。
この部分を IDA で覗いてみます。すると Switch 文の存在に気付きます。v5 変数が特定の条件を満たす場合に具体的な処理が走ることが分かります。
case 文としては3つの分岐のみです。v5 変数は 0x7804A0 関数に与えられたポインタ引数の 0x34 offset から取得されています。
上図から、まず最初にインクリメントすることも分かります。この部分のメモリを観測しながら何度かウィンドウをクリックすると、v5 の値が1ずつ増えていくことから、これまでにクリックした回数を保存していることが分かります。つまり、クリックした回数が、0x539(1337)、 0x208D9(133337)、0x145859(1333337) 回になったときに何か処理が行われることになります。
実際に1333337回クリックするのは無理なので、クリックに対応する window message を送るプログラムを作るか、メモリを改変し 1333337 回実行したように仕向けるかの2種類が考えられます。後者が楽ですが、クリックした位置情報をベースに何らかの処理や条件分岐が発生する可能性も想定され、その場合正しくプログラムが動作しないなどの副作用が発生する可能性があります。今回は楽なメモリ改変でまずは進めてみました。結果としては特に副作用はなかったのでこのやり方で正解でした。
1337 回クリックした状態にした場合、次のようなプロンプトが出てきます。
暗号キーを入力せよ、とのことですので、適当に入力してみます。すると次のようなプロンプトが出てきます。
何かを暗号化した結果が表示されます。入力した暗号化キーで flag が暗号化されたのでしょうか?よくわからないので次に行きます。133337回クリックした状態にしてみると、何も目に見える変化はありません。続いて、1333337 回クリックした状態にすると今度は次のようなプロンプトが出ます。
flag を暗号化した文が出てきました。この時点ではピンとこなかったのですが、チームメンバが次のような仮説を立ててくれました。実際その通りでした。
① 1337 回 → 暗号化キーを求め、それを使って暗号アルゴリズムで何らかの文字列を暗号化
② 133337 回 → 正解となる暗号化キーを生成する処理
③ 1333337 回 → ②で生成した暗号化キーを使って暗号化したときの暗号文を表示
つまり、①の暗号アルゴリズムの特定と、②の暗号化キー特定、を行う問題です。なお、①の 何らかの文字列 と ③の暗号文は、また別のシンプルな暗号化手法で暗号化されたうえでハードコードされています。
ここからが悪戦苦闘の始まりです。大変長いストーリーがあるのですが WriteUp ですのでポイントのみ整理してなるだけシンプルに紹介したいと思います。アウトラインとしては次のようになります。
- 暗号アルゴリズムの特定
- 1337で入力した暗号キーがどこに使われているのか
- 同じく1337で表示された "Encrypted results: " は何を暗号化したものなのか
- そしてその暗号アルゴリズムは何なのか
- 1333337 で表示された暗号化された flag の暗号キーの特定
暗号アルゴリズムの特定
まず、暗号アルゴリズムの特定です。1337 回クリック時の処理の冒頭は次のようになっています。
この問題の難しいところは、あらゆる処理が msvbvm60.dll が提供する class, method, structure をベースに行われる点です。例えば、上記の _vbaVarMove は単純な変数の move ですが、2つある引数はどちらの構造体になっており、その構造の詳細は不明です。チームメンバによると VARIANT 型(Tagged Union)というものを使っているらしく、様々に構造を変えながら利用されるようです。構造体のメンバの一つに type を表す value があり、その value によってメンバの型やメンバの数が変わるといった具合になります。
(参考) https://learn.microsoft.com/en-us/windows/win32/api/oaidl/ns-oaidl-variant
また、IDA でデコンパイルした上記を見ると、例えば v283 と v284 は実は同じ構造体メンバです。IDA がうまくデコンパイルできず、v284 が全く使われていないように見えます。IDA のデコンパイルではこのようなことはよくあるのですが、この msvbvm60.dll においてはほぼすべての箇所でこの問題が発生してしまい、解析を非常に困難にさせます。とはいえ、使用される変数の型はそんなに多くないようで、動的解析しながら構造体の構造を推察し、IDA で構造体定義をしながら解析を進めることでどうにか対応可能でした。
1337の続きの処理に戻ります。上記の画像のような処理が多数続いたあと、次のような処理が現れます。
上記の encFlag_v120 は一つ前の画像の v288 からコピーされたものです。v288 には、xor で暗号化された byte 列が入っています。一つ前の画像でいうと、v284, v282, v280 がそれにあたります。これを 0xC0 をベースとし1ずつインクリメントしながら xor することで xor 暗号化を解除しています。結果として、次の文字列が得られます:
そのあと、この文字列は v304 にコピーされ rtcInputBox に渡されます。これが前述しの 1337回クリックした際に表示されるプロンプトに該当する処理と、プロンプトに書かれた文字列の生成方法です。
そのあと上記プロンプトに入力した input は sub_783EA0 に渡されます。
一旦 sub_783EA0 の詳細は置いておいて次に進むと、先ほどと同じテクニックで xor 暗号化された文字列が復号され、
暗号化関数 sub_784220 に渡されます。そして、その結果が base64 され、rtcMsgBox で表示されるという流れです。
問題は、この暗号アルゴリズムが何なのか、です。完全にオリジナルなのか、それとも名前の付いたアルゴリズムなのか、その特定を行い必要に応じて復号アルゴリズムを作成しなければなりません。結論を言ってしまうと、これは Blowfish でした。ただし、S-Box などの Constants は暗号化された状態でメモリに格納され、使うときにだけ Constants を復号し利用する形になっています。これによって、Blowfish の形に似ているけど何か違うし、S-Box も既知のものと違うということで Blowfish であると確信するのに時間がかかってしまいました。悔やまれるのは、割と早い段階で Blowfish っぽいとは思っていたのですが、微妙に違っていたので確信が持てず深堀調査に走ってしまったことです。
この時点で、問題バイナリが出してくれる Encrypted Results を使って Blowfish で検算してみればほとんど解析する必要がなかったものになります。大きなタイムロスをしたということで大いに反省しました。以下は IDA のデコンパイル結果から作成した疑似コードです。一部再現できていないですし間違っている部分が多数あるのですが、暗号化の構造を知るうえでは十分かなと思います。これは先ほど一旦置いておいた、sub_783EA0 と sub_784220 に該当します。
bigConst1 = [0xe84f72a1,0x49d310fa,0xdf699207,0xcf006b6d,0x6879200b,0xe5ef29f9,0xc45ee2b1,0x203e74a0,0x895839cf,0xf4a00b5e,0x72247ee6,0xf8991445,0xcdc319e,0x50c48f4,0xf3f4cd9c,0x7937113e,0x5e66cdf0,0x4509e332] bigConst2 = [0x1d41138f,0x870a68c0,0x254d4241,0xf649d61e,0x54afad85,0x79c3316d,0x58f158de,0x1f8aede6,0xe38d6af2,0x17051107,0x3a3c3e35,0x67b26f1e,0x1c6ac79e,0x8693e0a,0x5819311d,0x96b53532,0x7491b7c4,0x611ebe99,(snipped) def someFunc(&val1, &val2): tmp = *(double *)val1 + *(double *)val2 if tmp > 4294967296.0: tmp -= 4294967296.0 if *(double *)&tmp < 0.0 || *(double *)&tmp >= 4294967296.0: # error pass #if !(v3 | v4) : # tmp -=4294967296.0 return int(tmp) def convert1(val): return ((val & 0xFFFF0000) | (val & 0xFFFF) ^ 0x1829) ^ 0xCC700000 def convert2(val): high,byte2,byte1,lo = splitval(val) a = convert1(bigConst2[high*4]) b = convert1(bigConst2[byte2*4+4]) z = someFunc(&a, &b) c = convert1(bigConst2[bytes1*4+8]) z ^= c d = convert1(bigConst2[lo*4+12]) return someFunc(&z, &d) def convert3_sub_783BC0(a1, first4bytes, next4bytes): for i in range(0,16): first4bytes ^= convert1(bigConst1[i]) tmp = first4bytes result = convert2(first4bytes) ^ next4bytes next4bytes = tmp next4bytes = first4bytes ^ convert1(bigConst1[16]) first4bytes ^= convert1(bigConst1[17]) return first4bytes # Key Scheduling i = 0 index = 0 while 1: if i > 17: break result = 0 for j in range(0,3): result = input[index] | shl8(result) index += 1 if index > len(input): index = 0 bigConst[i] = result result = 0 for i in range(0,18,2): convert3(v21, result, v22) bigConst1[i*4] = convert1(result) bigConst1[(i+1)*4] = convert1(v22) i = 0 while 1: if i > 3: break for j in range(0,256,2): convert3(v21, result, v22) bigConst2[4*(i+4j)] = convert1(result) bigConst2[4*(i+4(j+1)] = convert1(v22) i += 1 # Encryption for i in range(0,len(aFakeFlag),8): chunk1 = struct.unpack("<I", aFakeFlag[i:i+4])[0] chunk2 = struct.unpack("<I", aFakeFlag[i+4:i+8])[0] convert3(enc?, chunk1, chunk2)
convert2 や convert3 関数を見ればわかる通り、bigConst1 = P-array, bigConst2 = S-Box にアクセスするたびに convert1 関数を通しています。これにより、ファイルに対する yara scan でも P-array, S-Box を見つけることができませんし、メモリ yara scan でも 見つけることができません。ですが、上記のようにわかりやすくしてやれば Blowfish であることがすぐにわかります。
暗号化キーの特定
続いて、暗号化キー特定のため、133337回クリックした際の処理を追います。ここでは、変数 v8, v12(どちらも同じデータ) に格納された関数アドレスリストの offset 636 の関数が、不思議な引数と一緒に26回実行されます。
もちろんこの関数も msvbvm60.dll の関数です。msvbvm60.dll を IDA 等で解析しても、シンボル情報がなく何をやる関数なのかさっぱりわかりません。ですが、いくつかのヒントがメモリ上に現れます。上記関数の内部で使用されるとあるレジスタに格納されたアドレスからデリファレンスしていくと、PictureBox という文字列が出てきます。
なるほど、描画に関わる処理を行っているようです。ということで、チームメンバがこれらの処理を行ったあとの画像の変化を追ってみましたが変化はありません。また、他のメンバは、この画像の右下に途中で切れたようなオブジェクトが移っているため、実はまだ隠れた領域があるんじゃないかとウィンドウサイズを変更して表示してみましたが、特に変なものはないですし、この領域に何かが描画される様子もありません。
(ウィンドウのサイズを拡張した様子)
仕方ないので挙動をさらに追ってみると、msvbvm60.dll + 2FFA の関数の返り値として “Line” がセットされていることが分かります。
また、この Line という文字列が配置されているメモリの周辺を見ると以下のように複数のウィンドウに対する操作らしき文字列が見えます。
ここで一つの仮説が生まれます。この関数はウィンドウに線を引くAPIであり、その引数の不思議な数字たちは、座標を示しているのではないか?ChatGPT に聞いてみると
まさに、4つの数字はスタート地点の(x1, y1), 着地地点の(x2, y2) であり、関数の最後の引数 0xFF0000 は RGB の赤色に一致します。かなり可能性が高いので実際に線を引いてみることにしてみましたが、下記の通り、座標にしては数字が大きすぎます。Big Endian として扱ってみてもそれでもかなり大きいです。
無理やり描画してみると(座標の位置が画像サイズを超える部分は、許容される座標の max 値で置換)次のようになります。
やはり座標の評価方法が間違っているようです。ですが、いくら Google 検索しても ChatGPT に聞いても、正しい評価方法が分かりません。ライブラリの静的解析は膨大な時間が必要とされます。Visual Basic 6.0 をインストールして PictureBox.Line を使うプログラムを書いて挙動を確認しようとするも、手持ちの Windows 10 にはインストールできません。詰みかけたのですが、一つの仮説が生まれます。結局描画は低レイヤの Win32 API を使っているのではないか、であれば gdi32.dll が使われていそう、というものです。これが的中しました。次のように gdi32.MoveToEx, gid32.LineTo が使われています。
ChatGPT に聞いてみると、どちらも第2, 3引数が座標とのことで、この引数を26回分抽出して描画してみました。
lines = [(40, 1040, 30, 1070), (40, 1040, 50, 1070), (35, 1055, 45, 1055), (60, 1040, 60, 1070), (60, 1055, 80, 1040), (60, 1055, 80, 1070), (95, 1040, 85, 1070), (95, 1040, 105, 1070), (90, 1055, 100, 1055), (115, 1070, 120, 1040), (120, 1040, 125, 1070), (125, 1070, 130, 1040), (130, 1040, 135, 1070), (155, 1040, 155, 1070), (152, 1043, 155, 1040), (150, 1070, 160, 1070), (180, 1040, 200, 1047), (200, 1047, 180, 1054), (180, 1054, 200, 1061), (200, 1061, 180, 1070), (210, 1040, 230, 1047), (230, 1047, 210, 1054), (210, 1054, 230, 1061), (230, 1062, 210, 1070), (240, 1040, 260, 1040), (260, 1040, 240, 1070)] im = Image.new('RGB', (2000, 2000), (128, 128, 128)) draw = ImageDraw.Draw(im) for i in lines: draw.line(i, fill=(255,255,0), width=1) im.save('a.jpg', quality=95)
やりました!AKAM1337 が暗号化キーのようです。あとはこれを使って Blowfish で暗号化解除するだけです。
import base64 import blowfish enc = b"jEJclmCsLkox48h7uChks6p/+Lo6XHquEPBbOJzC3+0Witqh+5EZ2D7Ed7KiAbJq" encbin= base64.b64decode(enc) cipher = blowfish.Cipher(b"AKAM1337") b"".join(cipher.decrypt_ecb(encbin))
b'flag{vb6_and_blowfish_fun_from_the_old_days}\\x04\\x04\\x04\\x04'
以上が、私の実施した解法になります。感想としては、ブロック暗号の方式を捉えるのに無駄に多くの時間をかけてしまったこと、未知の構造体が多数出てくるコードの解析に課題があると感じたことです。より効率的に解けるよう、得た教訓や経験を使って、業務でのマルウェア解析等に活かしていきたいと思います。ちなみに ChatGPT には大変お世話になりました。ありがとうございました。
おまけ:
本ブログを執筆後、チーム内レビューの際に有識者に教えていただきましたが、頭を悩ませた下記の4つの数字は float32 でした。これを見て float32 と気付ける力、今身につきました。大変勉強になる CTF でした。
>>> unpack("ffff", pack("IIII", 0x42200000, 0x44820000, 0x41f00000, 0x4485c000)) (40.0, 1040.0, 30.0, 1070.0)