NFLabs. エンジニアブログ

セキュリティやソフトウェア開発に関する情報を発信する技術者向けのブログです。

NFLabs. Cybersecurity Challenge for Students 2023 Malware medium 作問者 Writeup

この記事は NFLaboratories Advent Calendar 2023 10 日目の記事です。

はじめに

ソリューション事業部の齋藤です。

この記事は 2023/11/22 ~ 2023/11/27 で開催された学生オンリー CTF イベント NFLabs. Cybersecurity Challenge for Students 2023 で自分が作問した Malware ジャンルの medium 難易度の問題「sneak」の Writeup です。

このイベントは通常の CTF のような pwn, web, crypto ジャンルの問題ではなく、インシデントレスポンスや脆弱性診断、マルウェア解析といった、実際のセキュリティ業務に関連するスキルを題材にした全 25 問を個人戦で 5 日間で解いてもらう、というものでした。

connpass.com

筆者は Malware ジャンルの medium 難易度想定の問題「sneak」を作りました。 イベントの参加人数は 50 人が上限だったので、数人 ~ 10 人程度解かれるぐらいの難易度感を目指して作ったのですが、蓋を開けてみると 1 人しか解かれませんでした...難易度調整って難しいですね。

そこで、問題を解いてみたけど惜しくもフラグまで辿り着けなかった、あるいは解析の経験がなく手をつけられなかった方に少しでも参考になればと思い、 Writeup を書いてみました。 「Ghidra 触ってみたことあるけど解析全然わからん...」という方でも解析の流れやツール操作を追いかけられるよう、かなり細かめに書いてみたつもりなので、ぜひ実際に解きながら Writeup を読み進めていただけたらと思います!

今回参加できず、バイナリが手元にない方にとっては粒度が細かすぎて逆にわかりにくいかも知れませんが、なんとなく流れや雰囲気を感じていただいて、解析に興味を持っていただけたら嬉しいです。

TL;DR

  • 動作概要
    • Windows API + ライブラリ関数のアドレスやコンピューター名などの環境情報をメモリ上に保存
    • Reflective DLL Loading で動的にメモリ上に DLL のコードを読み込み
    • 読み込んでおいた API のアドレスや環境情報をもとに通信内容を暗号化して送信
    • 受信したデータを復号して表示
  • 暗号鍵の生成方法や配布ファイルに含まれる受信データをもとに暗号鍵を特定し、データを復号するとフラグ

問題のテーマ

Sneak は API & コードを動的に読み込む検体を解析できるかを問う問題として作りました。 安直ですが挙動を隠すという意味で Sneak です。

実際のマルウェアでも検知回避などの目的でこうした挙動を取るものは多いです。 動的なコード実行といっても様々な種類がありますが、今回は比較的動作を追いかけやすいように LoadLibrary & Reflective DLL Loading というオーソドックスな組み合わせで作ってみました。 これが解ければ、他にも色々なマルウェアを解析して自走で力を付けていけるレベルなんじゃないかと思います。

sneaker.exe の解析

では早速、問題の解析に入っていきます。

今回は無料で使えるデコンパイラとしてユーザーも増えてきたであろう Ghidra を使って解説してみます。 Ghidra はバージョン 10 からデバッガ機能が追加されており、これを使って全編解説しようと思ったのですが、どうも挙動が不安定だったので、途中から x64dbg を使ってます。

問題文はこんな感じです。

マルウェアが受け取った HTTP のレスポンスボディが手に入ったけれど、通信しているのは一体どこ...?

配布ファイルを展開すると、 sneaker.exe と response.txt の2つのファイルが入っています。

問題文には通信するプログラムという記述がありますが、Import をみると通信を行えそうな API がありません。 どうやら動的に API のアドレスを解決していそうです。

そこで、 API を解決する際に呼び出される LoadLibraryGetProcAddress を呼び出している箇所を探すと、 FUN_40001080 が見つかります。

GetProcAddress は第二引数に与えられた文字列に対応する関数のアドレスを返す Windows API です。 デコンパイル結果を見ると、引数で与えられたアドレス param_1 に対して、 GetProcAddress で解決したアドレスを次々に格納しているのがわかります。

また、下にスクロールしていくと、 HTTP 通信を行う Windows API である WinHTTP の文字が見えます。

他にもハッシュ計算を行うCryptCreateHash や、ブロック暗号 blowfish の OpenSSL 実装である BF_cbc_encrypt といった関数も確認できます。

このままではどの位置にどの API のアドレスが入っているか分かりづらいので、 param_1 を右クリック -> Auto Fill in Structure を選択して、構造体として定義しておきます。 特に注目したい WinHttpSendRequest などの関数については、格納先となる構造体のフィールド名を変更しておくと分かりやすいです。

自動生成された構造体は、 Data Type Manager から確認できます。 構造体の名前や、フィールドの編集はここから可能です。 ここで作った構造体は APITable という名前に変更してみました。

API を動的に解決していることがわかったので、次はこれらの関数がどこで使われているかを確認していきます。

手始めに API を解決していた関数の Reference をたどると、 FUN_140001410 が見つかります 1。 この関数ではいくつか malloc でメモリを確保し、解決した APITable を使って FUN_140001000 を呼び出しているのが見えます。 (適宜 Auto Fill in Structure で構造体を定義しています)

APITable のフィールドをリネームしつつ、 FUN_140001000 の中身を読むと、 GetComputerNameGetSystemDefaultUILanguage などでマシンの情報を取得して構造体に格納していることが分かります。 (デコンパイルの精度が悪く GetLocalTime の引数に何も渡されていないように見えますが、 Listing の方を見ると FUN_140001000 の引数で渡された rcx (構造体の先頭のアドレス) の値が変化せず、そのまま GetLocalTime に渡されているのが分かります。)

取得している情報をまとめたものが以下です。

  • ローカル時刻
  • マシン名
  • マシンが起動してから経過した時間 (ミリ秒)
  • 言語設定 (short)
  • ランダムな値

マルウェアはこのようにマシンの環境情報を取得することがよくあります。 取得した情報は C2 サーバーに送信されて標的の選別に使われたり、動作時点での解析環境の検知を行う目的で使用されることが多いです。

そのため、今回の検体も環境検知のような処理があるはず、と当たりをつけて解析すると良いでしょう。

構造体の名前を Environment と変更して、呼び出し元の FUN_140001410 に戻って処理を見ていきましょう。

APITable と Environment へのポインタは、小さな別の構造体に格納されています。 これを Context という名前で構造体定義してみると、 Context の中身を準備している様子がかなりわかりやすくなりました。

続きを読んでいくと、 DAT_140005008 のデータが FUN_1400013d0 に渡された後、 WriteProcessMemoryVirtualAlloc された領域に書き込まれている事がわかります。

DAT_140005008 を確認すると、以下の様にバイト列が入っています。

FUN_1400013d0 を読んでみると、引数で受け取ったデータを xor しているようです。

引数を辿っていくと、各データはこの様になっていることが分かります。

  • xor キー: 0x486b2d26
  • サイズ: 0x8c00
  • データ: DAT_140005008 以降のデータ

次に、デバッガを動作させて WriteProcessMemry で書き込まれるデータを確認してみます。

するとデータの先頭に 0x5a4d ("MZ") という値が見えます。 これは DOS Header に含まれるマジックナンバーです。 "This program cannot be run in DOS mode" や "PE" 等の文字列からして、このデータは Windows で使われる実行形式である PE フォーマットのファイルデータであることが推測できます。

learn.microsoft.com

このことから、このプログラムは dll などの PE ファイルをメモリ上に展開して動作を行いそうという予測ができます。

次の疑問点として、このデータをどのように実行しているかが気になります。 データの先頭 (DOS Header) + 0x3c は e_lfanew であり、 NT Header へのオフセットが格納されていることに注目して構造体定義を当てはめてみましょう。 (変数のデータ型の変更は右クリック -> Retype Variable, もしくは ctrl + L で)

すると、 DataDirectory の Section をループで回し、一つずつ WriteProcessMemory で読み込んでいそうなことがわかります。 (普段 IDA 使っているので Ghidra 力が足りておらず、デコンパイル結果だと変なメンバを指してたりしますがご容赦ください。)

また、先程の WriteProcessMemory ではデータを一度に全部読み込んでいたのではなく、 NT Header だけを読み込んでいたことも読み取りやすくなりました。

これを見て、なんとなく「ローダーっぽいことをしているな」と感じた方もいるのではないでしょうか? 種明かしをすると、ここではまさに dll を手動でロードしようとしている最中なのです。

動的に PE ファイルを読み込む場合、通常は LoadLibrary を使います。 しかし、 LoadLibrary はファイルシステム上にあるファイルしか読み込ませることはできません。 メモリ上にある dll をロードするためには、 LoadLibrary を呼び出したときに裏で行われていることを自ら手動で行う必要があります。

まずはメモリ上の dll をロードする方法を調べてみましょう。 いくつか参考になる記事が見つかりますが、ここでは以下のリポジトリを参考にします。

github.com

LoadLibrary で行われている操作は以下のようになっていると書かれています。

When issuing the API call LoadLibrary, Windows basically performs these tasks:

  1. Open the given file and check the DOS and PE headers.
  2. Try to allocate a memory block of PEHeader.OptionalHeader.SizeOfImage bytes at position PEHeader.OptionalHeader.ImageBase.
  3. Parse section headers and copy sections to their addresses. The destination address for each section, relative to the base of the allocated memory block, is stored in the VirtualAddress attribute of the IMAGE_SECTION_HEADER structure.
  4. If the allocated memory block differs from ImageBase, various references in the code and/or data sections must be adjusted. This is called Base relocation.
  5. The required imports for the library must be resolved by loading the corresponding libraries.
  6. The memory regions of the different sections must be protected depending on the section's characteristics. Some sections are marked as discardable and therefore can be safely freed at this point. These sections normally contain temporary data that is only needed during the import, like the informations for the base relocation.
  7. Now the library is loaded completely. It must be notified about this by calling the entry point using the flag DLL_PROCESS_ATTACH.

各ステップで行われる処理は実際にリンク先の説明を見てもらうのが早いので、ここでは詳細は割愛します。

リポジトリにはメモリ上の dll をロードする関数の実装があるので、それと比較しながら今回の問題プログラムのでコンパイル結果と見比べていきましょう。 すると、今回のプログラムは上記の 2-6 の操作を行っていることがわかります。

ここまでの流れをまとめると以下の通りです。

  1. API のアドレスを動的に解決
  2. 環境情報を取得
  3. プログラム中の暗号化された dll を復号してロード

では、読み込まれた dll のどの関数を呼んでいるのでしょうか?

今回のプログラムでは、上記のリストの 7 番目にあたる、 DLL_PROCESS_ATTACH を使ったエントリーポイント呼び出しをしていません。 代わりに、 dll が export している関数名を 0x140003528 にあるデータと比較して、一致した場合にその関数に Context を渡して実行していることがわかります。 (ここも Ghidra 力不足で DataDirectory[2] を指していることになっていますが、デバッガで実際の値を見ていくと Export されている関数の情報 (IMAGE_DIRECTORY_ENTRY_EXPORT) を参照していることがわかります。)

0x140003528 を確認すると、 "sneak" という文字列がありました。 つまり、 dll から export されている sneak という関数のアドレスを探索しているようです。

sneaker.exe で使われている動的な dll の読み込みは Reflective DLL loading と呼ばれるテクニックです。 今回は自身のプロセス上のメモリマップへと dll をロードしていましたが、別のプロセスへ dll をロードさせて実行する Reflective DLL injection と呼ばれる手法もあります。 Reflective DLL injection は無害なプロセスに外から悪意のあるコードを注入して動作させられるため、攻撃に気づかれにくくなるという攻撃者にとってのメリットがあります。

attack.mitre.org

cyberfortress.jp

Reflective DLL injection のように外部からコードを渡して実行させるテクニックは一般に code injection と呼ばれるケースが多く、 WriteProcessMemory を使ったもの以外にも多くの手法があります。 気になる方は調べてみてください。

少し話題がそれましたが、 sneaker.exe の動作はわかったので、次はいよいよ dll の中身の解析に移っていきましょう。

IDA であればメモリのスナップショットを取得して解析ができますが、 Ghidra でデコンパイルしやすいように、自分の好きなデバッガでメモリ上にある dll をファイルとしてダンプしてみましょう2

Ghidra であれば Interpreter から WinDBG の .writemem コマンドが使えますし、 x64dbg であれば Dump ウィンドウからダンプしたいデータを選択して右クリック -> Binary -> "Save To a File" でダンプできます。

DLL の解析

dll をダンプできたら、 sneaker.exe から呼び出されていた sneak 関数を見てみましょう。 なお、筆者が dll をダンプしたときのプログラムのベースは 0x180000000 です。

この関数の引数は Context * だったので、 sneaker.exe で作った構造体定義を dll の Data Type にコピーしましょう。 sneaker.dll の auto_structs をまるごとコピーしてやるのが簡単です。

引数の型を Context * にできたら、内部で呼ばれている関数を見ていきます。 あまり量が多くはないので、頭から見て行ってもいいのですが、マルウェアの挙動を解析する上では、どんな Windows API の呼び出しを追いかけていくと動作概要の把握がしやすくなるため、 Context->envContext->api_table を引数に取る関数に着目して見ましょう。

ここまでに何度かやっていると思いますが、引数の型が正しくない場合は関数を右クリック -> "Edit Signature" から引数の型を修正しておくと読みやすいです。

api_table のフィールドを参照している箇所を探していくと、 4 つの関数があること、また内部で呼ばれている API (or ライブラリ関数) が以下のようになっていることが確認できます。

  • FUN_1800037a0 (env のみ受け取り)
    • 呼ばれている API: なし
  • FUN_180001bd0 (env, api_table 両方を受け取り)
    • 呼ばれている API or ライブラリ関数
      • BF_set_key
      • BF_cbc_encrypt
      • CryptCreateHash
      • ...
  • FUN_180004120 (api_table のみ受け取り)
    • 呼ばれている API
      • WinHttpSendRequest
      • WinHttpReceiveResponse
      • WinHttpQueryHeaders
      • WinHttpReadData
  • FUN_180002650 (env, api_table 両方を受け取り)
    • 呼ばれている API
      • BF_set_key
      • BF_cbc_encrypt
      • CryptCreateHash
      • ...

また、 FUN_1800037a0 は Environment * を受け取り、返した値が 0 だった場合 exit していたり、

FUN_180004120 では http://localhost:1234/flag という文字列が含まれていたり、

FUN_180002650 が返した値は puts() に渡されていることも確認できます。

さらに、 env, api_table 以外の各関数の引数に注目してみると、FUN_180001bd0 の第一引数は FUN_180004120 の第一引数へ渡されていたり、

FUN_180004120 の返り値は FUN_180002650 の第一引数のデータの一部として使われていそうです。

これらの呼び出しを見るに、 sneak では以下の処理を行っているのではないかと推測することができます3

  1. env が条件を満たすかを確認 (FUN_1800037a0)
  2. env のデータをエンコード (blowfish で暗号化 & 何らかのハッシュ計算の組み合わせ?) (FUN_180001bd0)
  3. 作成したデータを http://localhost:1234/flag へ送信 & 受信 (FUN_180004120)
  4. 受信したデータをデコード (blowfish で復号 & 何らかのハッシュ計算の組み合わせ?) (FUN_180002650)
  5. 復号したデータを puts() で表示

この推測が正しければ、配布ファイルに含まれている response.txt は FUN_180004120 で受信したデータになっていそうです。 なので、通信時に使っているフォーマットを特定できれば、 response.txt の中に含まれる文字列を特定できそうです。

ということで、 response.txt のデコードを行っていそうな FUN_180002650 を見てみます。

処理がごちゃごちゃしていますが、先程と同様に API の呼び出しなどを手がかりに読んでいきましょう。

デコンパイル結果では BF_cbc_encrypt は 4 つの引数しか受け取っていませんが、ドキュメントには BF_cbc_encrypt が 6 引数の関数であると書かれているため、右クリック -> "Override Singature" からシグネチャを更新しましょう。

www.openssl.org

key の参照を辿ると、 FUN_180003ec0 で作成されているのが分かります。 また、 key+8 が渡されている FUN_180001010 の第一引数が ivec になっています。

FUN_180003ec0 の中に入って、各種 API の引数を調べつつ変数名などを変更していくと、 MD5 を計算する関数であることが分かります。

learn.microsoft.com

では MD5 関数の呼び出し元に戻り、わかったことを反映させてみましょう。 MD5 のハッシュ値の長さは 128 bit == 16 byte であることを考えると、

  • blowfish の暗号化キーは FUN_180003b10(env, api_table) の MD5 の前半 8 バイト
  • blowfish の IV (ivec) は FUN_180003b10(env, api_table) の MD5 の後半 8 バイト

であることが読み取れます。

あとは、 FUN_180003b10(env, api_table) の返り値と、復号するデータ (encrypted) が分かればデータをもとに戻せそうです。

key, IV, encrypted の特定

blowfish で復号されるデータは response.txt の内容をもとに作られているはずです。 なので、デバッガと FakeNet を使って、 response.txt の内容が C2 から返ってきたときの状況を再現してみましょう。

github.com

なお、 Ghidra のデバッガが思った以上に使いづらかったので、ここからは x64dbg を使っていきます。

早速 FakeNet で response.txt を返せるように設定を変更します。

まずは FakeNet のインストールフォルダに移動し、 config/default.ini を sneak.ini という名前でコピーし、以下の設定を追記します。

[HTTPListener1234]
Enabled:     True
Port:        1234
Protocol:    TCP
Listener:    HTTPListener
UseSSL:      No
HttpRawFile: sneak/response.txt
Timeout:     10
DumpHTTPPosts: Yes
DumpHTTPPostsFilePrefix: http
Hidden:      False

続いて FakeNet の exe と同じ階層に sneak/ フォルダを作り、その中に response.txt を保存します。

FakeNet を以下のコマンドで起動すれば準備は完了です。

fakenet.exe -c config\sneak.ini

デバッガで exit を読んでしまう分岐をスキップしつつ、処理を進めて行くと FUN_180004120 を呼び出したときに以下のレスポンスが確認できます。

11/24/23 06:12:23 AM [  HTTPListener1234]   POST /flag HTTP/1.1
11/24/23 06:12:23 AM [  HTTPListener1234]   Connection: Keep-Alive
11/24/23 06:12:23 AM [  HTTPListener1234]   Content-Type: text/plain
11/24/23 06:12:23 AM [  HTTPListener1234]   User-Agent: UserAgent/1.0
11/24/23 06:12:23 AM [  HTTPListener1234]   Content-Length: 106
11/24/23 06:12:23 AM [  HTTPListener1234]   Host: localhost:1234
11/24/23 06:12:23 AM [  HTTPListener1234]
11/24/23 06:12:23 AM [  HTTPListener1234]   11c0cb8a::3021d4c60daea1f0bdf845eeba1872606bae761fc06c43513bf3d7e466933700525ab9fd68978aebecbf0a0563b153c8

リクエストの Body にも response.txt と同様のフォーマットが使われていそうです。

これで response.txt の中身をレスポンスとして受け取れたので、 FUN_180002650 で実際にどのように処理されるかを追っていきましょう。

まず初めに、第一引数で受け取ったレスポンスの入った構造体 response の値を操作し、 :: を検索しているのがわかります。

response に入っている値をデバッガで確認してみると、 response.txt の中身が格納されているアドレス、 0x7a などのデータが確認できます。

ここで、デコンパイル結果で memchr に渡されている pplVar8 にどんな値が入っているか見てみると、

  • response[3] > 0xf のときは *response
  • response[3] <= 0xf のときは response

となっています。

C++ のバイナリを解析しなれている方であれば、ここで responsestd::string だと見当が付くでしょう。

std::string は実際の文字列が格納されているヒープ上のバッファのアドレスと、サイズ、文字列を格納しているバッファの大きさ (capacity) などのメンバ変数を持ちます。 このクラスは少し変わったデータの持ち方をしていて、文字列のサイズが小さい時 (0xf 以下) のとき、文字列をヒープに確保せず、クラス内に char 型の配列として直接文字列を書き込んで保持します。

短い文字列の作成によって発生するメモリ確保を抑えるためのパフォーマンス上の最適化のため、このような実装になっています。(以前この理由についてどこかで言及している文献を見た記憶はありますが参考リンクは見つからず...)

構造体や共用体を定義して適用すると以下のようになります。 共用体は Data Type Manager の適当なフォルダを右クリック -> New -> Union... から作成できます。

また size に当たる領域にはデバッガ上で 0x7a が入っており、これは response.txt のデータ長と一致しています。

というわけで、 response は std::string として受け取っていることが分かったので、中身がどのように処理されているか引き続き見ていきます。

再度、先程の :: を検索していた箇所を見てみましょう。

*param_2response->size - (:: のある位置 + 2) が代入されています。 また、 BF_cbc_encrypt に暗号化されたデータの長さとして渡されている dataLength を追っていくと、 param_2 をコピーした local_1150 であることが確認できます。

そのため、 response:: 以降の文字列が暗号化に使われていると推測できます。

続けてたくさんの STL のメソッドが呼ばれているブロックを流し読みしつつ、デコンパイル結果を読んでいくと、 0x180002ae3 で Environment->random と値を比較しているのが確認できます。 random が uVar6 と同じでないとクリーンアップと思われる処理が走って return してしまうようです。

デバッガでここまで進めて比較している値を確認すると、 0x1ef36d34 となっています。

これは、 response.txt の先頭にある 1ef36d34:: の文字列と一致しています。 つまり、 :: の前の値は Environment->random を 16 進表記したもののようです。 STL のメソッドがたくさん呼ばれていたのは、おそらく 16 進数の文字列をバイト列に変換するためのものだったと解釈できそうです。

デバッガでクリーンアップへ進む分岐をスキップさせるか、 Environment->random の値を書き換えるなどして、先へ進みましょう。

次に気になるのは encrypted に入っているデータが何かです。 これまで見てきた処理を振り返ると、なんとなく response.txt の :: 以降の 16 進文字列をバイト列に変換しているのではないかと予測が付きますが、実際にデバッガで見てみます。

FUN_1800022b0 の引数を確認すると、 response.txt の :: 以降の文字列へのポインタが渡されており、

関数を実行するとそのバイト表現へのポインタが返されていました。 予想通りです。

最後に暗号鍵と IV の生成に使う FUN_180003b10 を見てみます。

すると、 env の localTime を snprintf でワイド文字列に変換したり、 local_b0 を介して computerName と何か操作をしています。

デバッガでこの関数を実行すると、以下のように localTime などの env のデータを | で区切って連結した文字列が返されることが確認できます。

デコンパイル結果にあった DAT_18000652c は | なので、 FUN_180003620 はおそらく文字列 (std::wstring) の連結処理でしょう。

これらのデータをもとに再度 FUN_180003b10 を見ると、 localTIme|computerName|language(10 進)|random(10 進) という形式で変換されていることが読み取れます。 key 生成のために MD5 に渡されていたデータはこの文字列になっていました。

ここまでをまとめると以下です。

  • response.txt の :: より前の文字列は 16 進表記した Environment->random
  • response.txt の :: より後の文字列は blowfish で暗号化されたデータの 16 進表記
  • localTIme|computerName|language(10 進)|random(10 進) の MD5 ハッシュのうち、先頭 8 バイトを暗号化キー、後方 8 バイトを IV として使う

大分わかってきました。 あとは Environment の値を特定できれば復号が可能です。

では、 Environment のデータをチェックしていそうな FUN_1800037a0 の中身を見てみます。

するとごちゃっとしていますがなんとなく local_20 から続く一連の値を Environment->xor_key で xor していそうです。 それが Environment->computerName と比較されていることから、 local_20 は computerName を xor したものだと当たりをつけてみます。

実際にデバッガで比較するところまで進めてみると、ワイド文字列で "NFLABS" という文字列が確認できます。

念のためスクリプトを書いて本当に xor をしているか検証してみると同じ値になることが確認できます。 なので予想通り (4 byte) xor が行われているということで間違いなさそうです。

from itertools import cycle
import struct


key = struct.pack("<I", 0x486B2D26)


def xor_bytes(key: bytes, data: bytes) -> bytes:
    return bytes(map(lambda x: x[0] ^ x[1], zip(cycle(key), data)))


xored_computer_name = b"".join(
    map(lambda x: struct.pack("<I", x), [0x482D2D68, 0x482A2D6A, 0x48382D64])
) + struct.pack("<H", 0x2D26)
print(xor_bytes(key, xored_computer_name)) # b'N\x00F\x00L\x00A\x00B\x00S\x00\x00\x00'

よって、 computerName が "NFLABS" である端末でのみ動作するようになっていることがわかります。

また、 if 文の条件式から、 language は 0x409 (en-US), tickCount は 71999999 ミリ秒よりも大きい (== 起動から 20 時間以上経過している) 値でなくてはいけないことも分かります。

if 文の中身を更に見ていくと、今度は local_38 から始まる値をまた xor していそうです。

しかし、デコンパイル結果を見ると xor した結果を使わずにそのまま return していそうです。

実はこれは Ghidra のデコンパイル精度が悪く処理が見えていません... ディスアセンブル画面 (Listing view) を見ると、 Environment->localTime の各フィールドと xor した値を比較しており、すべて一致した場合にのみ rax に 1 がセットされるようです。

ということで、デバッガで比較する地点まで進めるか、スクリプトを書いて値を検証すると、 localTime の各フィールドは以下の通りになっている必要があることが分かります。

  • wYear -> 2000
  • wMonth -> 11
  • wDay -> 22
  • wHour -> 3
  • wMinute -> 44
  • wSecond -> 55

他のフィールドについても条件をまとめると以下の通りです。

  • computerName -> "NFLABS"
  • language -> 0x409 (en-US)
  • tickCount -> > 71999999
  • localTime
    • wYear -> 2000
    • wMonth -> 11
    • wDay -> 22
    • wHour -> 3
    • wMinute -> 44
    • wSecond -> 55
  • random -> 0x1ef36d34 (response.txt に記載)

これで、 Environment のフィールドをすべて特定できました。

今までの情報をまとめると、以下のとおりです。

  • MD5("20001122034455|NFLABS|1013|519269684") を計算
  • response.txt の :: より後のデータ を blowfish で復号
    • MD5 ハッシュの先頭 8 バイトを暗号化キー、後方 8 バイトを IV として使う

あとはスクリプトを書いて復号してみましょう。 この程度の簡単な処理であれば、スクリプトを書く代わりに CyberChef を使うのもおすすめです。

gchq.github.io

import hashlib
from Crypto.Cipher import Blowfish


def gen_key_and_iv(data: bytes) -> tuple[bytes, bytes]:
    hash = hashlib.md5(data).digest()
    return hash[:8], hash[8:]


def blowfish_decrypt(key: bytes, iv: bytes, data: bytes) -> bytes:
    bs = Blowfish.block_size
    cipher = Blowfish.new(key=key, iv=iv, mode=Blowfish.MODE_CBC)
    return cipher.decrypt(data).rstrip(b"\x00")


def decrypt(response: bytes, env_string: bytes):
    key, iv = gen_key_and_iv(env_string)
    plain = blowfish_decrypt(key, iv, bytes.fromhex(response.decode()))
    print(f"{plain=}")


decrypt(
    b"97f4d4fc7602cde78f21716d30e55e2457278abf127a825e89e369d42c5ba29f7f87c30492e17b4d8a4dd7bb3e095c7cc31973ff52349bde",
    b"20001122034455|NFLABS|1033|519269684",
)

これを実行するとフラグが得られます。

NFLABS{dynamic_4p1_resolve_is_useful_for_sneaking}

おわりに

記事を読んで少しでもマルウェア解析に興味を持っていただけたら幸いです!

明日は 「Kai6u さんの Offsec 資格「OSED」合格体験記」です。

注釈

  1. この段階で APITable の適当なフィールドの Reference を辿って直接 API の呼び出し箇所へ飛べることもあります。この検体では実は API を呼び出している箇所が動的に展開されるため、この段階では Reference が見つかりません。
  2. Ghidra のバージョン 10 以降ではデバッガを動かしつつ、メモリスナップショットを取る機能があるらしいのですが、筆者はちゃんと動かせませんでした...デバッガ機能が安定するまでは、他のデバッガを使ったほうがスムーズに解析できるかもしれません。
  3. この時点だと無理のある紐づけに見えるかもしれません。しかし、解析するプログラムの規模が大きくなっていく程、このように「なんとなく」で当たりをつけて、読んでいきながら推測結果を修正していく方が効率が良いです。
  4. 少なくとも msvc ではこのような実装になっているようですが、データが書き込まれる先が微妙に異なる実装もあるようです。