Cyril François

NightMARE sur la rue 0xelm, une visite guidée

Cet article décrit nightMARE, une bibliothèque en python destinée aux chercheurs de logiciels malveillants et développée par Elastic Security Labs pour faciliter l'analyse à grande échelle. Il décrit comment nous utilisons nightMARE pour développer des extracteurs de configuration de logiciels malveillants et extraire des indicateurs de renseignement.

17 minutes de lectureAnalyse des malwares
NightMARE sur la rue 0xelm, une visite guidée

Introduction

Depuis la création d'Elastic Security Labs, nous nous sommes concentrés sur le développement d'outils d'analyse de logiciels malveillants, non seulement pour nous aider dans nos recherches et nos analyses, mais aussi pour les mettre à la disposition du public. Nous voulons rendre à la communauté tout ce qu'elle nous donne. Afin de rendre ces outils plus robustes et de réduire la duplication du code, nous avons créé la bibliothèque Python nightMARE. Cette bibliothèque rassemble diverses fonctionnalités utiles pour la rétro-ingénierie et l'analyse des logiciels malveillants. Nous l'utilisons principalement pour créer nos extracteurs de configuration pour différentes familles de logiciels malveillants répandus, mais nightMARE est une bibliothèque qui peut être appliquée à de multiples cas d'utilisation.

Avec la sortie de la version 0.16, nous souhaitons présenter officiellement la bibliothèque et détailler dans cet article quelques fonctionnalités intéressantes offertes par ce module, ainsi qu'un court tutoriel expliquant comment l'utiliser pour implémenter votre propre extracteur de configuration compatible avec la dernière version de LUMMA (à la date de publication).

nightMARE présente la tournée

Propulsé par Rizin

Pour reproduire les capacités des désassembleurs les plus répandus, nightMARE a d'abord utilisé un ensemble de modules Python pour effectuer les différentes tâches nécessaires à l'analyse statique. Par exemple, nous avons utilisé LIEF pour l'analyse des exécutables (PE, ELF), Capstone pour désassembler les binaires et SMDA pour obtenir une analyse des références croisées (xref).

Ces nombreuses dépendances ont rendu la maintenance de la bibliothèque plus complexe que nécessaire. C'est pourquoi, afin de réduire autant que possible l'utilisation de modules tiers, nous avons décidé d'utiliser le cadre de rétro-ingénierie le plus complet qui soit. Notre choix s'est naturellement porté sur Rizin.

Rizin est un logiciel de rétro-ingénierie open-source, issu du projet Radare2. Sa vitesse, sa conception modulaire et son ensemble presque infini de fonctionnalités basées sur ses commandes de type Vim en font un excellent choix de backend. Nous l'avons intégré au projet en utilisant le module rz-pipe, qui permet de créer et d'instrumenter très facilement une instance Rizin à partir de Python.

Structure du projet

Le projet s'articule autour de trois axes :

  • Le module d'analyse "" contient des sous-modules utiles pour l'analyse statique.
  • Le module principal "" contient des sous-modules couramment utilisés : opérations sur les bits, moulage d'entiers et expressions récurrentes pour l'extraction de la configuration.
  • Le module "malware" contient toutes les implémentations d'algorithmes (cryptage, déballage, extraction de configuration, etc.), regroupées par famille de logiciels malveillants et, le cas échéant, par version.

Modules d'analyse

Pour l'analyse binaire statique, ce module propose deux techniques de travail complémentaires : le désassemblage et l'analyse des instructions avec Rizin via le module d'inversion, et l'émulation des instructions via le module d'émulation.

Par exemple, lorsque des constantes sont déplacées manuellement sur la pile, au lieu d'essayer d'analyser les instructions une par une pour récupérer les immédiates, il est possible d'émuler l'ensemble du code et de lire les données sur la pile une fois le traitement terminé.

Un autre exemple que nous verrons plus loin dans cet article est que, dans le cas des fonctions cryptographiques, si elles sont complexes, il est souvent plus simple de les appeler directement dans le binaire à l'aide de l'émulation que d'essayer de les implémenter manuellement.

Module d'inversion

Ce module contient la classe Rizin, qui est une abstraction des fonctionnalités de Rizin qui envoie des commandes directement à Rizin grâce à rz-pipe et offre à l'utilisateur une incroyable puissance d'analyse gratuitement. Comme il s'agit d'une abstraction, les fonctions que la classe expose peuvent être facilement utilisées dans un script sans connaissance préalable du cadre.

Bien que cette classe expose un grand nombre de fonctionnalités différentes, nous n'essayons pas d'être exhaustifs. L'objectif est de réduire la duplication du code pour les fonctionnalités récurrentes dans tous nos outils. Toutefois, si un utilisateur constate qu'une fonction est manquante, il peut interagir directement avec l'objet rz-pipe pour envoyer des commandes à Rizin et atteindre ses objectifs.

Voici une courte liste des fonctions que nous utilisons le plus souvent :

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

Module d'émulation

Dans la version 0.16, nous avons retravaillé le module d'émulation pour tirer pleinement parti des capacités de Rizin à effectuer ses diverses tâches liées aux données. Sous le capot, il utilise le moteur Unicorn pour effectuer l'émulation.

Pour l'instant, ce module n'offre qu'une émulation PE "light" avec la classe WindowsEmulator, light dans le sens où seul le strict minimum est fait pour charger un PE. Pas de relocalisation, pas de DLL, pas d'émulation de système d'exploitation. Le but n'est pas d'émuler complètement un exécutable Windows comme Qiling ou Sogen, mais d'offrir un moyen simple d'exécuter des extraits de code ou de courtes séquences de fonctions tout en connaissant ses limites.

La classe WindowsEmulator offre plusieurs abstractions utiles.

# 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

La classe permet l'enregistrement de deux types de crochets : les crochets licornes normaux et les crochets IAT.

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

Comme exemple d'utilisation, nous utilisons le binaire Windows DismHost.exe .

Le binaire utilise l'importation Sleep à l'adresse 0x140006404:

Nous allons donc créer un script qui enregistre un crochet IAT pour l'importation Sleep, qui démarre l'exécution de l'émulation à l'adresse 0x140006404, et qui se termine à l'adresse 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()

Il est important de noter que la fonction hook doit nécessairement retourner avec la fonction do_return afin que nous puissions atteindre l'adresse située après l'appel.

Lorsque l'émulateur démarre, notre hook est correctement exécuté.

Module malveillant

Le module sur les logiciels malveillants contient toutes les implémentations d'algorithmes pour chaque famille de logiciels malveillants que nous couvrons. Ces algorithmes peuvent couvrir l'extraction de configuration, les fonctions cryptographiques ou le déballage d'échantillons, en fonction du type de logiciel malveillant. Tous ces algorithmes utilisent les fonctionnalités du module d'analyse pour effectuer leur travail et fournissent de bons exemples d'utilisation de la bibliothèque.

Avec la sortie de la v0.16, voici les différentes familles de logiciels malveillants que nous couvrons.

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

L'implémentation complète des algorithmes LUMMA que nous aborderons dans le tutoriel du chapitre suivant se trouve dans le sous-module LUMMA.

Veuillez noter que l'évolution rapide des logiciels malveillants rend la maintenance de ces modules difficile, mais nous accueillons volontiers toute aide au projet, toute contribution directe ou tout problème d'ouverture.

Exemple : Extraction de la configuration de LUMMA

LUMMA STEALER, également connu sous le nom de LUMMAC2, est un logiciel malveillant de vol d'informations encore largement utilisé dans les campagnes d'infection malgré une récente opération de démantèlement en mai 2025. Ce logiciel malveillant incorpore l'obscurcissement du flux de contrôle et le cryptage des données, ce qui rend son analyse statique et dynamique plus difficile.

Dans cette section, nous utiliserons l'échantillon non crypté suivant comme référence : 26803ff0e079e43c413e10d9a62d344504a134d20ad37af9fd3eaf5c548122

Nous analysons brièvement la manière dont il décrypte ses noms de domaine étape par étape, puis nous montrons comment nous construisons l'extracteur de configuration à l'aide de nightMARE.

Étape 1 : Initialisation du contexte ChaCha20

Dans cette version, LUMMA effectue l'initialisation de son contexte cryptographique après avoir chargé WinHTTP.dll, avec la clé de décryptage et le nonce ; ce contexte sera réutilisé pour chaque appel à la fonction de décryptage ChaCha20 sans être réinitialisé. La nuance ici est qu'un compteur interne au contexte est mis à jour à chaque utilisation, de sorte que nous devrons ultérieurement prendre en compte la valeur de ce compteur avant le premier décryptage de domaine, puis les décrypter dans l'ordre correct.


Pour reproduire cette étape dans notre script, nous devons collecter la clé et le nonce. Le problème est que nous ne connaissons pas leur emplacement à l'avance, mais nous savons où ils sont utilisés. Nous effectuons une correspondance de motifs dans cette partie du code, puis nous extrayons les adresses g_key_0 (key) et g_key_1 (nonce) des instructions.

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)

Étape 2 : Localiser la fonction de décryptage

Dans cette version, la fonction de décryptage de LUMMA est facilement repérable dans les échantillons, car elle est utilisée immédiatement après le chargement des importations WinHTTP.

Nous déduisons le motif hexadécimal des premiers octets de la fonction pour la localiser dans notre script :

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

Étape 3 : Localiser l'adresse de base du domaine crypté

En utilisant les références x de la fonction de décryptage, qui n'est pas appelée avec une indirection obscurcie comme d'autres fonctions LUMMA, nous pouvons facilement trouver l'endroit où elle est appelée pour décrypter les domaines.

Comme pour la première étape, nous utiliserons les instructions pour découvrir l'adresse de base des domaines cryptés dans le binaire :

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

Étape 4 : Décryptage des domaines à l'aide de l'émulation

Une analyse rapide de la fonction de décryptage montre que cette version de LUMMA utilise une version légèrement personnalisée de ChaCha20. Nous reconnaissons les mêmes fonctions de décryptage, petites et diverses, disséminées dans les binaires. Ici, ils sont utilisés pour décrypter des parties de la constante ChaCha20 " expand 32-byte k", qui sont ensuite dérivées par XOR-ROL avant d'être stockées dans la structure contextuelle.

Bien que nous puissions implémenter la fonction de décryptage dans notre script, nous disposons de toutes les adresses nécessaires pour démontrer comment nous pouvons appeler directement la fonction déjà présente dans le binaire pour décrypter nos domaines, en utilisant le module d'émulation de 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

Résultat

Enfin, nous pouvons exécuter notre module avec pytest et consulter la liste 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

Cet exemple montre comment la bibliothèque nightMARE peut être utilisée pour l'analyse binaire, en particulier pour extraire la configuration du voleur LUMMA.

Télécharger nightMARE

L'implémentation complète du code présenté dans cet article est disponible ici.

Conclusion

nightMARE est un module Python polyvalent, basé sur les meilleurs outils que la communauté open source a à offrir. Avec la sortie de la version 0.16 et ce court article, nous espérons avoir démontré ses capacités et son potentiel.

En interne, le projet est au cœur de plusieurs projets encore plus ambitieux, et nous continuerons à maintenir nightMARE au mieux de nos capacités.

Partager cet article