FFRIエンジニアブログ

株式会社FFRIセキュリティのエンジニアが執筆する技術者向けブログです

LIEF の紹介

はじめに

基礎技術研究室リサーチエンジニアの中川です。ブログシステム移行後記念すべき(?)第 1 回を担当します。

今回は FFRI Dataset 作成にあたっても利用している LIEF というライブラリについて紹介します。

LIEF は PE・ELF などの実行ファイルをパースし、一部のレコードの編集を行うためのライブラリです。以下のような特徴を備えています。

  • 実行ファイルの一部のレコードを編集した上で新しい実行ファイルとして書き出し
  • パース可能な実行ファイルフォーマットが単純に多い (PE ELF MachO DEX VDEX ART など)
  • C C++ Python での API を提供 (※ C の場合は一部利用できない API あり)

実行ファイルのパーサー自体は pefilepyelftools がありましたが、複数の実行ファイルフォーマットに対応し、かつレコードの編集までできるライブラリはこれまでなかったように思えます。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 には resourcesresources_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)。

IMAGE_DATA_DIRECTORY の改変による dumpbin.exe のアンチ逆アセンブル

デバッグ・エキスポート情報などについては特にそうですが、マルウェアではあえて 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)。

test1.exe の import table を編集した test1_mod.exe 実行可能であることを示す図

こうした 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 の方が多く、さらに実装も綺麗で拡張しやすくなっており、安定性が向上すれば"最強"の表層情報取得ツールになることは間違いないです。今後に期待ですね。