Cyril François

0xelm Streetのナイトメア、ガイド付きツアー

この記事では、Elastic Security Labs が分析の拡張を支援するために開発した、マルウェア研究者向けの Python ベースのライブラリである nightMARE について説明します。nightMARE を使用してマルウェア構成抽出ツールを開発し、インテリジェンス インジケーターを切り出す方法について説明します。

17分で読めますマルウェア分析
0xelm Streetのナイトメア、ガイド付きツアー

はじめに

Elastic Security Labs の設立以来、私たちは研究と分析を支援するだけでなく、一般に公開するためのマルウェア分析ツールの開発に注力してきました。私たちはコミュニティに貢献し、そこから得たものと同じだけコミュニティを還元したいと考えています。これらのツールをより堅牢にし、コードの重複を減らすために、Python ライブラリnightMARE を作成しました。このライブラリは、リバース エンジニアリングとマルウェア分析に役立つさまざまな機能をまとめています。私たちは主に、さまざまな広く普及しているマルウェア ファミリ用の構成抽出ツールを作成するためにこれを使用していますが、nightMARE は複数のユース ケースに適用できるライブラリです。

バージョン 0.16 のリリースに伴い、このライブラリを正式に紹介し、このモジュールが提供する興味深い機能の詳細をこの記事で紹介するとともに、このライブラリを使用して最新バージョンの LUMMA (投稿日時点) と互換性のある独自の構成抽出機能を実装する方法を説明する短いチュートリアルも提供します。

nightMARE特集ツアー

RIZIN提供

一般的な逆アセンブラの機能を再現するために、nightMARE は当初、静的解析に必要なさまざまなタスクを実行する Python モジュールのセットを使用していました。たとえば、実行可能ファイルの解析 (PE、ELF) にはLIEFを使用し、バイナリの逆アセンブルにはCapstone を使用し、相互参照 (xref) 分析の取得にはSMDA を使用しました。

これらの多数の依存関係により、ライブラリの保守は必要以上に複雑になりました。そのため、サードパーティ モジュールの使用を可能な限り減らすために、利用可能な最も包括的なリバース エンジニアリング フレームワークを使用することを決定しました。私たちの選択は自然とRIZINへと向かいました。

Rizin は、Radare2 プロジェクトからフォークされたオープンソースのリバース エンジニアリング ソフトウェアです。そのスピード、モジュール設計、そして Vim のようなコマンドに基づくほぼ無限の機能セットにより、優れたバックエンドの選択肢となっています。私たちはrz-pipeモジュールを使用してこれをプロジェクトに統合しました。これにより、Python から Rizin インスタンスを簡単に作成してインストルメント化できるようになります。

プロジェクト構造

このプロジェクトは、3 つの軸に沿って構成されています。

  • 「分析」モジュールには、静的分析に役立つサブモジュールが含まれています。
  • 「core」モジュールには、ビット演算、整数キャスト、構成抽出のための繰り返し正規表現など、一般的に役立つサブモジュールが含まれています。
  • 「マルウェア」モジュールには、マルウェア ファミリ別、および該当する場合はバージョン別にグループ化されたすべてのアルゴリズム実装 (暗号化、解凍、構成の抽出など) が含まれています。

分析モジュール

静的バイナリ解析の場合、このモジュールは、リバース モジュールを介した Rizin による逆アセンブリと命令解析、およびエミュレーション モジュールを介した命令エミュレーションという 2 つの補完的な作業手法を提供します。

たとえば、定数を手動でスタックに移動する場合、命令を 1 つ 1 つ解析して即値を取得するのではなく、コード全体をエミュレートして、処理が完了したらスタック上のデータを読み取ることができます。

この記事の後半で説明するもう 1 つの例として、暗号化関数の場合、複雑な場合は手動で実装するよりも、エミュレーションを使用してバイナリで直接呼び出す方が簡単な場合が多いことが挙げられます。

リバースモジュール

このモジュールには、Rizin クラスが含まれています。これは、 rz-pipeによってコマンドを Rizin に直接送信する Rizin の機能を抽象化し、ユーザーに信じられないほどの分析力を無料で提供します。抽象化されているため、クラスが公開する関数は、フレームワークに関する事前の知識がなくてもスクリプトで簡単に使用できます。

このクラスはさまざまな機能を公開していますが、すべてを網羅しようとしているわけではありません。目標は、すべてのツールにわたって繰り返し使用される機能の重複コードを削減することです。ただし、機能が不足していることに気付いた場合は、ユーザーはrz-pipeオブジェクトを直接操作して Rizin にコマンドを送信し、目的を達成できます。

以下は、私たちが最もよく使用する機能の短いリストです。

# Disassembling
def disassemble(self, offset: int, size: int) -> list[dict[str, typing.Any]]
def disassemble_previous_instruction(self, offset: int) -> dict[str, typing.Any]
def disassemble_next_instruction(self, offset: int) -> dict[str, typing.Any]

# Pattern matching
def find_pattern(
    self, 
    pattern: str,
    pattern_type: Rizin.PatternType) -> list[dict[str, typing.Any]]
def find_first_pattern(
    self,
    patterns: list[str],
    pattern_type: Rizin.PatternType) -> int

# Reading bytes
def get_data(self, offset: int, size: int | None = None) -> bytes
def get_string(self, offset: int) -> bytes

# Reading words
def get_u8(self, offset: int) -> int
...
def get_u64(self, offset: int) -> int

# All strings, functions
def get_strings(self) -> list[dict[str, typing.Any]]
def get_functions(self) -> list[dict[str, typing.Any]]

# Xrefs
def get_xrefs_from(self, offset: int) -> list
def get_xrefs_to(self, offset: int) -> list[int]

エミュレーションモジュール

バージョン 0.16 では、Rizin の機能を最大限に活用してさまざまなデータ関連タスクを実行できるように、エミュレーション モジュールを再構築しました。内部的には、 Unicorn エンジンを使用してエミュレーションを実行します。

現時点では、このモジュールは、WindowsEmulator クラスを使用した「軽量」な PE エミュレーションのみを提供します。軽量とは、PE をロードするために厳密に最小限のことだけが実行されるという意味です。再配置なし、DLLなし、OS エミュレーションなし。目標は、 QilingSogenのような Windows 実行ファイルを完全にエミュレートすることではなく、制限を認識しながらコード スニペットや短い関数のシーケンスを実行する簡単な方法を提供することです。

WindowsEmulator クラスは、いくつかの便利な抽象化を提供します。

# Load PE and its stack
def load_pe(self, pe: bytes, stack_size: int) -> None

# Manipulate stack
def push(self, x: int) -> None
def pop(self) -> int

# Simple memory management mechanisms
def allocate_memory(self, size: int) -> int
def free_memory(self, address: int, size: int) -> None

# Direct ip and sp manipulation
@property
def ip(self) -> int
@property
def sp(self) -> int

# Emulate call and ret
def do_call(self, address: int, return_address: int) -> None
def do_return(self, cleaning_size: int = 0) -> None

# Direct unicorn access
@property
def unicorn(self) -> unicorn.Uc

このクラスでは、通常のユニコーン フックと IAT フックの 2 種類のフックを登録できます。

# Set unicorn hooks, however the WindowsEmulator instance get passed to the callback instead of unicorn
def set_hook(self, hook_type: int, hook: typing.Callable) -> int:

# Set hook on import call
def enable_iat_hooking(self) -> None:
def set_iat_hook(
        self,
        function_name: bytes,
        hook: typing.Callable[[WindowsEmulator, tuple, dict[str, typing.Any]], None],
) -> None:

使用例として、Windows バイナリDismHost.exeを使用します。

バイナリはアドレス0x140006404の Sleep インポートを使用します:

したがって、 Sleep インポート用の IAT フックを登録し、アドレス0x140006404でエミュレーション実行を開始し、アドレス0x140006412で終了するスクリプトを作成します。

# coding: utf-8

import pathlib

from nightMARE.analysis import emulation


def sleep_hook(emu: emulation.WindowsEmulator, *args) -> None:
    print(
        "Sleep({} ms)".format(
            emu.unicorn.reg_read(emulation.unicorn.x86_const.UC_X86_REG_RCX)
        ),
    )
    emu.do_return()


def main() -> None:
    path = pathlib.Path(r"C:\Windows\System32\Dism\DismHost.exe")
    emu = emulation.WindowsEmulator(False)
    emu.load_pe(path.read_bytes(), 0x10000)
    emu.enable_iat_hooking()
    emu.set_iat_hook("KERNEL32.dll!Sleep", sleep_hook)
    emu.unicorn.emu_start(0x140006404, 0x140006412)


if __name__ == "__main__":
    main()

呼び出し後のアドレスに到達できるように、フック関数は必ずdo_return関数を返す必要があることに注意することが重要です。

エミュレータが起動すると、フックが正しく実行されます。

マルウェアモジュール

マルウェア モジュールには、対象となる各マルウェア ファミリのすべてのアルゴリズム実装が含まれています。これらのアルゴリズムは、マルウェアの種類に応じて、構成の抽出、暗号化機能、またはサンプルの解凍をカバーできます。これらのアルゴリズムはすべて、分析モジュールの機能を使用して機能を実行し、ライブラリの使用方法の優れた例を提供します。

v0.16 のリリースでは、次のようなさまざまなマルウェア ファミリがカバーされています。

blister
deprecated
ghostpulse
latrodectus
lobshot
lumma
netwire
redlinestealer
remcos
smokeloader
stealc
strelastealer
xorddos

次の章のチュートリアルで説明する LUMMA アルゴリズムの完全な実装は、LUMMA サブモジュールにあります。

マルウェアは急速に進化するため、これらのモジュールの維持は困難ですが、プロジェクトへの支援、直接的な貢献、問題の提起は歓迎します。

例: LUMMA構成抽出

LUMMA STEALER(別名 LUMMAC2)は、2025 年 5 月に削除作戦が行われたにもかかわらず、依然として感染キャンペーンで広く使用されている情報窃盗マルウェアです。このマルウェアには制御フローの難読化とデータ暗号化が組み込まれているため、静的および動的の両方の分析が困難になっています。

このセクションでは、次の暗号化されていないサンプルを参照として使用します: 26803ff0e079e43c413e10d9a62d344504a134d20ad37af9fd3eaf5c54848122

ドメイン名を段階的に復号化する方法を簡単に分析し、その過程で nightMARE を使用して構成抽出ツールを構築する方法を説明します。

ステップ1: ChaCha20コンテキストの初期化

このバージョンでは、LUMMA はWinHTTP.dll読み込んだ後、復号化キーと nonce を使用して暗号化コンテキストの初期化を実行します。このコンテキストは、再初期化されることなく、 ChaCha20復号化関数の各呼び出しで再利用されます。ここでのニュアンスは、コンテキスト内の内部カウンターは使用ごとに更新されるため、後で最初のドメイン復号化の前にこのカウンターの値を考慮して、正しい順序で復号化する必要があるということです。


スクリプトでこのステップを再現するには、キーと nonce を収集する必要があります。問題は、それらの位置は事前に分からないが、それらが使用される場所が分かっているということです。コードのこの部分をパターン マッチし、命令からアドレスg_key_0 (key)g_key_1 (nonce)を抽出します。

CRYPTO_SETUP_PATTERN = "b838?24400b???????00b???0???0096f3a5"

def get_decryption_key_and_nonce(binary: bytes) -> tuple[bytes, bytes]:
    # Load the binary in Rizin
    rz = reversing.Rizin.load(binary)

    # Find the virtual address of the pattern
    if not (
        x := rz.find_pattern(
            CRYPTO_SETUP_PATTERN, reversing.Rizin.PatternType.HEX_PATTERN
        )
    ):
        raise RuntimeError("Failed to find crypto setup pattern virtual address")

    # Extract the key and nonce address from the instruction second operand
    crypto_setup_va = x[0]["address"]
    key_and_nonce_address = rz.disassemble(crypto_setup_va, 1)[0]["opex"]["operands"][
        1
    ]["value"]

    # Return the key and nonce data
    return rz.get_data(key_and_nonce_address, CHACHA20_KEY_SIZE), rz.get_data(
        key_and_nonce_address + CHACHA20_KEY_SIZE, CHACHA20_NONCE_SIZE
    )

def build_crypto_context(key: bytes, nonce: bytes, initial_counter: int) -> bytes:
    crypto_context = bytearray(0x40)
    crypto_context[0x10:0x30] = key
    crypto_context[0x30] = initial_counter
    crypto_context[0x38:0x40] = nonce
    return bytes(crypto_context)

ステップ2: 復号化関数を見つける

このバージョンでは、LUMMA の復号化機能は WinHTTP インポートのロード直後に利用されるため、サンプル間で簡単に見つけることができます。

関数の最初のバイトから 16 進パターンを導出して、スクリプト内でその位置を特定します。

DECRYPTION_FUNCTION_PATTERN = "5553575681ec1?0100008b??243?01000085??0f84??080000"

def get_decryption_function_address(binary) -> int:
    # A cache system exist so the binary is only loaded once, then we get the same instance of Rizin :)
    if x := reversing.Rizin.load(binary: bytes).find_pattern(
        DECRYPTION_FUNCTION_PATTERN, reversing.Rizin.PatternType.HEX_PATTERN
    ):
        return x[0]["address"]
    raise RuntimeError("Failed to find decryption function address")

ステップ3: 暗号化されたドメインのベースアドレスを見つける

他の LUMMA 関数のように難読化された間接参照で呼び出されない復号化関数からの xref を使用することで、ドメインを復号化するために呼び出される場所を簡単に見つけることができます。

最初のステップと同様に、バイナリ内の暗号化されたドメインのベース アドレスを検出するための手順を使用します。

C2_LIST_MAX_LENGTH = 0xFF
C2_SIZE = 0x80
C2_DECRYPTION_BRANCH_PATTERN = "8d8?e0?244008d7424??ff3?565?68????4500e8????ffff"

def get_encrypted_c2_list(binary: bytes) -> list[bytes]:
    rz = reversing.Rizin.load(binary)
    address = get_encrypted_c2_list_address(binary)
    encrypted_c2 = []
    for ea in range(address, address + (C2_LIST_MAX_LENGTH * C2_SIZE), C2_SIZE):
        encrypted_c2.append(rz.get_data(ea, C2_SIZE))
    return encrypted_c2


def get_encrypted_c2_list_address(binary: bytes) -> int:
    rz = reversing.Rizin.load(binary)
    if not len(
        x := rz.find_pattern(
            C2_DECRYPTION_BRANCH_PATTERN, reversing.Rizin.PatternType.HEX_PATTERN
        )
    ):
        raise RuntimeError("Failed to find c2 decryption pattern")

    c2_decryption_va = x[0]["address"]
    return rz.disassemble(c2_decryption_va, 1)[0]["opex"]["operands"][1]["disp"]

ステップ4: エミュレーションを使用してドメインを復号化する

復号化関数を簡単に分析すると、このバージョンの LUMMA ではChaCha20のわずかにカスタマイズされたバージョンが使用されていることがわかります。バイナリ全体に散在する同じ小さく多様な復号化関数を認識します。ここでは、それらはChaCha20 「展開 32 バイト k」定数の一部を復号化するために使用され、その後、コンテキスト構造に格納される前に XOR-ROL 導出されます。

スクリプトに復号化機能を実装することもできますが、nightMARE のエミュレーション モジュールを使用してバイナリにすでに存在する関数を直接呼び出してドメインを復号化する方法を示すために必要なアドレスはすべてあります。

# We need the right initial value, before decrypting the domain
# the function is already called once so 0 -> 2
CHACHA20_INITIAL_COUNTER = 2

def decrypt_c2_list(
    binary: bytes, encrypted_c2_list: list[bytes], key: bytes, nonce: bytes
) -> list[bytes]:
    # Get the decryption function address (step 2)
    decryption_function_address = get_decryption_function_address(binary)

    # Load the emulator, True = 32bits
    emu = emulation.WindowsEmulator(True)
 
    # Load the PE in the emulator with a stack of 0x10000 bytes
    emu.load_pe(binary, 0x10000)
    
    # Allocate the chacha context
    chacha_ctx_address = emu.allocate_memory(CHACHA20_CTX_SIZE)
    
    # Write at the chacha context address the crypto context
    emu.unicorn.mem_write(
        chacha_ctx_address,
        build_crypto_context(
            key,
            nonce,
            CHACHA20_INITIAL_COUNTER, 
        ),
    )

    decrypted_c2_list = []
    for encrypted_c2 in encrypted_c2_list:
	 # Allocate buffers
        encrypted_buffer_address = emu.allocate_memory(C2_SIZE)
        decrypted_buffer_address = emu.allocate_memory(C2_SIZE)
        
        # Write encrypted c2 to buffer
        emu.unicorn.mem_write(encrypted_buffer_address, encrypted_c2)

        # Push arguments
        emu.push(C2_SIZE)
        emu.push(decrypted_buffer_address)
        emu.push(encrypted_buffer_address)
        emu.push(chacha_ctx_address)
 
        # Emulate a call
        emu.do_call(decryption_function_address, emu.image_base)

        # Fire!
        emu.unicorn.emu_start(decryption_function_address, emu.image_base)

        # Read result from decrypted buffer
        decrypted_c2 = bytes(
            emu.unicorn.mem_read(decrypted_buffer_address, C2_SIZE)
        ).split(b"\x00")[0]

        # If result isn't printable we stop, no more domain
        if not bytes_re.PRINTABLE_STRING_REGEX.match(decrypted_c2):
            break

        # Add result to the list
        decrypted_c2_list.append(b"https://" + decrypted_c2)

        # Clean up the args
        emu.pop()
        emu.pop()
        emu.pop()
        emu.pop()

        # Free buffers
        emu.free_memory(encrypted_buffer_address, C2_SIZE)
        emu.free_memory(decrypted_buffer_address, C2_SIZE)

       # Repeat for the next one ...

    return decrypted_c2_list

結果

最後に、 pytestを使用してモジュールを実行し、LUMMA C2 リスト ( decrypted_c2_list ) を表示します。

https://mocadia[.]com/iuew  
https://mastwin[.]in/qsaz  
https://ordinarniyvrach[.]ru/xiur  
https://yamakrug[.]ru/lzka  
https://vishneviyjazz[.]ru/neco  
https://yrokistorii[.]ru/uqya  
https://stolevnica[.]ru/xjuf  
https://visokiykaf[.]ru/mntn  
https://kletkamozga[.]ru/iwqq

この例では、nightMARE ライブラリをバイナリ分析、具体的には LUMMA スティーラーから構成を抽出するためにどのように使用できるかについて説明します。

nightMAREをダウンロード

この記事で紹介したコードの完全な実装は、こちらから入手できます

まとめ

nightMARE は、オープンソース コミュニティが提供する最高のツールに基づいた、多用途の Python モジュールです。バージョン 0.16 のリリースとこの短い記事によって、その機能と可能性を示すことができたと思います。

社内的には、このプロジェクトはさまざまなさらに野心的なプロジェクトの中心であり、私たちは今後も全力を尽くして nightMARE を維持していきます。

この記事を共有する