FFRIエンジニアブログ

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

Qiling Framework の紹介

はじめに

東京研究開発第一部セキュリティエンジニアの佐藤です。本記事では、Python ベースのバイナリエミュレーションフレームワークである Qiling Framework (以降 Qiling と表記) について紹介します。

サイバーセキュリティ技術には、マネジメント、脆弱性診断、セキュリティ監視、フォレンジックなど多種多様なスキルがあります。その中のマルウェア解析という技術は主に海外に依存しており、日本において実業務で実施する需要が限られていることから、マルウェア解析に取り組む機会が少なく、結果として実施できる人材が限られているのが現実です。しかし、インシデント対応や攻撃アクターの分析、脅威インテリジェンスの分析の基礎となる重要なスキルセットであることから、今後はマルウェアの挙動を迅速に把握できる手段が求められると考えられます。

マルウェア解析には、主に静的解析と動的解析が用いられますが、それぞれに対して解析の回避技術が進んでいます。静的解析では難読化やパッキングにより解析の回避が行われており、動的解析ではサンドボックスの検出回避や外部環境への依存といった課題が存在します。

こうした問題を解決するために、近年注目されているのが「半動的解析」と呼ばれる手法です。これは、マルウェアをエミュレーション実行し、任意の API やシステムコールをフックすることで、環境に依存せず柔軟にマルウェアの挙動を観察するためのアプローチです。

サンドボックス検知では、例えば IsDebuggerPresent や GetSystemInfo のような API を使用した際の結果に応じて仮想マシン上で動作しているかを検知します。動的解析ではこの API の挙動を変えることは困難ですが、Qiling を使用すれば、この API の結果を制御できるため、検知を無効にできます。また解析環境については、動的解析ではマルウェアが実際の OS や仮想環境上で動作するため、実行環境の違いによる影響があります。Qiling の場合は、マルウェアの機械語を元にメモリやレジスタをエミュレートし、ホスト環境を直接参照しないため、環境に左右されず解析できます。

本記事では、この半動的解析を可能とする Qiling というフレームワークについて、その機能と利点、実際に使用した結果をご紹介します。

Qiling とは

Qiling は、軽量で柔軟性の高いバイナリエミュレーションツールで、特にクロスプラットフォーム対応とスクリプトによる拡張性が強みになっています。例えば、スクリプトによりマルウェアなどのバイナリの挙動をトレースし、API 呼び出しやレジスタの状況を確認できます。また、API をオーバーライドして制御できます。例えば、ファイルの削除などの危険な動作を抑止しながら、解析できます。さらに、様々な CPU アーキテクチャをエミュレートする Unicorn Engine が使用されているため、直接そのエンジンを使用し、組み合わせて解析できます。

Qiling は、さまざまな国際カンファレンスでも紹介されており、Black Hat では複数回講演が行われています。このことからもその技術的有用性がセキュリティコミュニティで高く評価されていることが確認できます。

Qiling GitHub リンク

Unicorn Engine リンク

Qiling の機能

Qiling は単なる命令レベルのエミュレーターではなく、OS レベルの挙動やシステムコール、ファイルシステム、メモリ空間なども模倣します。また、マルチプラットフォームのバイナリの解析が可能で、非常に多くの機能があります。以下のような機能を使用することで、迅速なマルウェア解析を支援し、その挙動を把握することが可能となります。

  • クロスプラットフォームバイナリエミュレーション
    • 異なるプラットフォームのバイナリをホスト環境に関係なく実行可能
      • Windows (PE)
      • Linux (ELF)
      • macOS (Mach-O)
      • UEFI (.efi バイナリ)
      • DOS (.com, .exe)
      • BSD など
  • エミュレーション環境
    • 仮想的なファイルシステム (rootfs) 上で解析対象のバイナリを動作可能
    • 様々なバイナリが動作する OS 環境を模倣し、再現性の高いテストが可能
  • API 呼び出しのロギング
    • どの API (Windows API, libc 関数など) が呼び出されたかをログに記録
  • API のフック
    • フックや置換処理を行うことで、API の動作をカスタマイズ可能
  • 命令単位でのエミュレーション制御
    • 任意のアドレスで実行停止
    • メモリ読み書きの監視
    • レジスタの変化を追跡
    • Unicorn Engine を利用した高速かつ正確なエミュレーション
  • スクリプト解析
    • 解析対象に応じて Python で柔軟なスクリプトを書くことが可能
    • API トレース、解析の自動化、挙動の検出などをスクリプトベースで実装可能
  • 動的逆アセンブル・デバッグ支援
    • Capstone (命令デコード) や Keystone (命令生成) と連携
    • IDA Pro と連携した補助スクリプトあり

Qiling の使用例

ここでは、例として PE 形式の x86 アーキテクチャのマルウェアを対象として、汎用的に活用できそうな使用例を紹介します。

  • 特定の API 呼び出しの検出
    ファイル操作、プロセス生成、メモリ確保といったマルウェア解析において重要となる挙動の検出に活用できます。また、デバッガ検出や仮想環境検出などの耐解析技術が使用されている場合、特定の Windows API を利用して実現されることが多いため、その耐解析技術の使用有無の検出などに活用できます。API hashing による API 名の難読化にも対応しています。
  • Windows API のエミュレート処理の追加
    Qiling はすべての Windows API のエミュレート処理を実装しているわけではないため、マルウェアに未実装の Windows API が使用されている場合には、自前で処理を追加する必要があります。また、実装されている処理とは別の処理をさせることで、解析しやすいよう改変することもできます。
  • スナップショットによる中断と途中再開
    多くのマルウェアには、解析を困難にするため、文字列復号や API 解決等の長時間かかる処理を先頭に埋め込んでいることがあります。エミュレートの際、毎回この処理を行うのは時間がかかるため、これらが終わった後のタイミングからエミュレートしたい場合があります。また、ある時点に巻き戻し、別の条件分岐のルートをエミュレートしたい場合があります。このような場合に、不要なコード実行を省略し、途中再開することで解析時間を短縮できます。

はじめに Qiling の基本的なエミュレーションについて紹介し、その後に上記の使用例を紹介します。

基本的なエミュレーション

基本的に Qiling によるエミュレーションを行うためには、Python によるスクリプトを作成します。ここでは、その基本となるコードの記述例を紹介します。

from capstone import *
from qiling import *
from qiling.const import QL_OS, QL_ARCH, QL_VERBOSE

def hook_callback(ql, address, size):
    data = ql.mem.read(address, size)
    md = Cs(CS_ARCH_X86, CS_MODE_32)

    for i in md.disasm(data, address):
        print("[*] 0x{:08x}: {} {}".format(i.address, i.mnemonic, i.op_str))

ARGV    = ["qiling\\examples\\rootfs\\x86_windows\\bin\\example.exe"]
ROOTFS  = "qiling\\examples\\rootfs\\x86_windows"

ql = Qiling(
    argv=ARGV,
    rootfs=ROOTFS,
    ostype=QL_OS.WINDOWS,
    archtype=QL_ARCH.X86,
    verbose=QL_VERBOSE.DISABLED,
)

ql.hook_code(hook_callback)
ql.run()

この例の通り、はじめに Qiling インスタンスを定義します。パラメーターには他にも指定できる項目はありますが、基本的には次のものを指定しておけば十分です。

  • argv
    • 解析対象のマルウェアのパスを配列の 1 番目に指定します。
    • マルウェアを実行する際に引数を指定する場合は、配列の 2 番目以降に指定します。
  • rootfs
    • Qiling の仮想ファイルシステムのパスを指定します。
    • ファイルの入出力がある場合は、このパス配下が使用されます。
  • ostype
    • 解析対象のマルウェアが動作する OS を指定します。
    • Windows の場合は QL_OS.WINDOWS を指定します。
  • archtype
    • 解析対象のマルウェアのアーキテクチャを指定します。
    • x86 の場合は QL_ARCH.X86 を指定します。
  • verbose
    • エミュレーションのログレベルを指定します。
    • 内部的には Python の logging モジュールが使用されています。
    • このパラメーターを指定しない場合は、QL_VERBOSE.DEFAULT となり、INFO レベルが出力されます。
    • 詳細な情報を確認したい場合は QL_VERBOSE.DEBUG、対象のマルウェアや作成したスクリプトの出力以外の詳細情報が不要な場合は、QL_VERBOSE.DISABLED を指定します。

なお、ostype、archtype、verbose で指定できる値は「qiling/const.py」で定義されています。

次の処理ではマルウェアの命令コードが実行されるたびに呼び出されるコールバックを登録しています。

ql.hook_code(hook_callback)

パラメーターの hook_callback は関数として定義しており、次のように記述されています。

def hook_callback(ql, address, size):
    data = ql.mem.read(address, size)
    md = Cs(CS_ARCH_X86, CS_MODE_32)

    for i in md.disasm(data, address):
        print("[*] 0x{:08x}: {} {}".format(i.address, i.mnemonic, i.op_str))

フックする関数のパラメーターは原則的にこの 3 つ (qladdresssize) を指定します。ql には Qiling インスタンスのオブジェクト、address には命令アドレス、size には機械語のサイズが受け取られます。このコード記述例では、命令をデコードする Capstone の機能により x86 の逆アセンブリを作成し、命令アドレス、ニーモニックとオペランドを表示しています。なお、for 文を使っていますが、md.disasm(data, address) にはこのコールバックをトリガーした 1 命令分の逆アセンブルデータしか無いため、コールバック 1 回の呼び出しにつき 1 行ずつ標準出力されます。

最後に次の処理でエミュレーションを開始します。

ql.run()

このスクリプトの実行結果は次のようになり、スクリプトの出力内容の他に標準出力の内容も表示されます。

図1 基本的なエミュレーション実行結果

これにより、逆アセンブリをトレースし、どのような経路で処理を行っているかを確認できます。

特定の API 呼び出しの検出

特定の API の呼び出しを検出し、その引数や戻り値等を確認する場合、以下の 2 ステップが必要です。 1. 当該 API のアドレスを取得する 1. 当該 API が呼ばれた際に実行されるコールバックを定義し、引数や戻り値等を確認する

まず始めに、検出したい API のアドレスを取得する必要があります。しかし、Windows OS 上の API のアドレスをそのまま使用できない点に注意が必要です。Qiling は独自の仮想メモリを準備し、そこへ対象マルウェアをロードして実行します。このメモリ空間には、Windows API の一部をエミュレートしたものも配置されています。したがって、API 呼び出しには、実際の Windows OS 上での API ではなく、Qiling がエミュレートした API のアドレスが使われます。よって、エミュレートされた方の API のアドレスを取得する必要があります。

例えば、Windows API である IsDebuggerPresent のアドレスを取得する場合は、次のコードで取得できます。

funcs = ql.loader.import_symbols
for addr, info in funcs.items():
    if not info["name"]:
        continue
    func_name = info["name"].decode()
    dll_name = info["dll"]
    if func_name == "IsDebuggerPresent" and dll_name == "kernel32":
        target_addr = addr
        break

アドレスを取得した API が実行された際に呼ばれるコールバックは、以下のように定義できます。このコールバックでは、IsDebuggerPresent が呼ばれた旨をメッセージとして表示しています。

def hook_callback(ql, address, size):
    data = ql.mem.read(address, size)
    md = Cs(CS_ARCH_X86, CS_MODE_32)

    for i in md.disasm(data, address):
        print("[*] 0x{:08x}: {} {}".format(i.address, i.mnemonic, i.op_str))
        if i.address == target_addr:
            print("IsDebuggerPresent call detected")
            ql.emu_stop()

if 文の条件分岐で、現在の命令アドレスである i.address が検知したい API アドレスと一致しているかどうかを判定することによって、検出しています。ちなみに、ql.emu_stop() はエミュレーションを停止するコードです。

この例では、1 つの API 呼び出しのみ検知していますが、複数の API 呼び出しを検知することもできます。また、今回は紹介しませんが、その時点での eax などのレジスタの値や特定のアドレスの数値、文字列なども確認できます。

Windows API のエミュレート処理の追加

Windows API が呼び出されると Qiling 内に実装されたコードにて、それをエミュレートして動作を代替しています。このため、Qiling では個別に各 Windows API のエミュレーション用コードが整備されていますが、未実装の Windows API もあります。このような未実装の Windows API がエミュレーション中に呼び出された場合はクラッシュします。なお、当該の原因によるクラッシュ時には、エミュレーションのログレベルをデバッグレベル (QL_VERBOSE.DEBUG) としている場合、次のようなメッセージが表示されます。

図2 Windows API 未実装メッセージ

この例では Windows API の CryptAcquireContextW が実装されていないことが上記のメッセージより読み取れます。

Windows API の追加実装

Windows API が実装されていない場合、独自に Windows API のエミュレート処理を追加することによりクラッシュの発生を抑制できます。

はじめに、次のインポート処理を記述します。

from qiling.os.windows.api import *
from qiling.os.windows.fncc import *

次に、エミュレート処理を追加したい Windows API について、引数と API の処理内容を定義します。定義の形式は次の通りです。なお、ここで定義する引数は、エミュレート処理を追加したい実際の Windows API のものと同じにする必要があります。

@winsdkapi(cc=STDCALL, params={
    '<引数 1 変数名>': <データ型>,
    '<引数 2 変数名>': <データ型>,
            :
})
def hook_<Windows API名>(ql: Qiling, address: int, params):
    <エミュレート処理>

引数の定義にて指定するデータ型は、基本的には「qiling/os/windows/api.py」で定義されているものを使用します。この定義の中に該当するデータ型がない場合は、次の 2 つの方法をとることができます。

  • この定義内容と同様に自作のスクリプトで定義する。
  • ポインタの場合は POINTER、ハンドルの場合は HANDLE などの同等の別のデータ型を代用する。

例えば、自作のスクリプト内で定義する場合は、次のように定義できます。

PHCRYPTPROV                 = POINTER
HCRYPTPROV                  = HANDLE

この例では、PHCRYPTPROVHCRYPTPROV の 2 つのデータ型が api.py には定義されていないことから、独自に定義しています。なお、PHCRYPTPROVHCRYPTPROV を指し示すポインタを表すデータ型であるため、api.py にて定義済みの POINTER データ型にて代用できます。また、HCRYPTPROV はハンドルを表すデータ型であるため api.py にて定義済みの HANDLE データ型にて代用できます。

また、Windows API のエミュレート処理では、引数の値を取得することが必要となります。 例えば、引数 1 を取得する場合は、次の形式で記述できます。

<格納する変数名> = params["<引数 1 変数名>"]

以上の情報を元に、例として CryptAcquireContextW、CryptGenRandom、CryptReleaseContext の一連の Windows API を定義してみます。

これら一連の API はランダム値を取得するための API です。CryptAcquireContextW にて CSP ハンドルを取得し、そのハンドルを指定して CryptGenRandom を呼び出すことでランダム値を取得できます。最後に CryptReleaseContext にて当該の CSP ハンドルを解放する、というのが一般的な流れとなります。なお、CSP (Cryptographic Service Provider) は、暗号処理やランダム値生成などの機能を提供する Windows のアプリケーションサービスです。また、CSP ハンドルはその暗号サービスの識別子となる値です。

これらの API の定義については、次のように書くことができます。

@winsdkapi(cc=STDCALL, params={
    'phProv'      : PHCRYPTPROV,
    'szContainer' : LPCSTR,
    'szProvider'  : LPCSTR,
    'dwProvType'  : DWORD,
    'dwFlags'     : DWORD
})
def hook_CryptAcquireContextW(ql: Qiling, address: int, params):
    return 1

@winsdkapi(cc=STDCALL, params={
    'hProv'    : HCRYPTPROV,
    'dwLen'    : DWORD,
    'pbBuffer' : LPBYTE
})
def hook_CryptGenRandom(ql: Qiling, address: int, params):
    dwLen = params["dwLen"]
    pbBuffer = params["pbBuffer"]
    rand_str = b'\x01' * dwLen
    ql.mem.write(pbBuffer, rand_str)
    return 1

@winsdkapi(cc=STDCALL, params={
    'hProv'    : HANDLE,
    'dwFlags'  : DWORD
})
def hook_CryptReleaseContext(ql: Qiling, address: int, params):
    return 1

このコードでは、実際の Windows API にて実装されている処理よりも、かなり単純化しています。最初の CryptAcquireContextW では、何もせず成功値である 1 をリターンしているだけです。ハンドルを生成したり、そのハンドルを介して情報を受け渡したりする仕組みにはしていません。次の CryptGenRandom では、本来はランダム文字列を取得できるようにするべきですが、簡便化のため指定の長さの固定文字を指定のバッファに書き込み、成功値である 1 をリターンしています。最後の CryptReleaseContext では何もせず成功値である 1 をリターンしています。今回は CSP ハンドルを介して情報をやり取りしていないため、特にハンドルの解放などの処理は行わず引数として渡されるハンドルは無視しています。

上記のコードは簡易的な例ですが、Python の暗号ライブラリを用いて乱数生成するなど、様々なエミュレーションが可能です。また、Qiling は独自にハンドルを管理する仕組みがあるため、HCRYPTPROV ハンドルによる情報の受け渡しもエミュレート可能です。

最後に、定義した API をフックするための登録が必要で、エミュレーション開始前に次の形式で記述します。

ql.os.set_api("<Windows API名>", hook_<Windows API名>)

CryptAcquireContextW、CryptGenRandom、CryptReleaseContext の場合は次のように記述します。

ql.os.set_api("CryptAcquireContextW", hook_CryptAcquireContextW)
ql.os.set_api("CryptGenRandom", hook_CryptGenRandom)
ql.os.set_api("CryptReleaseContext", hook_CryptReleaseContext)

このようにして独自で未対応の Windows API を実装できます。

実装済み API の上書き実装

例えば、Windows API である FindFirstFileA、FindNextFileA、FindClose の Qiling での実装にはバグがあります。このため、Qiling でトレース時に実行されるとクラッシュします。これらの API を上書きして定義してみます。

これら一連の API はファイルを検索するための API です。FindFirstFileA にて検索条件に合致する 1 つ目のフィイル情報と検索ハンドルを取得します。その検索ハンドルを指定して FindNextFileA を呼び出すことで 2 つ目以降のファイル情報を取得できます。最後に FindClose にて当該の検索ハンドルを解放する、というのが本来の流れとなります。

はじめに、各 API の定義で使用するモジュールについて次のインポート処理を記述します。

from qiling.os.windows.api import *
from qiling.os.windows.fncc import *
from qiling.os.windows.handle import Handle, HandleManager
from qiling.os.windows.structs import FILETIME, make_win32_find_data
from qiling.os.windows.const import *
import os
import glob
import re

FindFirstFileA の API の定義

FindFirstFileA の API の定義については、次のように書くことができます。

@winsdkapi(cc=STDCALL, params={
    'lpFileName'     : LPCSTR,
    'lpFindFileData' : LPWIN32_FIND_DATAA
})
def hook_FindFirstFileA(ql: Qiling, address: int, params):
    filename = params["lpFileName"]
    pointer = params["lpFindFileData"]

    if not filename:
        return INVALID_HANDLE_VALUE

    habsfilename = ql.os.fs_mapper.path.virtual_to_host_path(filename)

    absfiles = glob.glob(habsfilename)

    pattern = os.path.basename(filename)
    pattern = re.escape(pattern)
    pattern = pattern.replace(r"\*", ".*")
    pattern = pattern.replace(r"\?", ".")
    for name in ["..", "."]:
        m = re.match(pattern, name)
        if m:
            absfiles.insert(0, os.path.dirname(habsfilename) + "\\" + name)

    absfilename = absfiles[0]

    set_file_info(absfilename, pointer, False)

    new_handle = Handle(obj=absfiles[1:])
    ql.os.handle_manager.append(new_handle)

    return new_handle.id

このコードではまず、API の引数として渡されるホスト上のパスを、Qiling の仮想ファイルシステム上のパスに変換して habsfilename に格納します。例えば、マルウェアが C:\* を探索しようとした場合、C:\qiling\example\rootfs\* を探索するよう変換します。その後、Python の glob モジュールで該当するファイル名一覧を変数 absfiles に格納しています。しかし、カレントディレクトリ . や親ディレクトリ .. は除外されます。このため、そのディレクトリが、指定されたワイルドカード文字を含むファイルパスの条件に適合する場合は absfiles に追加しています。そして、absfiles の 1 つ目のファイル情報について、後述する set_file_info 関数を使って、WIN32_FIND_DATA の形式にしてポインタの指す領域に格納しています。最後に Handle オブジェクトのインスタンスとして変数 new_handleabsfiles の 2 つ目以降のファイル一覧を設定しています。その後、Handle オブジェクトのインスタンスをハンドルマネージャに登録し、その ID をリターンしています。 なお、WIN32_FIND_DATA は「qiling/os/windows/structs.py」で定義されており、FindFirstFileA 関数や FindNextFileA の第 2 引数のポインタが指す領域のデータ構造と同じ構造になっています。

ここで簡単に Qiling のハンドル管理の仕組みを説明しておきます。ハンドル管理をするためには、はじめに Handle クラスのインスタンスとして情報を設定します。その情報の構成は次のようになっています。

  • id:0xa0000000 以降自動採番されます。
  • obj:扱うデータを指定します。
  • name:必要に応じて obj を識別するための名称などを設定します。
  • permissions:パーミッションの情報を設定します。

obj にすべての情報を詰め込めば、name や permissions の情報は基本的には使用不要ですが、目的に応じて、どのように使用しても問題はありません。今回は、obj をファイル名一覧として使用しています。

ハンドルマネージャでは append メソッドにより、この Handle クラスのインスタンスが id とマッピングされた状態で登録されます。あとは、id を元に get メソッドによりインスタンスの各種情報を取得したり、delete メソッドによりインスタンスを破棄したりできます。

set_file_info 関数は次に説明する FindNextFileA でも同様の処理となるため、共通関数として切り出したもので、次のように書くことができます。

def set_file_info(absfilename, pointer, wide):
    mask = (1 << 32) - 1
    filesize = os.path.getsize(absfilename)

    fdata_struct = make_win32_find_data(ql.arch.bits, wide=wide)
    
    if wide:
        filename = os.path.basename(absfilename)
        alternate_filename = ""
    else:
        filename = os.path.basename(absfilename).encode()
        alternate_filename = b""

    with fdata_struct.ref(ql.mem, pointer) as fdata_obj:
        fdata_obj.dwFileAttributes   = FILE_ATTRIBUTE_NORMAL
        fdata_obj.ftCreationTime     = FILETIME(0, 0)
        fdata_obj.ftLastAccessTime   = FILETIME(0, 0)
        fdata_obj.ftLastWriteTime    = FILETIME(0, 0)
        fdata_obj.nFileSizeHigh      = (filesize >> 32) & mask
        fdata_obj.nFileSizeLow       = (filesize >>  0) & mask
        fdata_obj.dwReserved0        = 0
        fdata_obj.dwReserved1        = 0
        fdata_obj.cFileName          = filename
        fdata_obj.cAlternateFileName = alternate_filename
        fdata_obj.dwFileType         = 0
        fdata_obj.dwCreatorType      = 0
        fdata_obj.wFinderFlags       = 0

このコードではファイル名の他にファイルサイズを取得し、WIN32_FIND_DATA の形式でデータを構成しています。他の項目については、解析上不要であることが多いため、仮の値を設定するなど固定値で設定しています。

FindNextFileA の API の定義

FindNextFileA の API の定義については、次のように書くことができます。

@winsdkapi(cc=STDCALL, params={
    'hFindFile'      : HANDLE,
    'lpFindFileData' : LPWIN32_FIND_DATAA
})
def hook_FindNextFileA(ql: Qiling, address: int, params):
    handleid = params["hFindFile"]
    pointer = params["lpFindFileData"]

    handle = ql.os.handle_manager.get(handleid)

    absfiles = handle.obj
    absfilename = absfiles[0]

    set_file_info(absfilename, pointer, False)

    handle.obj = absfiles[1:]

    return len(handle.obj)

このコードでは指定されたパラメーターの handleid を取得し、get メソッドにより FindFirstFileA で生成した Handle クラスのインスタンスを取得します。そして、その中に含まれる obj からファイル一覧を取得します。そのファイル一覧の 1 つ目のファイル情報について、set_file_info 関数を使って、WIN32_FIND_DATA の形式にしてポインタの指す領域に格納しています。その後、Handle クラスのインスタンスの obj に設定しているファイル一覧を 2 つ目以降のファイル一覧に変更しています。最後に FindNextFileA は次のファイルがない場合 0 を返却する仕様となっているため、そのファイル一覧のファイル数を返却することで、実際の FindNextFileA と近い動作にしています。

FindClose の API の定義

FindClose の API の定義については、次のように書くことができます。

@winsdkapi(cc=STDCALL, params={
    'hFindFile' : HANDLE
})
def hook_FindClose(ql: Qiling, address: int, params):
    handleid = params["hFindFile"]
    ql.os.handle_manager.delete(handleid)
    return 1

このコードでは、はじめに指定されたパラメーターの handleid を取得しています。次に delete メソッドにより FindFirstFileA で生成した Handle クラスのインスタンスを破棄し、成功値である 1 をリターンしています。

Windows API によってはその処理が実装されていてもバグがあったり、不完全でクラッシュしたりする場合がありますが、このようにして既存の実装を上書きすることによって、安定して動作させることができます。

スナップショットによる中断と途中再開

ある時点のメモリやレジスタなどの状態をスナップショットとして保存することでエミュレーションを停止し、その時点の状態を復旧し、再開できます。

次の 4 ステップにより、エミュレーションの中断、再開が可能となります。

  1. 次の命令アドレスの取得、保存
  2. スナップショットによる状態の保存
  3. スナップショットから状態のリストア
  4. 次の命令アドレスからエミュレーション再開

エミュレーションを中断した後、再開時には現在実行している命令の次の命令アドレスを実行してほしいはずです。つまり、スナップショットの取得に先立ち、次の命令アドレスを何らかの方法で入手しておく必要があります。

例えば、特定の命令アドレスのコールバック関数内では、次のようなコードで次の命令アドレス (next_addr) を取得できます。

def hook_callback(ql, address, size):
    data = ql.mem.read(address, size)
    md = Cs(CS_ARCH_X86, CS_MODE_32)

    next_addr = address + size

ある時点の状態を保存するには、次のような形式で記述します。

saved_state = ql.save(cpu_context=True, loader=True, snapshot=<スナップショットファイル名>)

ファイル名を指定した場合はディスク上にスナップショットファイルが作成されます。なお、パラメーターの snapshot 指定を省略した場合、戻り値である saved_state 変数にスナップショットが保持され、メモリ上で管理できます。

ファイルに保存したスナップショットの時点からエミュレーションを再開する場合は、次のような形式で記述し、スナップショットをリストアします。

ql.restore(snapshot=<スナップショットファイル名>)

なお、変数 saved_state にスナップショットを保存していた場合は、次のような形式で記述し、スナップショットをリストアします。

ql.restore(saved_state)

スナップショットのリストアが完了した後に、続いてはじめに取得した次の命令アドレスからエミュレーションを再開します。

ql.run(begin=<次の命令アドレス>)

これによってスナップショットを保存した時点からエミュレーションを再開できます。ただし、現在の Qiling の実装では、ファイルオープン中のハンドルの状態やレジストリの状態については保存できませんので、注意する必要があります。

おわりに

本記事では、Qiling を使ったマルウェア解析について紹介しました。

Qiling を活用することによって、スクリプトを使った自動化などを実現し、解析を効率化できます。また同等のマルウェアを解析する場合など属人性を削減できます。これにより静的解析や動的解析と組み合わせて解析することで、マルウェア検体の挙動、特徴の把握がしやすくなり、解析時間の短縮につながります。

ただ Qiling はまだ開発途上の側面もあり、一部の Windows API が未実装であったり、実装にバグがあったりします。しかしながら、Qiling の設計は拡張性が高く、利用者自身が Windows API を上書き・追加できる仕組みが用意されているため、逆に足りない部分を自由に補えるという利点もあります。興味がある方は一度試していただき、活用方法を検討してみてはいかがでしょうか。