서문
Elastic Security Labs를 설립한 이래, 우리는 연구와 분석을 지원할 뿐만 아니라 대중에게 공개하기 위해 맬웨어 분석 도구를 개발하는 데 주력해 왔습니다. 저희는 커뮤니티에서 얻은 만큼 커뮤니티에 환원하고 돌려드리고 싶습니다. 이러한 도구를 더욱 강력하게 만들고 코드 중복을 줄이기 위해 Python 라이브러리 nightMARE를 만들었습니다. 이 라이브러리는 리버스 엔지니어링 및 멀웨어 분석을 위한 다양하고 유용한 기능을 제공합니다. 저희는 주로 널리 퍼져 있는 다양한 멀웨어 제품군에 대한 구성 추출기를 만드는 데 사용하지만, nightMARE는 여러 사용 사례에 적용할 수 있는 라이브러리입니다.
버전 0.16이 출시됨에 따라, 이 글에서 라이브러리를 공식적으로 소개하고 이 모듈이 제공하는 몇 가지 흥미로운 기능에 대한 자세한 내용과 함께 최신 버전의 LUMMA와 호환되는 자체 구성 추출기를 구현하는 방법을 설명하는 짧은 튜토리얼을 제공하고자 합니다(게시일 기준).
나이트메어 기능 투어
Rizin 제공
널리 사용되는 디스어셈블러의 기능을 재현하기 위해 nightMARE는 처음에 정적 분석에 필요한 다양한 작업을 수행하기 위해 Python 모듈 세트를 사용했습니다. 예를 들어, 실행 파일 구문 분석(PE, ELF)에는 LIF를, 바이너리 분해에는 Capstone을, 상호 참조(xref) 분석에는 SMDA를 사용했습니다.
이러한 수많은 종속성으로 인해 라이브러리 유지 관리가 필요 이상으로 복잡해졌습니다. 그렇기 때문에 타사 모듈의 사용을 최대한 줄이기 위해 가장 포괄적인 리버스 엔지니어링 프레임워크를 사용하기로 결정했습니다. 저희의 선택은 자연스럽게 리진으로 향했습니다.
Rizin은 Radare2 프로젝트에서 포크된 오픈 소스 리버스 엔지니어링 소프트웨어입니다. 속도, 모듈식 디자인, Vim과 유사한 명령어를 기반으로 하는 거의 무한한 기능 세트로 인해 탁월한 백엔드 선택이 될 수 있습니다. 파이썬에서 Rizin 인스턴스를 매우 쉽게 생성하고 계측할 수 있는 rz-pipe 모듈을 사용해 프로젝트에 통합했습니다.
프로젝트 구조
프로젝트는 세 가지 축을 따라 구성됩니다:
- "분석" 모듈에는 정적 분석에 유용한 하위 모듈이 포함되어 있습니다.
- "핵심" 모듈에는 비트 연산, 정수 캐스팅, 구성 추출을 위한 반복 정규식 등 일반적으로 유용한 하위 모듈이 포함되어 있습니다.
- "악성코드" 모듈에는 모든 알고리즘 구현(암호화, 언패킹, 구성 추출 등)이 악성코드 제품군별로 그리고 해당되는 경우 버전별로 그룹화되어 있습니다.
분석 모듈
정적 바이너리 분석의 경우, 이 모듈은 리버스 모듈을 통한 Rizin의 분해 및 명령어 분석과 에뮬레이션 모듈을 통한 명령어 에뮬레이션이라는 두 가지 상호 보완적인 작업 기법을 제공합니다.
예를 들어 상수를 스택으로 수동으로 이동하는 경우, 명령어를 하나씩 분석하여 즉각적인 것을 검색하는 대신 전체 코드를 에뮬레이션하여 처리가 완료되면 스택에서 데이터를 읽을 수 있습니다.
이 글의 뒷부분에서 살펴볼 또 다른 예는 암호화 함수의 경우 복잡할 경우 수동으로 구현하는 것보다 에뮬레이션을 사용하여 바이너리로 직접 호출하는 것이 더 간단한 경우가 많다는 것입니다.
반전 모듈
이 모듈에는 rz-pipe 덕분에 Rizin에 직접 명령을 전송하는 Rizin의 기능을 추상화한 Rizin 클래스가 포함되어 있으며, 사용자에게 엄청난 양의 분석 기능을 무료로 제공합니다. 추상화되어 있기 때문에 클래스가 노출하는 함수는 프레임워크에 대한 사전 지식이 없어도 스크립트에서 쉽게 사용할 수 있습니다.
이 수업에서는 다양한 기능을 소개하지만, 모든 기능을 다 소개하지는 않으려 합니다. 목표는 모든 도구에서 반복되는 기능에 대한 중복 코드를 줄이는 것입니다. 그러나 사용자가 기능이 누락된 것을 발견하면 rz-pipe 객체와 직접 상호 작용하여 Rizin에 명령을 전송하고 목표를 달성할 수 있습니다.
다음은 저희가 가장 많이 사용하는 기능의 간단한 목록입니다:
# 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]
에뮬레이션 모듈
0.16 버전에서는 에뮬레이션 모듈을 재작업하여 다양한 데이터 관련 작업을 수행할 수 있는 Rizin의 기능을 최대한 활용할 수 있도록 했습니다. 내부적으로는 유니콘 엔진을 사용하여 에뮬레이션을 수행합니다.
현재 이 모듈은 WindowsEmulator 클래스를 사용하는 "가벼운" PE 에뮬레이션만 제공하며, PE를 로드하는 데 필요한 최소한의 작업만 수행한다는 점에서 가볍습니다. 재배치, DLL, OS 에뮬레이션이 필요 없습니다. Qiling이나 Sogen과 같은 Windows 실행 파일을 완전히 에뮬레이션하는 것이 아니라, 그 한계를 알면서도 코드 조각이나 짧은 함수 시퀀스를 실행할 수 있는 간단한 방법을 제공하는 것이 목표입니다.
WindowsEmulator 클래스는 몇 가지 유용한 추상화를 제공합니다.
# 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
이 클래스에서는 일반 유니콘 후크와 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:
사용 예로, Windows 바이너리( DismHost.exe .
바이너리는 주소 0x140006404 에서 Sleep 가져오기를 사용합니다:
따라서 Sleep 가져오기에 대한 IAT 훅을 등록하고 주소 0x140006404 에서 에뮬레이션 실행을 시작하고 주소 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()
후크 함수는 반드시 do_return 함수와 함께 반환되어야 호출 뒤에 위치한 주소에 도달할 수 있다는 점에 유의하세요.
에뮬레이터가 시작되면 훅이 올바르게 실행됩니다.
멀웨어 모듈
멀웨어 모듈에는 저희가 다루는 각 멀웨어 제품군에 대한 모든 알고리즘 구현이 포함되어 있습니다. 이러한 알고리즘은 멀웨어의 유형에 따라 구성 추출, 암호화 기능 또는 샘플 언패킹을 다룰 수 있습니다. 이러한 모든 알고리즘은 분석 모듈의 기능을 사용하여 작업을 수행하고 라이브러리 사용 방법에 대한 좋은 예를 제공합니다.
0.16 버전 출시와 함께 저희가 다루는 다양한 멀웨어 제품군은 다음과 같습니다.
blister
deprecated
ghostpulse
latrodectus
lobshot
lumma
netwire
redlinestealer
remcos
smokeloader
stealc
strelastealer
xorddos
다음 장 튜토리얼에서 다루는 LUMMA 알고리즘의 전체 구현은 LUMMA 하위 모듈에서 확인할 수 있습니다.
빠르게 진화하는 멀웨어의 특성상 이러한 모듈을 유지 관리하는 것이 어렵지만 프로젝트에 대한 도움, 직접적인 기여 또는 공개 이슈를 환영합니다.
예시: LUMMA 구성-추출
LUMMAC2라고도 알려진 루마 스틸러는 정보 탈취 악성코드로, 2025년 5월에 최근 제거 작전을 수행했음에도 불구하고 여전히 감염 캠페인에 널리 사용되고 있습니다. 이 멀웨어는 제어 흐름 난독화 및 데이터 암호화를 통합하여 정적 및 동적 분석을 더욱 어렵게 만듭니다.
이 섹션에서는 다음 비암호화 샘플을 참조로 사용합니다: 26803ff0e079e43c413e10d9a62d344504a134d20ad37af9fd3eaf5c54848122
도메인 이름을 단계별로 해독하는 방법을 간략하게 분석한 다음 밤마법을 사용하여 구성 추출기를 구축하는 방법을 시연합니다.
1단계: ChaCha20 컨텍스트 초기화하기
이 버전에서 LUMMA는 복호화 키와 논스를 사용하여 WinHTTP.dll 를 로드한 후 암호화 컨텍스트의 초기화를 수행하며, 이 컨텍스트는 다시 초기화하지 않고 ChaCha20 복호화 함수를 호출할 때마다 재사용됩니다. 여기서 뉘앙스는 컨텍스트 내의 내부 카운터가 사용할 때마다 업데이트되므로 나중에 첫 번째 도메인 복호화 전에 이 카운터의 값을 고려한 다음 올바른 순서로 복호화해야 한다는 것입니다.
스크립트에서 이 단계를 재현하려면 키와 논스를 수집해야 합니다. 문제는 그 위치는 미리 알 수 없지만 사용처는 알 수 있다는 것입니다. 코드의 이 부분을 패턴 일치시킨 다음 지침에서 g_key_0 (key) 및 g_key_1 (nonce) 주소를 추출합니다.
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)
2단계: 암호 해독 기능 찾기
이 버전에서 LUMMA의 복호화 기능은 WinHTTP 가져오기를 로드한 직후에 사용되므로 샘플 전체에서 쉽게 찾을 수 있습니다.
함수의 첫 바이트에서 16진수 패턴을 도출하여 스크립트에서 이를 찾습니다:
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")
3단계: 암호화된 도메인의 기본 주소 찾기
다른 LUMMA 함수처럼 난독화된 인디렉션으로 호출되지 않는 복호화 함수의 xref를 사용하면 도메인을 복호화하기 위해 호출되는 위치를 쉽게 찾을 수 있습니다.
첫 번째 단계와 마찬가지로 지침을 사용하여 바이너리에서 암호화된 도메인의 기본 주소를 찾습니다:
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"]
4단계: 에뮬레이션을 사용하여 도메인 암호 해독하기
복호화 기능을 빠르게 분석해 보면 이 버전의 LUMMA는 ChaCha20 의 약간 사용자 정의된 버전을 사용한다는 것을 알 수 있습니다. 바이너리 전체에 흩어져 있는 작고 다양한 복호화 기능을 인식하고 있습니다. 여기서는 ChaCha20 " 확장 32바이트 k" 상수의 일부를 해독하는 데 사용되며, 컨텍스트 구조에 저장되기 전에 XOR-ROL이 파생됩니다.
스크립트에서 복호화 함수를 구현할 수도 있지만, 밤마미의 에뮬레이션 모듈을 사용하여 바이너리에 이미 존재하는 함수를 직접 호출하여 도메인을 복호화하는 방법을 시연하는 데 필요한 모든 주소가 있습니다.
# 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
결과
마지막으로 pytest 로 모듈을 실행하고 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
이 예제는 바이너리 분석, 특히 LUMMA 스틸러에서 구성을 추출하는 데 nightMARE 라이브러리를 사용하는 방법을 강조합니다.
밤의 꿈 다운로드
이 문서에 제시된 코드의 전체 구현은 여기에서 확인할 수 있습니다.
결론
nightMARE는 오픈 소스 커뮤니티에서 제공하는 최고의 도구를 기반으로 하는 다목적 Python 모듈입니다. 0.16 버전 출시와 이 짧은 글을 통해 그 기능과 잠재력이 입증되었기를 바랍니다.
내부적으로 이 프로젝트는 더욱 야심찬 여러 프로젝트의 핵심이며, 앞으로도 최선을 다해 나이트메어를 유지해 나갈 것입니다.
