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.
