はじめに

社会情報研究所NTT-CERTの田中です。2019/03にNSAがリバースエンジニアリングツールGhidraをオープンソースとして公開しました。多くのアーキテクチャに対してデコンパイラが無償で使えるようになり、解析のハードルが下がりました。ただ現場では、多くの解析者がIDA Pro等の慣れ親しんだ有償ツールを使っており、Ghidraはそこまで広がっていないように感じます。

理由として、有償と比較しての使い勝手や信頼性、機能追加のスピード等が不足している点があるかと思われますが、中でもPython3への対応が進んでいない点も一因となっていると思われます。

そういった中、Mandiantの解析チームがGhidraをPython3に対応させるGhidrathonをVer1として2022/08にリリースしました。本ブログでは、Ghidra+Python3で、スクリプトによる解析を行った事例を記載します。無償で使えますので解析に興味を持ってもらえる方が増えれば幸いです。

セットアップ

Ghidrathonサイトの手順に従いセットアップします。GhidraとコンパイルツールであるGradleをインストール、その後、Ghidrathonのソースコードをダウンロードしてコンパイル、出来上がったzipファイルを、GhidraにInstall Extensionsから追加するという流れです。以下の通り、本稿執筆時の、最新版を利用しました。

    - Ghidra 10.2.2

    - Ghidrathon 2.0.0

Ghidra推奨設定

                                                                        

特にIDA Proに慣れている方には、以下3点の変更をお勧めします。

1. 左クリックでのハイライト

Edit→Tool Optionsを選択。以下のオプション画面で、Listing Fields→Cursor Text Highlightで、Mouse Button To Activate をLEFTに変更(デフォルトMIDDLE)

2. Xキーでのクロスリファレンス

同じくオプション画面の、Key Bindingで、Find Reference Toを、Xキーに割り当てる(デフォルトctrl+shift+F)

3. クロスリファレンスの表示項目追加(関数名)

クロスリファレンスのある関数上でXキー押下して、クロスリファレンスウインドウを表示。

カラム名にカーソルを合わせ右クリックで、Add/Remove Columnsを選択しSelect Columns画面を表示させ、Function Nameにチェック。

テストに用いる検体

このブログ記事で紹介されている、Trickbotというマルウェアファミリーの検体を対象にします。ブログ記事で説明のあるとおり、Trickbotの難読化に対して、GhidrathonのPython3上でスクリプト化を行い難読化を解除して、GhidraのListingウインドウにコメントとして反映してみます。

パック検体のsha256

8f590ac32a7c7c0ddfbfa7a70e33ec0ee6eb8d88846defbda6144fadcc23663a

アンパック検体のsha256

8bdbde1d15404258bcb66a64e7df34741543d519e7175679a223957284675f79

Ghidraにはアンパック検体を読み込ませます。

Ghidrathon動作確認と準備

検体を読み込ませた後、Windows→Ghidrathonを選択。

簡単なコマンドはこの画面から確認ができます。print(getFirstFunction())と入れてみます。最初の関数名が表示されます。エラーが出る場合はインストール手順を確認します。

Windows→Script Managerを選択、以下のScript Manager画面を出します。

Script Manager画面で、Create New Scriptを押下します。Script Type選択画面で、Python3を選択します。(GhidrathonをインストールしたことでPython3が増えていることがわかります。)

任意の名前をつけて、Script Manager上で編集していきます。

検体の分析

スクリプティングを始める前に、手動で分析を行い、自動化のポイントを確認します。検体の詳細は、ブログ記事を参照してください。

分析をすすめると、FUN_0040e970が難読化処理のポイントを担っていることがわかります。同関数は、FUN_00404080を呼び出すだけの関数ですが、第一引数を加工しています。DAT_00427c1cを確認すると、難読化文字列へのポインタテーブルのベースアドレスであることがわかります。param1を入力としてベースアドレスからのオフセットを調整しています。

次にFUN_0040e970関数の呼び出し元と呼び出し先をみてみます。Windows→Function Call Treesを選択すると画面下部に以下の表示がされます。この画面はGhidraの便利な機能で、呼び出し関係の見通しがよくなるので、常に開いておいても良いと思います。

上記呼び出し関係を参考にして、周辺関数を調査していきます。結果を図にまとめました。

FUNC_405210, FUNC_407110, FUNC_40719F0(オフセット渡し関数)

それぞれ、引数として1バイトのオフセット値を取り、FUNC_40E970(文字列ポインタ生成関数)に渡します。 FUNC_407110, FUNC_40719F0については、FUNC_407140(AscToWch変換関数)を呼び、アスキーからユニコードの変換を行います。

FUNC_40E970(文字列ポインタ生成関数)

先程確認した関数です。オフセットを引数として取り、難読化文字列ポインタテーブルのベースアドレスにオフセットを加え、FUNC_404080(base64デコード関数)に渡します。

FUNC_404080(base64デコード関数)

入力文字列ポインタを受け取り、base64独自デコードを行い、スタック渡しで難読化解除後の文字列を返します。

オフセット関数を呼び出すコード

オフセット値をpushしスタック渡しで、オフセット渡し関数に渡します。

FUN_00405210にカーソルをあわせ、Xキーを押下すると以下のようにリファレンスウインドウが表示され、この場合、52のロケーションから呼ばれていることがわかります。

スクリプト設計

手動解析を行った結果を受けて自動化シナリオは以下となります。

オフセット渡し関数を起点にして、呼び出し元をリストアップ。すべての呼び出し元について以下の処理を実施。(①)

    - 戻っていく形で逆アセンブルを行い、PUSH [数値] の命令を探し、見つかったらオフセットを返す。(②)

    - ベースアドレス(DAT_00427C1C)+4xオフセットを計算し、該当文字列ポインタを取得し、さらに該当文字列を取得して返す。(③)

    - FUNC_404080(base64デコード関数)の逆の演算を行い、文字列を復元する。(④)

    - 復元した文字列をコンソールに出力し、また、Listingウィンドウの逆アセンブラの該当アドレスにコメントとして、復元した文字列を表示する。(⑤)

スクリプト実装

ブログ記事の参考レポジトリも参考にして実装を行います。 ①と②の実装は以下となります。

L19で、最初のオフセット渡し関数のアドレスを入力します。L14でGhidra APIのgetReferencesTo()を用い、リファレンスをすべて取得しています。toAddrでアドレスオブジェクトとして扱う点に注意してください。L15で、それぞれのrefオブジェクトからgetFromAddress()を用いアドレスを取得します。

L1で、getOperandBack関数はaddrを受け取ります。L3でGhidra APIのgetInstructionBefore()を用い、addrの1個前のインストラクションを取得します。L5でインストラクションのニーモニックがPUSHであれば、L6で第一オペランドを取得し、L7で即値であるか(0xで始まるかどうか)判断、Yesであれば、L8で10進数としてオフセットとして戻り値retに格納し、ループを抜けます。

L10では、次のインストラクションを取得します。L4でループ数を10として指定しており、10インストラクション分戻り逆アセンブルを続けます。

③の実装は以下となります。

L11のgetStringFromOffset() はオフセットを引数として取ります。L13でベースアドレスにオフセットを加え、該当文字列ポインタのアドレスを取得します。

L1のgetStringFromPRT()はアドレスを引数として取り、該当文字列を返します。Ghidra APIのgetDataAt()を用い、該当アドレスにある値を取得します。ポインタと文字列を2段階で取得し、存在しない場合はNoneを返します。

④の実装は、参考レポジトリのcrypt()を使わせて頂きました。(最後に完成スクリプトとして掲載します)

⑤の実装は、Ghidra APIのsetEOLComment()により行います。

完成スクリプト

def getStringFromPTR(addr):

  data_p = getDataAt(toAddr(addr))

  if not data_p:

    return None

  ptr_addr = data_p.getValue()

  data_s = getDataAt(ptr_addr)

  if not data_s:

    return None

  return data_s.getValue()


keyAddress = 0x0042A050

g_key = getStringFromPTR(keyAddress)


class crypt:

  def __getDWORD(self, instr):

    global g_key

    res = []


    for el in instr:

      for i in range(len(g_key)):

        if el == g_key[i]:

          res.append(i)

          break


    while len(res) < 4: res.append(0) #pad DWORD with zeros

    return res

        

  def __unscramble(self, instr):

    if len(instr) != 4:

      print ("[-]Error, input in \"unscramble\" is garbage")

      return ""


    a0 = (4 * instr[0] + ((instr[1] >> 4) & 3)) & 0xff

    a1 =  (16 * instr[1] ^ (instr[2] >> 2) & 0xF) &0xff

    a2 =  (instr[3] + (instr[2] << 6)) &0xff

    return "".join([chr(a0), chr(a1), chr(a2)])


  def decrypt(self, inputString):

    res = ""

    #unscramble string in blocks of 4 bytes

    for i in range(0, len(inputString),4):

      base64Foo = self.__getDWORD(inputString[i:i+4])

      res += self.__unscramble(base64Foo)

    return res


def getStringFromOffset(offset):

  stringArrayStart = 0x00427C1C

  addr = stringArrayStart + (offset * 4)

  return getStringFromPTR(addr)


def getOperandBack(addr):

  ret = -1

  instr = getInstructionBefore(addr)

  for i in range(10):

    if instr.getMnemonicString() == 'PUSH':

      operand = str(instr.getOpObjects(0)[0])

      if operand.startswith('0x'):

        ret = int(operand[2:], 16)

        break

    instr = getInstructionBefore(instr.getAddress())

  return ret


def DecryptRef(addr):

  cryptHelper = crypt()

  for ref in getReferencesTo(toAddr(addr)):

    addr = ref.getFromAddress()

    offset = getOperandBack(addr)

    strFromPtr = getStringFromOffset(offset)

    if not strFromPtr:

      continue

    decryptedString = cryptHelper.decrypt(strFromPtr)

    setEOLComment(addr, decryptedString)

    print("[+] Decrypted string at Addr: {0} Offset: {1} Crypted: {2} Decrypted: {3}".format(addr, offset, strFromPtr, decryptedString))


DecryptRef(0x00405210)

DecryptRef(0x00407110)

DecryptRef(0x004019F0)

スクリプト実行結果

Ghidra consoleに以下のように出力されます。0x00405210ではWindowsAPI名が復号されており、他の2つ(0x00407110, 0x004019F0)では、マルウェア内で処理に使う文字列が復号されていました。

NewScript.py> Running...

[+] Decrypted string at Addr: 004039b2 Offset: 181 Crypted: g5kitTZiuO4qjGqhAVk89xdTMC Decrypted: GetNativeSystemInfo

[+] Decrypted string at Addr: 0040c23e Offset: 93 Crypted: tB271OzitGzqMq2iMGQJ45kLKT35uxgqKW Decrypted: NCryptOpenStorageProvider

[+] Decrypted string at Addr: 0040c26c Offset: 94 Crypted: tB271Ozi9xUCMGQi95kd Decrypted: NCryptImportKey

[+] Decrypted string at Addr: 00409f8f Offset: 152 Crypted: kUgtgxdUMxk7rOgqj5khK5qeMm2z Decrypted: WTSEnumerateSessionsA

(snip)

[+] Decrypted string at Addr: 0040d6d4 Offset: 6 Crypted: r5If4xdiXVqhXVQquVqp4czsLkL Decrypted: client is behind NAT

[+] Decrypted string at Addr: 004218ed Offset: 133 Crypted: tx3BAxIqXVJJK7zJMEQqrxgdXVQq4xRWMV3J4VkB Decrypted: Module has already been loaded

また、Listingウインドウのコメント欄に復号された文字列が表示されます。下図では、赤枠で示すように、オフセット渡し関数(FUNC_405210)に、復号化されたAPI名がコメントとして表示されています。右側のデコンパイル結果をみると、それぞれのAPIがGetProcAddress()を用い、アドレス解決が行われていることがわかります。これらのコメントを参考にして解析効率を上げることができます。

終わりに

本稿ではGhidrathonを利用したGhidra+Python3スクリプティングの事例を紹介しました。今回はPython3の長所を生かした解析まで行いませんでしたが、Python3化により、豊富な解析パッケージの利用が可能になることが確認できたと思います。Mandiantのブログ記事では、Python3上でエミュレータフレームワークUniconを使いStackストリングを構築する事例が紹介されています。是非、ご活用いただければと思います。

                          社会情報研究所NTT-CERT 田中 恭之、今野 俊一