Introducción
Desde la creación de Elastic Security Labs, nos centramos en el desarrollo de herramientas de análisis de malware no solo para ayudar en nuestra investigación y análisis, sino también para lanzarlas al público. Queremos retribuir a la comunidad y retribuir todo lo que obtengamos de ella. En un esfuerzo por hacer que estas herramientas sean más robustas y reducir la duplicación de código, creamos la biblioteca de Python nightMARE. Esta biblioteca reúne varias características útiles para la ingeniería inversa y el análisis de malware. Lo usamos principalmente para crear nuestros extractores de configuración para diferentes familias de malware generalizadas, pero nightMARE es una biblioteca que se puede aplicar a múltiples casos de uso.
Con el lanzamiento de la versión 0.16, queremos presentar oficialmente la biblioteca y proporcionar detalles en este artículo sobre algunas características interesantes que ofrece este módulo, así como un breve tutorial que explica cómo usarlo para implementar su propio extractor de configuración compatible con la última versión de LUMMA (a partir de la fecha de publicación).
recorrido por las características de nightMARE
Desarrollado por Rizin
Para reproducir las capacidades de los desensambladores populares, nightMARE empleó inicialmente un conjunto de módulos de Python para realizar las diversas tareas necesarias para el análisis estático. Por ejemplo, usamos LIEF para el análisis de ejecutables (PE, ELF), Capstone para desensamblar binarios y SMDA para obtener análisis de referencias cruzadas (refx).
Estas numerosas dependencias hicieron que el mantenimiento de la biblioteca fuera más complejo de lo necesario. Por eso, para reducir al máximo el uso de módulos de terceros, decidimos emplear el marco de ingeniería inversa más completo disponible. Nuestra elección gravitó naturalmente hacia Rizin.
Rizin es un software de ingeniería inversa de código abierto, bifurcado del proyecto Radare2. Su velocidad, diseño modular y un conjunto casi infinito de funciones basadas en sus comandos similares a Vim lo convierten en una excelente opción de backend. Lo integramos en el proyecto empleando el módulo rz-pipe , lo que hace que sea muy fácil crear e instrumentar una instancia de Rizin desde Python.
Estructura del proyecto
El proyecto se estructura en torno a tres ejes:
- El módulo "análisis" contiene submódulos útiles para el análisis estático.
- El módulo "core" contiene submódulos comúnmente útiles: operaciones bit a bit, conversión de enteros y expresiones regulares recurrentes para la extracción de configuraciones.
- El módulo "malware" contiene todas las implementaciones de algoritmos (criptografía, desempaquetado, extracción de configuraciones, etc.), agrupadas por familia de malware y, cuando corresponda, por versión.
Módulos de análisis
Para el análisis binario estático, este módulo ofrece dos técnicas de trabajo complementarias: análisis de desmontaje e instrucciones con Rizin a través del módulo de inversión y emulación de instrucciones a través del módulo de emulación.
Por ejemplo, cuando las constantes se mueven manualmente a la pila, en lugar de intentar analizar las instrucciones una por una para recuperar las inmediatas, es posible emular todo el fragmento de código y leer los datos en la pila una vez que se realiza el procesamiento.
Otro ejemplo que veremos más adelante en este artículo es que, en el caso de las funciones criptográficas, si es complejo, a menudo es más sencillo llamarlo directamente en el binario mediante emulación que intentar implementarlo manualmente.
Módulo de inversión
Este módulo contiene la clase Rizin, que es una abstracción de las funcionalidades de Rizin que envía comandos directamente a Rizin gracias a rz-pipe y ofrece al usuario una asombrosa cantidad de poder de análisis gratis. Debido a que es una abstracción, las funciones que expone la clase se pueden usar fácilmente en un script sin conocimiento previo del marco.
Aunque esta clase expone muchas características diferentes, no estamos tratando de ser exhaustivos. El objetivo es reducir el código duplicado para funcionalidades recurrentes en todas nuestras herramientas. Sin embargo, si un usuario encuentra que falta una función, puede interactuar directamente con el objeto rz-pipe para enviar comandos a Rizin y lograr sus objetivos.
Aquí hay una breve lista de las funciones que más usamos:
# 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]
Módulo de emulación
En la versión 0.16, rediseñamos el módulo de emulación para aprovechar al máximo las capacidades de Rizin para realizar sus diversas tareas relacionadas con los datos. Bajo el capó, está empleando el motor Unicorn para realizar la emulación.
Por ahora, este módulo solo ofrece una emulación de PE "ligera" con la clase WindowsEmulator, ligera en el sentido de que solo se hace el mínimo estricto para cargar un PE. Sin reubicaciones, sin archivos DLL, sin emulación del sistema operativo. El objetivo no es emular completamente un ejecutable de Windows como Qiling o Sogen, sino ofrecer una forma sencilla de ejecutar fragmentos de código o secuencias cortas de funciones conociendo sus limitaciones.
La clase WindowsEmulator ofrece varias abstracciones útiles.
# 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 clase permite el registro de dos tipos de ganchos: ganchos de unicornio normales y ganchos 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:
Como ejemplo de uso, usamos el DismHost.exe binario de Windows .
El binario emplea la importación Sleep en la dirección 0x140006404:
Por lo tanto, crearemos un script que registre un gancho IAT para la importación de Sleep, inicie la ejecución de la emulación en la dirección 0x140006404y termine en la dirección 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()
Es importante tener en cuenta que la función de gancho debe regresar necesariamente con la función do_return para que podamos llegar a la dirección ubicada luego de la llamada.
Cuando se inicia el emulador, nuestro gancho se ejecuta correctamente.
Módulo de malware
El módulo de malware contiene todas las implementaciones de algoritmos para cada familia de malware que cubrimos. Estos algoritmos pueden cubrir la extracción de configuraciones, las funciones criptográficas o el desempaquetado de muestras, según el tipo de malware. Todos estos algoritmos emplean las funcionalidades del módulo de análisis para hacer su trabajo y proporcionan buenos ejemplos de cómo usar la biblioteca.
Con el lanzamiento de la versión 0.16, estas son las diferentes familias de malware que cubrimos.
blister
deprecated
ghostpulse
latrodectus
lobshot
lumma
netwire
redlinestealer
remcos
smokeloader
stealc
strelastealer
xorddos
La implementación completa de los algoritmos LUMMA que cubrimos en el tutorial del siguiente capítulo se puede encontrar en el submódulo LUMMA.
Tenga en cuenta que la naturaleza de rápido desarrollo del malware dificulta el mantenimiento de estos módulos, pero agradecemos cualquier ayuda al proyecto, contribución directa o problemas de apertura.
Ejemplo: Extracción de configuración de LUMMA
LUMMA STEALER, también conocido como LUMMAC2, es un malware de robo de información que todavía se usa ampliamente en campañas de infección a pesar de una reciente operación de eliminación en mayo de 2025. Este malware incorpora ofuscación de flujo de control y cifrado de datos, lo que hace que sea más difícil de analizar tanto estática como dinámicamente.
En esta sección, usaremos el siguiente ejemplo sin cifrar como referencia: 26803ff0e079e43c413e10d9a62d344504a134d20ad37af9fd3eaf5c54848122
Hacemos un breve análisis de cómo descifra sus nombres de dominio paso a paso, y luego demostramos a lo largo del camino cómo construimos el extractor de configuración usando nightMARE.
Paso 1: Inicializar el contexto ChaCha20
En esta versión, LUMMA realiza la inicialización de su contexto criptográfico luego de cargar WinHTTP.dll, con la clave de descifrado y nonce; Este contexto se reutilizará para cada llamada a la función de descifrado ChaCha20 sin reinicializar. El matiz aquí es que un contador interno dentro del contexto se actualiza con cada uso, por lo que más adelante tendremos que tener en cuenta el valor de este contador antes del primer descifrado del dominio y luego descifrarlos en el orden correcto.
Para reproducir este paso en nuestro script, necesitamos recopilar la clave y el nonce. El problema es que no conocemos su ubicación de antemano, pero sabemos dónde se emplean. Hacemos coincidir el patrón de esta parte del código, luego extraemos las direcciones g_key_0 (key) y g_key_1 (nonce) de las instrucciones.
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)
Paso 2: Localiza la función de descifrado
En esta versión, la función de descifrado de LUMA se ubica fácilmente en las muestras, ya que se emplea inmediatamente luego de cargar las importaciones de WinHTTP.
Derivamos el patrón hexadecimal de los primeros bytes de la función para ubicarlo en nuestro 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")
Paso 3: Localiza la dirección base del dominio cifrado
Mediante el uso de referencias externas de la función de descifrado, que no se llama con indirección ofuscada como otras funciones de LUMMA, podemos encontrar fácilmente dónde se llama para descifrar los dominios.
Al igual que con el primer paso, usaremos las instrucciones para descubrir la dirección base de los dominios cifrados en el binario:
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"]
Paso 4: Descifrar dominios mediante emulación
Un análisis rápido de la función de descifrado muestra que esta versión de LUMMA emplea una versión ligeramente personalizada de ChaCha20. Reconocemos las mismas funciones de descifrado pequeñas y diversas dispersas en los binarios. Aquí, se emplean para descifrar partes de la constante ChaCha20 "expandir k de 32 bytes", que luego se derivan de XOR-ROL antes de almacenar en la estructura de contexto.
Si bien podríamos implementar la función de descifrado en nuestro script, tenemos todas las direcciones necesarias para demostrar cómo podemos llamar directamente a la función ya presente en el binario para descifrar nuestros dominios, empleando el módulo de emulación 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
Resultado
Finalmente, podemos ejecutar nuestro módulo con pytest y ver la lista 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
Este ejemplo destaca cómo se puede usar la biblioteca nightMARE para el análisis binario, específicamente, para extraer la configuración del ladrón LUMMA.
Descargar nightMARE
La implementación completa del código presentado en este artículo está disponible aquí.
Conclusión
nightMARE es un módulo de Python versátil, basado en las mejores herramientas que la comunidad de código abierto tiene para ofrecer. Con el lanzamiento de la versión 0.16 y este breve artículo, esperamos demostrar sus capacidades y potencial.
Internamente, el proyecto está en el corazón de varios proyectos aún más ambiciosos, y continuaremos manteniendo nightMARE lo mejor que podamos.
