Introdução
Desde a criação do Elastic Security Labs, temos nos concentrado no desenvolvimento de ferramentas de análise de malware não apenas para auxiliar em nossa pesquisa e análise, mas também para disponibilizá-las ao público. Queremos retribuir à comunidade e retribuir o máximo que recebemos dela. Em um esforço para tornar essas ferramentas mais robustas e reduzir a duplicação de código, criamos a biblioteca Python nightMARE. Esta biblioteca reúne vários recursos úteis para engenharia reversa e análise de malware. Nós o usamos principalmente para criar nossos extratores de configuração para diferentes famílias de malware amplamente difundidas, mas o nightMARE é uma biblioteca que pode ser aplicada a vários casos de uso.
Com o lançamento da versão 0.16, queremos apresentar oficialmente a biblioteca e fornecer detalhes neste artigo sobre alguns recursos interessantes oferecidos por este módulo, bem como um breve tutorial explicando como usá-lo para implementar seu próprio extrator de configuração compatível com a versão mais recente do LUMMA (na data da publicação).
turnê de recursos do nightMARE
Desenvolvido por Rizin
Para reproduzir os recursos dos desmontadores populares, o nightMARE inicialmente usou um conjunto de módulos Python para executar as várias tarefas necessárias para análise estática. Por exemplo, usamos LIEF para análise de executáveis (PE, ELF), Capstone para desmontar binários e SMDA para obter análise de referência cruzada (xref).
Essas inúmeras dependências tornaram a manutenção da biblioteca mais complexa do que o necessário. É por isso que, para reduzir ao máximo o uso de módulos de terceiros, decidimos usar a estrutura de engenharia reversa mais abrangente disponível. Nossa escolha naturalmente gravitou em direção ao Rizin.
Rizin é um software de engenharia reversa de código aberto, bifurcado do projeto Radare2. Sua velocidade, design modular e conjunto quase infinito de recursos baseados em comandos semelhantes ao Vim o tornam uma excelente escolha de backend. Nós o integramos ao projeto usando o módulo rz-pipe , o que torna muito fácil criar e instrumentar uma instância do Rizin a partir do Python.
Estrutura do projeto
O projeto está estruturado em três eixos:
- O módulo "análise" contém submódulos úteis para análise estática.
- O módulo "core" contém submódulos comumente úteis: operações bit a bit, conversão de inteiros e expressões regulares recorrentes para extração de configuração.
- O módulo "malware" contém todas as implementações de algoritmos (criptografia, descompactação, extração de configuração, etc.), agrupadas por família de malware e, quando aplicável, por versão.
Módulos de análise
Para análise binária estática, este módulo oferece duas técnicas de trabalho complementares: desmontagem e análise de instruções com Rizin por meio do módulo de reversão e emulação de instruções por meio do módulo de emulação.
Por exemplo, quando constantes são movidas manualmente para a pilha, em vez de tentar analisar as instruções uma por uma para recuperar os imediatos, é possível emular todo o trecho de código e ler os dados na pilha quando o processamento estiver concluído.
Outro exemplo que veremos mais adiante neste artigo é que, no caso de funções criptográficas, se elas forem complexas, muitas vezes é mais simples chamá-las diretamente no binário usando emulação do que tentar implementá-las manualmente.
Módulo de reversão
Este módulo contém a classe Rizin, que é uma abstração das funcionalidades do Rizin que enviam comandos diretamente para o Rizin graças ao rz-pipe e oferece ao usuário uma quantidade incrível de poder de análise gratuitamente. Por ser uma abstração, as funções que a classe expõe podem ser facilmente usadas em um script sem conhecimento prévio do framework.
Embora esta aula exponha muitos recursos diferentes, não estamos tentando ser exaustivos. O objetivo é reduzir o código duplicado para funcionalidades recorrentes em todas as nossas ferramentas. Entretanto, se um usuário perceber que uma função está faltando, ele pode interagir diretamente com o objeto rz-pipe para enviar comandos ao Rizin e atingir seus objetivos.
Aqui está uma pequena lista das funções que mais 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 emulação
Na versão 0.16, retrabalhamos o módulo de emulação para aproveitar ao máximo os recursos do Rizin para executar suas diversas tarefas relacionadas a dados. Por baixo dos panos, ele usa o mecanismo Unicorn para realizar a emulação.
Por enquanto, este módulo oferece apenas uma emulação de PE "leve" com a classe WindowsEmulator, leve no sentido de que apenas o mínimo necessário é feito para carregar um PE. Sem realocações, sem DLLs, sem emulação de sistema operacional. O objetivo não é emular completamente um executável do Windows como Qiling ou Sogen, mas oferecer uma maneira simples de executar trechos de código ou sequências curtas de funções, conhecendo suas limitações.
A classe WindowsEmulator oferece diversas abstrações úteis.
# 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
A classe permite o registro de dois tipos de ganchos: ganchos unicórnio normais e 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 exemplo de uso, usamos o binário do Windows DismHost.exe .
O binário usa a importação Sleep no endereço 0x140006404:
Portanto, criaremos um script que registra um gancho IAT para a importação Sleep, inicia a execução da emulação no endereço 0x140006404 e termina no endereço 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()
É importante ressaltar que a função hook deve necessariamente retornar com a função do_return para que possamos chegar ao endereço localizado após a chamada.
Quando o emulador inicia, nosso gancho é executado corretamente.
Módulo de malware
O módulo de malware contém todas as implementações de algoritmos para cada família de malware que abordamos. Esses algoritmos podem cobrir extração de configuração, funções criptográficas ou descompactação de amostras, dependendo do tipo de malware. Todos esses algoritmos usam as funcionalidades do módulo de análise para fazer seu trabalho e fornecem bons exemplos de como usar a biblioteca.
Com o lançamento da v0.16, aqui estão as diferentes famílias de malware que cobrimos.
blister
deprecated
ghostpulse
latrodectus
lobshot
lumma
netwire
redlinestealer
remcos
smokeloader
stealc
strelastealer
xorddos
A implementação completa dos algoritmos LUMMA que abordamos no próximo tutorial do capítulo pode ser encontrada no submódulo LUMMA.
Observe que a natureza de rápida evolução do malware dificulta a manutenção desses módulos, mas agradecemos qualquer ajuda ao projeto, contribuição direta ou abertura de problemas.
Exemplo: configuração-extração LUMMA
LUMMA STEALER, também conhecido como LUMMAC2, é um malware que rouba informações e ainda é amplamente utilizado em campanhas de infecção, apesar de uma recente operação de remoção em maio de 2025. Este malware incorpora ofuscação de fluxo de controle e criptografia de dados, tornando-o mais desafiador de analisar tanto estaticamente quanto dinamicamente.
Nesta seção, usaremos o seguinte exemplo não criptografado como referência: 26803ff0e079e43c413e10d9a62d344504a134d20ad37af9fd3eaf5c54848122
Fazemos uma breve análise de como ele descriptografa seus nomes de domínio passo a passo e, em seguida, demonstramos ao longo do caminho como construímos o extrator de configuração usando o nightMARE.
Etapa 1: Inicializando o contexto ChaCha20
Nesta versão, o LUMMA realiza a inicialização do seu contexto criptográfico após carregar WinHTTP.dll, com a chave de descriptografia e o nonce; esse contexto será reutilizado para cada chamada à função de descriptografia ChaCha20 sem ser reinicializado. A nuance aqui é que um contador interno dentro do contexto é atualizado a cada uso, então mais tarde precisaremos levar em conta o valor desse contador antes da primeira descriptografia de domínio e então descriptografá-los na ordem correta.
Para reproduzir esta etapa em nosso script, precisamos coletar a chave e o nonce. O problema é que não sabemos sua localização com antecedência, mas sabemos onde eles são usados. Fazemos a correspondência de padrões desta parte do código e, em seguida, extraímos os endereços g_key_0 (key) e g_key_1 (nonce) das instruções.
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)
Etapa 2: localize a função de descriptografia
Nesta versão, a função de descriptografia do LUMMA é facilmente localizada nas amostras, pois é utilizada imediatamente após o carregamento das importações do WinHTTP.
Derivamos o padrão hexadecimal dos primeiros bytes da função para localizá-la em nosso 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")
Etapa 3: localize o endereço base do domínio criptografado
Ao usar xrefs da função de descriptografia, que não é chamada com indireção ofuscada como outras funções LUMMA, podemos encontrar facilmente onde ela é chamada para descriptografar os domínios.
Assim como no primeiro passo, usaremos as instruções para descobrir o endereço base dos domínios criptografados no binário:
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"]
Etapa 4: descriptografar domínios usando emulação
Uma análise rápida da função de descriptografia mostra que esta versão do LUMMA usa uma versão ligeiramente personalizada de ChaCha20. Reconhecemos as mesmas pequenas e diversas funções de descriptografia espalhadas pelos binários. Aqui, eles são usados para descriptografar partes da constante ChaCha20 "expand 32-byte k", que são então derivadas por XOR-ROL antes de serem armazenadas na estrutura de contexto.
Embora possamos implementar a função de descriptografia em nosso script, temos todos os endereços necessários para demonstrar como podemos chamar diretamente a função já presente no binário para descriptografar nossos domínios, usando o módulo de emulação do 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
Por fim, podemos executar nosso módulo com pytest e visualizar a 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 exemplo destaca como a biblioteca nightMARE pode ser usada para análise binária, especificamente, para extrair a configuração do LUMMA stealer.
Baixar nightMARE
A implementação completa do código apresentado neste artigo está disponível aqui.
Conclusão
nightMARE é um módulo Python versátil, baseado nas melhores ferramentas que a comunidade de código aberto tem a oferecer. Com o lançamento da versão 0.16 e este breve artigo, esperamos ter demonstrado suas capacidades e potencial.
Internamente, o projeto está no centro de vários projetos ainda mais ambiciosos, e continuaremos a manter o nightMARE com o melhor de nossas habilidades.
