はじめに
基礎技術研究室リサーチエンジニアの中川です。ブログシステム移行後記念すべき(?)第 1 回を担当します。
今回は FFRI Dataset 作成にあたっても利用している LIEF というライブラリについて紹介します。
LIEF は PE・ELF などの実行ファイルをパースし、一部のレコードの編集を行うためのライブラリです。以下のような特徴を備えています。
- 実行ファイルの一部のレコードを編集した上で新しい実行ファイルとして書き出し
- パース可能な実行ファイルフォーマットが単純に多い (PE ELF MachO DEX VDEX ART など)
- C C++ Python での API を提供 (※ C の場合は一部利用できない API あり)
実行ファイルのパーサー自体は pefile や pyelftools がありましたが、複数の実行ファイルフォーマットに対応し、かつレコードの編集までできるライブラリはこれまでなかったように思えます。GitHub スター数も 1800 と多く注目を集めています。
冒頭でも触れましたが、FFRI Dataset でも PE ファイルの表層情報取得に LIEF を用いています。また、Endgame が提供するデータセット EMBER でも PE ファイルの表層情報取得に LIEF が用いられています。これまでのデファクト的存在だった pefile と共に最近使われるようになった印象です。
私は微力ながらコントリビュータとして LIEF の開発に参加しておりまして、PE ファイルのパース処理のバグ修正を行いました。今は新規機能の開発(パース可能なリソースの種類の増加)・ライブラリの安定性向上(破損した PE ファイルのパースを可能にする)に取り組んでいます。
本記事では具体例を挙げながら LIEF のパース機能 (特に、特徴的なリソース周りを中心に)・バイナリ編集機能について紹介します。
LIEF のインストール
まず LIEF をインストールしましょう。
$ git clone https://github.com/lief-project/LIEF.git $ cd LIEF && mkdir build && cd build $ cmake -DLIEF_PYTHON_API=on -DPYTHON_VERSION=3.6 -DCMAKE_BUILD_TYPE=Release -DLIEF_TESTS=on -GNinja .. $ ninja
ビルド終了後 build/api/python
に cd して python (3.6 系が必要) を起動します。
$ python3 >>> import lief >>> print(lief.__version__) 0.11.0-ddc8b72
上記のように出力されれば OK です。
パーサーとしての使い方
手始めに、適当に PE ファイルをパースして種々のレコードを取り出してみましょう。リソース・署名情報・デバッグ情報などを含んでいる explorer.exe
を対象にします。
import lief binary = lief.parse("explorer.exe")
lief.parse
で実行ファイルのファイルタイプを推定の上、適切なパーサーを選択、パース結果を返してくれます。この binary
というオブジェクトは lief.PE.Binary
クラスのインスタンスで、パース結果が一括して含まれています。試しに section header・signature の情報を出力してみます。
# binary クラスの型を表示 print(type(binary)) # <class 'lief.PE.Binary'> # section header を表示 for section in binary.sections: print(section) # .text 240b19 1000 240c00 400 0 6.35507 CNT_CODE - MEM_EXECUTE - MEM_READ # .imrsiv 4 242000 0 0 0 -0 CNT_UNINITIALIZED_DATA - MEM_READ - MEM_WRITE # .rdata a4df6 243000 a4e00 241000 0 4.78998 CNT_INITIALIZED_DATA - MEM_READ # .data 6084 2e8000 2a00 2e5e00 0 3.15808 CNT_INITIALIZED_DATA - MEM_READ - MEM_WRITE # .pdata 1b858 2ef000 1ba00 2e8800 0 6.25607 CNT_INITIALIZED_DATA - MEM_READ # .didat 828 30b000 a00 304200 0 3.2932 CNT_INITIALIZED_DATA - MEM_READ - MEM_WRITE # .rsrc 131818 30c000 131a00 304c00 0 4.94062 CNT_INITIALIZED_DATA - MEM_READ # .reloc 4d98 43e000 4e00 436600 0 5.45348 CNT_INITIALIZED_DATA - MEM_DISCARDABLE - MEM_READ # explorer.exe に含まれている署名を表示 print(binary.signature) # Version: 1 # Digest Algorithm: SHA_256 # Content Info # ============ # Content Type: SPC_INDIRECT_DATA_CONTEXT # Type: SPC_PE_IMAGE_DATA # Digest Algorithm: SHA_256 # # # Certificates # ============ # Version: 3 # Serial Number: 33:00:00:02:66:bd:15:80:ef:a7:5c:d6:d3:00:00:00:00:02:66 # Signature Algorithm: SHA256_WITH_RSA_ENCRYPTION # Valid from: 2020-3-4 18:30:39 # Valid to: 2021-3-3 18:30:39 # Issuer: C=US, ST=Washington, L=Redmond, O=Microsoft Corporation, CN=Microsoft Windows Production PCA 2011 # ... (省略)
他にどういった情報がパースされているのかは、JSON 形式で lief.PE.Binary
をシリアライズしてくれる lief.to_json
の出力結果から確認することができます。
print(lief.to_json(binary))
出力
{ "data_directories": [ { "RVA": 0, "size": 0, "type": "EXPORT_TABLE" }, { "RVA": 3010088, "section": ".rdata", "size": 2640, "type": "IMPORT_TABLE" }, { "RVA": 3194880, "section": ".rsrc", "size": 1251352, "type": "RESOURCE_TABLE" }, { "RVA": 3076096, "section": ".pdata", "size": 112728, "type": "EXCEPTION_TABLE" }, { "RVA": 4436992, "section": ".rsrc", "size": 47704, "type": "CERTIFICATE_TABLE" }, { "RVA": 4448256, "section": ".reloc", "size": 19864, "type": "BASE_RELOCATION_TABLE" }, { "RVA": 2657520, "section": ".rdata", "size": 84, "type": "DEBUG" }, { "RVA": 0, "size": 0, "type": "ARCHITECTURE" }, { "RVA": 0, "size": 0, "type": "GLOBAL_PTR" }, { "RVA": 2417712, "section": ".rdata", "size": 40, "type": "TLS_TABLE" }, { "RVA": 2414480, "section": ".rdata", "size": 280, "type": "LOAD_CONFIG_TABLE" }, { "RVA": 0, "size": 0, "type": "BOUND_IMPORT" }, { "RVA": 2570672, "section": ".rdata", "size": 9112, "type": "IAT" }, { "RVA": 3000136, "section": ".rdata", "size": 1696, "type": "DELAY_IMPORT_DESCRIPTOR" }, { "RVA": 0, "size": 0, "type": "CLR_RUNTIME_HEADER" } ], "debug": [ { "addressof_rawdata": 2839664, "characteristics": 0, "code_view": { "age": 1, "cv_signature": "PDB_70", "filename": "explorer.pdb", ...(省略) }
なかなか情報量が多いですね。(実はこれでもほんの一部で、この後再配置情報のエントリーが大量に続きます。) DOS Header・NT Header・Optional Header・Section Header・COFF デバッグ情報・再配置情報・リソースなど、PE 表層情報のかなりの部分が含まれています。
lief.to_json
の結果に含まれるレコードの全貌を知りたい方は、FFRI Dataset の JSON Schema をご覧ください。各レコードの説明を description としてつけ、適宜 MSDN へのリンクを付与していますので、PE ファイルの勉強にもなるかと思います。
resources と resources_manager
大抵の情報は lief.PE.Binary
のプロパティとして簡単にアクセスできますが、リソースについては少し特殊です。
基本的には resources
プロパティの childs
にアクセスし、欲しいリソース id を持ったノードを探索する方法をとります。例えば、実行ファイルに含まれるマニフェストを得たいと思った場合、
root = binary.resources for child in root.childs: if child.id == int(lief.PE.RESOURCE_TYPES.MANIFEST): manifest = bytes(child.childs[0].childs[0].content).decode("utf-8") print(manifest)
のように resources.childs
から辿り、欲しいデータノードを探索します。
※少しだけここでリソースのデータ構造について簡単に説明しておきます。リソースは木構造になっており、各ノードは生データ (icon string manifest など) もしくは、subtree へのポインタが含まれています。LIEF では前者を ResourceData
後者を ResourceDirectory
クラスで管理しており、leaf には必ず ResourceData
が来るようになっています。木構造の各階層には特別な意味があり、図 1 を見てもらえればわかりやすいですが、root を第 0 階層として定義した場合、
- 第 1 階層はリソースのタイプ
- 第 2 階層は含まれるリソースの ID
- 第 3 階層は言語情報
を持っており、階層によって生データがグルーピングされています。
binary.resources
ではこの木構造を持ったデータをそのまま返すため、必要なリソースを取得するには木構造を探索する必要があり、少しだけ面倒です。この手間を省くため、LIEF では resources_manager
プロパティが用意されています。これを使うと必要なリソースへの探索処理を resources_manager
に委託することができ、以下のようにシンプルに必要なデータにアクセスすることが可能です。
print(binary.resources_manager.manifest)
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\r\n<!-- Copyright (c) Microsoft Corporation -->\r\n<!--\r\n This is the manifest file only for Explorer.\r\n It only differs from windowsshell.manifest in the <dpiAware> tag.\r\n-->\r\n<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">\r\n<assemblyIdentity\r\n name="Microsoft.Windows.pcshellshell.explorer"\r\n ... (省略)
また、resources_manager
を介してアクセスすると、リソースタイプに応じ ResourceData
に含まれているデータのパースが可能であれば、それも合わせて行ってくれます。例えばバージョン情報に resources_manager
を経由してアクセスすると
ver = binary.resources_manager.version print(type(ver)) # <class 'lief.PE.ResourceVersion'> print(ver.fixed_file_info) # Signature: feef04bd # Struct version: 10000 # File version: 10 - 0 - 19041 - 329 # Product version: 10 - 0 - 19041 - 329 # File OS: NT_WINDOWS32 # File type: APP
のように、ResourceVersion
というバージョン情報を保持した専用のクラスのインスタンスを結果として返してくれます。この点、resources
を使う場合、リソースの生のバイト列のデータにしかアクセスできず、同等の情報にアクセスするには、そのバイト列を再度パースしなければいけません。
ここまで聞くと、「常に resources_manager
を使えばいいのでは?」と感じるかもしれませんが、resources
プロパティが必要になる場面ももちろんあります。
特にマルウェアに多いですが、アイコンやダイアログのリソースの中にパックしたデータを埋め込んでいる場合があります。このケースでは、resources_manager
を使うとデータをアイコン・ダイアログなどとして解釈できないため、resources_manager
経由でアクセスしようとすると、パース過程でクラッシュしアクセスすることができません。例えばですが、こちらの検体(VirusTotal へのリンク なお、この実行ファイルはマルウェアですので、仮想環境での作業を推奨)の場合、VirusTotal によればリソースとしてアイコンを含んでいることがわかりますが(図 2)、resources_manager
経由ではアクセスできません。
binary = lief.PE.parse("95eb631f665e10e4214093a3104ae217da049640a1d961a907bae712be5f10c1") binary.resources_manager.icons # Traceback (most recent call last): # File "<stdin>", line 1, in <module> # lief.corrupted: Group icon type should be equal to 1 (5147) # ↑ アイコンとして解釈できないデータであるためパースの途中でクラッシュしてしまう
VirusTotal の Details を見ると、アイコンのデータが含まれていますが、7.88 と非常に高いエントロピーになっており、パックしたデータが含まれています。こういった場合には resources
からアクセスしたいデータまで辿っていかなければいけません。
少し長くなりましたので、resources
プロパティと resources_manager
プロパティの違いについて簡単にまとめておきます。
resources
プロパティ -> リソースの生データにアクセスする際に用いる。リソースの木構造をそのまま保持しているので、必要なデータにアクセスするには毎度手動で探索を行う必要がある。resources_manager
プロパティ -> リソースをタイプごとにパースした結果を取得する際に用いる(ただし、パースできない場合、結果の取得不可)。探索はresources_manager
の中で行なってくれ、ユーザーが手動で行う必要はない。
ちなみに lief.to_json
には resources
と resources_manager
の両方が出力結果に含まれており、それぞれ resources_tree
resources_manager
をキーとして取り出すことが可能です。
バイナリ編集ツールとしての LIEF
ここまで、LIEF のパーサーとしての側面について紹介してきましたが、LIEF はパース結果の一部のレコードを編集し新しい実行ファイルとして書き出す機能も有しています。この機能についても具体例を挙げながら紹介していきます。
なお、公式でもこの機能を使ったサンプルが用意されており、リポジトリとしてまとめられているので、興味があればそちらも合わせてご覧ください。(特に PE from scratch という PE ファイルを 0 から作るサンプルスクリプトは非常に興味深いサンプルです。)
例 1: IMAGE_DATA_DIRECTORY の RVA 値の編集
Optional Header に含まれる IMAGE_DATA_DIRECTORY のレコードの 1 つを編集し、新しく実行ファイルとして書き出してみましょう。(何故このようなことをするのかの理由は後述します。) テスト用に以下のような C のソースコード
#include <windows.h> int main() { MessageBoxA(NULL, "Stand by ready", "MBCS", MB_OK); }
を MSVC でコンパイルしたものを利用します。x64 でデバッグエントリーが消されないコンパイルオプションでビルドし、ファイル名は test0.exe
としておきます。
編集前の IMAGE_DATA_DIRECTORY の配列を表示しておきます。
import lief binary = lief.PE.parse("test0.exe") print(binary.data_directories) for data_directory in binary.data_directories: if data_directory.rva != 0: print(data_directory)
# 出力結果 Data directory "IMPORT_TABLE" RVA: 0x 26fc Size: 0x b4 Section: .rdata Data directory "EXCEPTION_TABLE" RVA: 0x 4000 Size: 0x 168 Section: .pdata Data directory "BASE_RELOCATION_TABLE" RVA: 0x 5000 Size: 0x 28 Section: .reloc Data directory "DEBUG" RVA: 0x 2258 Size: 0x 38 Section: .rdata Data directory "LOAD_CONFIG_TABLE" RVA: 0x 2290 Size: 0x 130 Section: .rdata Data directory "IAT" RVA: 0x 2000 Size: 0x 198 Section: .rdata
この binary
から IMAGE_DATA_DIRECTORY
のエントリーのうち IMAGE_DATA_DIRECTORY_ENTRY_DEBUG
を取り出し、RVA を 0 以外の無効な値 (0xffffffff) に設定し、test0_mod.exe
として書き出します。binary.data_directories
の配列にアクセスし、rva
プロパティの値を 0xffffffff に設定します。
binary = lief.PE.parse("test0.exe") binary.data_directories[int(lief.PE.DATA_DIRECTORY.DEBUG)].rva = 0xffffffff # <-- rva の値を 0xffffffff に設定 print(binary.data_directories[int(lief.PE.DATA_DIRECTORY.DEBUG)]) # Data directory "DEBUG" # RVA: 0x ffffffff # <-- RVA が 0xffffffff に確かに書き換わっている # Size: 0x 38 # Section: .rdata
このあと、実行ファイルとして書き出すには、PE Builder というクラスを作成し、build
メソッドを呼ぶことにより行えます。
builder = lief.PE.Builder(binary) # <-- PE Builder クラス作成 builder.build() # <-- PE ファイルをビルド builder.write("test0_mod.exe") # <-- ファイルとして書き出す
test0_mod.exe
ですが、デバッグ情報を改変しているだけで、問題なく実行可能です。
さて、この改変により一体何が起きるのでしょうか。実は dumpbin.exe
による逆アセンブルをできなくしてしまいます (図 3)。
デバッグ・エキスポート情報などについては特にそうですが、マルウェアではあえて RVA をこのように 0 以外の無効な値に設定している場合があります。pefile LIEF は RVA に無効な値が設定されている場合、その項目についてパースを中断、データエントリーがないものと判断してくれます。しかし dumpbin.exe
ではこの辺りの検証があまりしっかりとは行われていないようで、表層情報を出力してくれません。
例 2: import table に新しいエントリーを追加
前の例は正直 binary editor でも簡単にできてしまう例でしたので、次は LIEF を使わないと実現しにくい例についてみていきます。
#include <stdio.h> int main() { puts("Hello World"); }
上記ファイルを MSVC でビルドし、実行ファイル名を test1.exe
にしておきます。今回は test1.exe
に実行時には使われない dummy import 関数を追加してみます。
import lief binary = lief.PE.parse("test1.exe") binary.optional_header.dll_characteristics &= ~lief.PE.DLL_CHARACTERISTICS.DYNAMIC_BASE # <-- ASLR を無効化 user32 = binary.add_library("user32.dll") # <-- user32.dll!MessageBoxA をインポートするようにインポートテーブルを編集 user32.add_entry("MessageBoxA") builder.build_imports(True).patch_imports(True) # <-- import table を再構築 builder.build() builder.write("test1_mod.exe")
※ASLR を無効にしていますが、これは LIEF の現在の実装の制約によるもので必須です。build_imports
の実装を見るとわかりますが、エントリーを追加した後に再配置情報の更新を行なってくれません。そのため、image base の値を固定しておく必要があります。
編集後 import table に user32.dll!MessageBoxA
が追加されていることを確認してみます。
$ diff <(rabin2 -ij test1_mod.exe | jq ".imports[].name" | sort) <(rabin2 -ij test1.exe | jq ".imports[].name" | sort) < "user32.dll_MessageBoxA"
確かに user32.dll!MessageBoxA
が追加されていることが確認できます。もちろん、import table 編集後のファイルは実行可能です (図 4)。
こうした import table の編集は具体的にどういう場面で使えるでしょうか。まず、解析者に利用している API 情報について錯覚させることができます。また、PE 表層情報を用いた機械学習によるマルウェア検知で import table の情報を利用している場合、検知回避にこうした import table の編集は利用することがあります。gym malwareなどは機械学習による静的検知回避にあたり、PE ファイルの各種エントリーを編集しますが、編集の 1 手段としてこの import table の改変を行っています。
おわりに
本記事では LIEF のパーサー・バイナリ編集の機能について具体例を挙げながら紹介しました。今後、LIEF を利用する方のお役に少しでもたてれば幸いです。
pefile と LIEF を両方使っている人の感想としては、LIEF は(pefile と比較すると)まだまだ安定性にやや難があるという感じです。少し変わったパッカーでパックされていたりすると、表層情報取得時にセグフォで落ちたり、メモリ使い潰して落ちるみたいなことはよくあります。
一方で pefile と比較すると LIEF は C/C++ から使えるという利点があります。また、パースできている情報も LIEF の方が多く、さらに実装も綺麗で拡張しやすくなっており、安定性が向上すれば"最強"の表層情報取得ツールになることは間違いないです。今後に期待ですね。