Cyril François

NightMARE in der 0xelm Street, eine geführte Tour

Dieser Artikel beschreibt nightMARE, eine Python-basierte Bibliothek für Malware-Forscher, die von Elastic Security Labs entwickelt wurde, um die Skalierung von Analysen zu erleichtern. Es beschreibt, wie wir nightMARE verwenden, um Extraktoren für Malware-Konfigurationen zu entwickeln und Intelligenzindikatoren herauszuarbeiten.

17 Minuten LesezeitMalware-Analyse
NightMARE in der 0xelm Street, eine geführte Tour

Einführung

Seit der Gründung von Elastic Security Labs haben wir uns auf die Entwicklung von Tools zur Malware-Analyse konzentriert, die uns nicht nur bei unserer Forschung und Analyse unterstützen, sondern auch der Öffentlichkeit zugänglich machen. Wir möchten der Gemeinschaft etwas zurückgeben und so viel zurückgeben, wie wir von ihr bekommen. Um diese Tools robuster zu machen und die Code-Duplizierung zu reduzieren, haben wir die Python-Bibliothek nightMARE erstellt. Diese Bibliothek vereint verschiedene nützliche Funktionen für Reverse Engineering und Malware-Analyse. Wir verwenden es hauptsächlich zum Erstellen unserer Konfigurationsextraktoren für verschiedene weit verbreitete Malware-Familien, aber nightMARE ist eine Bibliothek, die auf mehrere Anwendungsfälle angewendet werden kann.

Mit der Veröffentlichung der Version 0.16 möchten wir die Bibliothek offiziell vorstellen und in diesem Artikel Details zu einigen interessanten Funktionen dieses Moduls bereitstellen. Außerdem wird in einem kurzen Tutorial erklärt, wie Sie damit Ihren eigenen Konfigurationsextraktor implementieren können, der mit der neuesten Version von LUMMA (zum Zeitpunkt des Beitrags) kompatibel ist.

nightMARE-Features-Tour

Angetrieben von Rizin

Um die Fähigkeiten gängiger Disassembler zu reproduzieren, verwendete nightMARE zunächst eine Reihe von Python-Modulen, um die verschiedenen für die statische Analyse erforderlichen Aufgaben auszuführen. Beispielsweise haben wir LIEF zum Parsen ausführbarer Dateien (PE, ELF), Capstone zum Disassemblieren von Binärdateien und SMDA zum Erhalten von Querverweisanalysen (XRef) verwendet.

Diese zahlreichen Abhängigkeiten machten die Wartung der Bibliothek komplexer als nötig. Um die Verwendung von Modulen von Drittanbietern so weit wie möglich zu reduzieren, haben wir uns daher für die Verwendung des umfassendsten verfügbaren Reverse-Engineering-Frameworks entschieden. Unsere Wahl fiel natürlich auf Rizin.

Rizin ist eine Open-Source-Reverse-Engineering-Software, die aus dem Radare2-Projekt hervorgegangen ist. Seine Geschwindigkeit, sein modulares Design und die nahezu unbegrenzte Anzahl an Funktionen, die auf seinen Vim-ähnlichen Befehlen basieren, machen es zu einer hervorragenden Wahl für das Backend. Wir haben es mithilfe des rz-pipe -Moduls in das Projekt integriert, wodurch es sehr einfach ist, eine Rizin-Instanz aus Python zu erstellen und zu instrumentieren.

Projektstruktur

Das Projekt ist entlang dreier Achsen strukturiert:

  • Das Modul „Analyse“ enthält Untermodule, die für die statische Analyse nützlich sind.
  • Das „Kern“-Modul enthält allgemein nützliche Untermodule: bitweise Operationen, Integer-Casting und wiederkehrende reguläre Ausdrücke zur Konfigurationsextraktion.
  • Das Modul „Malware“ enthält alle Algorithmusimplementierungen (Krypto, Entpacken, Konfigurationsextraktion usw.), gruppiert nach Malware-Familie und gegebenenfalls nach Version.

Analysemodule

Für die statische Binäranalyse bietet dieses Modul zwei sich ergänzende Arbeitstechniken: Disassemblierung und Befehlsanalyse mit Rizin über das Umkehrmodul und Befehlsemulation über das Emulationsmodul.

Wenn beispielsweise Konstanten manuell auf den Stapel verschoben werden, ist es möglich, den gesamten Code zu emulieren und die Daten auf dem Stapel zu lesen, sobald die Verarbeitung abgeschlossen ist, anstatt zu versuchen, die Anweisungen einzeln zu analysieren, um die unmittelbaren Anweisungen abzurufen.

Ein weiteres Beispiel, das wir später in diesem Artikel sehen werden, ist, dass es im Fall komplexer kryptografischer Funktionen oft einfacher ist, diese direkt in der Binärdatei mithilfe einer Emulation aufzurufen, als zu versuchen, sie manuell zu implementieren.

Rückfahrmodul

Dieses Modul enthält die Rizin-Klasse, eine Abstraktion der Rizin-Funktionalitäten, die dank rz-pipe Befehle direkt an Rizin senden und dem Benutzer kostenlos eine unglaubliche Menge an Analyseleistung bieten. Da es sich um eine Abstraktion handelt, können die von der Klasse bereitgestellten Funktionen problemlos in einem Skript verwendet werden, ohne dass Vorkenntnisse des Frameworks erforderlich sind.

Obwohl diese Klasse viele verschiedene Funktionen bietet, erheben wir keinen Anspruch auf Vollständigkeit. Das Ziel besteht darin, doppelten Code für wiederkehrende Funktionen in allen unseren Tools zu reduzieren. Wenn ein Benutzer jedoch feststellt, dass eine Funktion fehlt, kann er direkt mit dem rz-pipe -Objekt interagieren, um Befehle an Rizin zu senden und seine Ziele zu erreichen.

Hier ist eine kurze Liste der Funktionen, die wir am häufigsten verwenden:

# 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]

Emulationsmodul

In Version 0.16 haben wir das Emulationsmodul überarbeitet, um die Fähigkeiten von Rizin zur Ausführung seiner verschiedenen datenbezogenen Aufgaben voll auszunutzen. Unter der Haube wird die Unicorn-Engine zur Emulation verwendet.

Derzeit bietet dieses Modul nur eine „leichte“ PE-Emulation mit der Klasse WindowsEmulator, leicht in dem Sinne, dass nur das absolute Minimum zum Laden eines PE getan wird. Keine Umzüge, keine DLLs, keine Betriebssystememulation. Das Ziel besteht nicht darin, eine ausführbare Windows-Datei wie Qiling oder Sogen vollständig zu emulieren, sondern eine einfache Möglichkeit anzubieten, Codeausschnitte oder kurze Funktionssequenzen auszuführen, wobei die Einschränkungen bekannt sind.

Die WindowsEmulator-Klasse bietet mehrere nützliche Abstraktionen.

# 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

Die Klasse ermöglicht die Registrierung von zwei Arten von Hooks: normale Unicorn-Hooks und IAT-Hooks.

# 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:

Als Anwendungsbeispiel verwenden wir die Windows-Binärdatei DismHost.exe .

Die Binärdatei verwendet den Sleep-Import an der Adresse 0x140006404:

Wir werden daher ein Skript erstellen, das einen IAT-Hook für den Sleep-Import registriert, die Emulationsausführung bei Adresse 0x140006404 startet und bei Adresse 0x140006412 endet.

# 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()

Wichtig ist, dass die Hook-Funktion unbedingt mit der Funktion do_return zurückkehren muss, damit wir die Adresse erreichen können, die nach dem Aufruf liegt.

Wenn der Emulator startet, wird unser Hook korrekt ausgeführt.

Malware-Modul

Das Malware-Modul enthält alle Algorithmusimplementierungen für jede von uns abgedeckte Malware-Familie. Diese Algorithmen können je nach Art der Malware die Konfigurationsextraktion, kryptografische Funktionen oder das Entpacken von Beispielen umfassen. Alle diese Algorithmen nutzen die Funktionen des Analysemoduls, um ihre Arbeit zu erledigen, und liefern gute Beispiele für die Verwendung der Bibliothek.

Mit der Veröffentlichung von v0.16 decken wir die folgenden verschiedenen Malware-Familien ab.

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

Die vollständige Implementierung der LUMMA-Algorithmen, die wir im nächsten Kapitel des Tutorials behandeln, finden Sie unter dem LUMMA-Untermodul.

Bitte beachten Sie, dass die Wartung dieser Module aufgrund der raschen Weiterentwicklung von Malware schwierig ist. Wir freuen uns jedoch über jede Hilfe zum Projekt, jeden direkten Beitrag oder jede Problemmeldung.

Beispiel: LUMMA Konfigurations-Extraktion

LUMMA STEALER, auch bekannt als LUMMAC2, ist eine informationsstehlende Schadsoftware, die trotz einer kürzlichen Abschaltung im Mai 2025 immer noch häufig in Infektionskampagnen eingesetzt wird. Diese Malware beinhaltet Kontrollflussverschleierung und Datenverschlüsselung, was sowohl die statische als auch die dynamische Analyse schwieriger macht.

In diesem Abschnitt verwenden wir das folgende unverschlüsselte Beispiel als Referenz: 26803ff0e079e43c413e10d9a62d344504a134d20ad37af9fd3eaf5c54848122

Wir führen eine kurze Analyse durch, wie es seine Domänennamen Schritt für Schritt entschlüsselt, und demonstrieren dann nebenbei, wie wir den Konfigurationsextraktor mit nightMARE erstellen.

Schritt 1: Initialisieren des ChaCha20-Kontexts

In dieser Version führt LUMMA die Initialisierung seines kryptografischen Kontexts nach dem Laden WinHTTP.dll mit dem Entschlüsselungsschlüssel und dem Nonce durch. Dieser Kontext wird für jeden Aufruf der Entschlüsselungsfunktion ChaCha20 wiederverwendet, ohne erneut initialisiert zu werden. Die Nuance besteht hier darin, dass ein interner Zähler innerhalb des Kontexts bei jeder Verwendung aktualisiert wird. Daher müssen wir später den Wert dieses Zählers vor der ersten Domänenentschlüsselung berücksichtigen und sie dann in der richtigen Reihenfolge entschlüsseln.


Um diesen Schritt in unserem Skript zu reproduzieren, müssen wir den Schlüssel und den Nonce erfassen. Das Problem ist, dass wir ihren Standort nicht im Voraus kennen, aber wir wissen, wo sie eingesetzt werden. Wir führen einen Musterabgleich dieses Codeteils durch und extrahieren dann die Adressen g_key_0 (key) und g_key_1 (nonce) aus den Anweisungen.

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)

Schritt 2: Suchen Sie die Entschlüsselungsfunktion

In dieser Version lässt sich die Entschlüsselungsfunktion von LUMMA problemlos über alle Samples hinweg finden, da sie unmittelbar nach dem Laden von WinHTTP-Importen verwendet wird.

Wir leiten das Hex-Muster aus den ersten Bytes der Funktion ab, um es in unserem Skript zu lokalisieren:

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")

Schritt 3: Suchen Sie die Basisadresse der verschlüsselten Domäne

Durch die Verwendung von XRefs aus der Entschlüsselungsfunktion, die nicht wie andere LUMMA-Funktionen mit verschleierter Indirektion aufgerufen wird, können wir leicht herausfinden, wo sie zum Entschlüsseln der Domänen aufgerufen wird.

Wie im ersten Schritt verwenden wir die Anweisungen, um die Basisadresse der verschlüsselten Domänen in der Binärdatei zu ermitteln:

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"]

Schritt 4: Domänen per Emulation entschlüsseln

Eine schnelle Analyse der Entschlüsselungsfunktion zeigt, dass diese Version von LUMMA eine leicht angepasste Version von ChaCha20 verwendet. Wir erkennen dieselben kleinen und vielfältigen Entschlüsselungsfunktionen, die über die Binärdateien verstreut sind. Hier werden sie verwendet, um Teile der Konstanten ChaCha20 „expand 32-byte k“ zu entschlüsseln, die dann XOR-ROL-abgeleitet werden, bevor sie in der Kontextstruktur gespeichert werden.

Obwohl wir die Entschlüsselungsfunktion in unserem Skript implementieren könnten, verfügen wir über alle erforderlichen Adressen, um zu demonstrieren, wie wir die bereits in der Binärdatei vorhandene Funktion direkt aufrufen können, um unsere Domänen mithilfe des Emulationsmoduls von nightMARE zu entschlüsseln.

# 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

Ergebnis

Schließlich können wir unser Modul mit pytest ausführen und die LUMMA C2-Liste (decrypted_c2_list) anzeigen:

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

Dieses Beispiel zeigt, wie die nightMARE-Bibliothek für die Binäranalyse verwendet werden kann, insbesondere zum Extrahieren der Konfiguration aus dem LUMMA-Stealer.

nightMARE herunterladen

Die vollständige Implementierung des in diesem Artikel vorgestellten Codes ist hier verfügbar.

Fazit

nightMARE ist ein vielseitiges Python-Modul, das auf den besten Tools basiert, die die Open-Source-Community zu bieten hat. Mit der Veröffentlichung der Version 0.16 und diesem kurzen Artikel hoffen wir, seine Fähigkeiten und sein Potenzial demonstriert zu haben.

Intern steht das Projekt im Mittelpunkt verschiedener noch ehrgeizigerer Projekte und wir werden nightMARE weiterhin nach besten Kräften pflegen.