
Хабр, всем привет! На связи Никита Полосухин, ведущий аналитик центра мониторинга и реагирования на кибератаки RED Security SOC. Сегодня мы разберем loader от группировки BO Team. Материал предназначен для начинающих ИБ-специалистов и представляет собой краткий мануал, который поясняет, как быстро определить функциональность вредоносного ПО, достать из семпла индикаторы и какие инструменты можно использовать для анализа.
Итак, на входе у нас два семпла:

Обратите внимание, в имени семпла много пробелов. Это сделано, чтобы пользователь не увидел расширение .exe, даже если они у него отображаются. Иконка pdf, по замыслу злоумышленников, тоже должна добавить этим файлам впечатления надежности. Для этого даже придумали отдельную технику T1036.006.
Путь самурая
Шаг 1
Выясним, что за файл мы анализируем на самом деле.
Отправляем его в утилиту Detect It Easy (DIE) и получаем вердикт:

Наша задача — как можно быстрее вытянуть всю базовую начальную информацию, чтобы исследовать семпл дальше. Поэтому сразу идем на небезызвестный virustotal. Отчет прямо кричит, что это вредонос, но вот что он делает — непонятно. Никаких адресов C2, включенных файлов, выполняемых команд нет (спойлер: все это мы увидим далее).
Шаг 2
Посмотрим на строки семлпа, чтобы проверить, какие текстовые значения есть внутри. Запустим команду
strings.exe -n 6 '.\Scan_СМК РЕСО-МЕД_248_28.10.2025 .exe' > str.txtЕсли строк много, поиск потребует вдумчивого и довольно творческого подхода. Однако есть список, с которого можно начать. Обычно хорошими кандидатами на объекты поиска будут:
URL-схемы
IP-адреса
github
usage строки
паттерны команд
доменные зоны
magic-байты типа MZ или ELF
В нашем случае ничего такого нет, однако мы видим следующие строки:

Это характерный паттерн pdf-файла: скорее всего, внутри семпла есть документ-приманка, под который он и маскируется. Делаем поиск в строках по расширению .pdf и находим кусок имени файла:

На данном этапе это максимум, что можно получить из анализа строк.
Шаг 3
Пришел черед сигнатурного анализа семпла. Можно анализировать его с помощью yara-правил, если они у вас есть, или выбрать готовые инструменты типа capa или floss. Floss не дал каких-то интересных результатов, но capa вернула информацию, за которую уже можно цепляться.

Особое внимание обращаем на блок Capability. Из него ясно, что происходит запись в файл (это понятно, мы видели приманку pdf), а затем runtime resolve функций Windows. Иными словами, некоторые функции подгружается в процессе запуска ВПО, а не импортированы сразу. Кроме того, у семпла есть функции по запуску CLR и парсингу заголовка PE-файла. Эти данные позволяют выдвинуть несколько гипотез:
Вероятно, ВПО резолвит функции в рантайме — значит, они могут быть потенциально зашифрованы в теле самого ВПО.
ВПО использует CLR — значит, есть участок кода на .NET.
Происходит парсинг PE-заголовков; с учетом предыдущего пункта, возможно, внутри ВПО есть нагрузка в виде исполняемого файла на .NET.
Так как мы не увидели внутри строк заголовка PE-файла, но заметили высокую энтропию секций, то, скорее всего, полезная нагрузка тоже зашифрована.
Итого:
Вероятнее всего, семпл загружает полезную нагрузку на .NET и запускает ее в памяти. При этом некоторые функции внутри семпла зашифрованы, как и сама нагрузка.
Анализ в песочнице ответа на вопрос, что это за нагрузка, не дает.
Анализ строк также не дает никакого ответа на наши вопросы.
Все что нам остается, это пойти в дизассемблер IDA Pro =).
Шаг 4
Полный листинг entrypoint-функции можно посмотреть здесь:
Тык
// Hidden C++ exception states: #wind=2
char sub_140003890()
{
unsigned int v0; // r14d
unsigned __int8 *v1; // r15
unsigned int v2; // r14d
HANDLE v3; // r12
LPVOID v4; // r13
__int64 v5; // rax
__int64 v6; // rsi
HMODULE LibraryA; // rax
FARPROC ProcAddress; // rbx
HANDLE v9; // rdi
char *v10; // rax
__int64 v11; // rax
unsigned __int64 v12; // rdx
_QWORD *v13; // rcx
unsigned __int64 v14; // rdi
__int128 *v15; // rsi
__int64 v16; // rbx
size_t v17; // rcx
void *v18; // rax
void *v19; // rcx
_QWORD *v20; // rax
HMODULE ModuleHandleA; // rsi
__int64 (*v22)(void); // rax
__int64 v23; // rdi
HMODULE v24; // rax
FARPROC v25; // rbx
HMODULE v26; // rax
FARPROC v27; // rax
FARPROC v28; // rdi
FARPROC v29; // rsi
__int64 v30; // rbx
__int64 v31; // rax
const WCHAR *CommandLineW; // rax
LPWSTR *v33; // rax
void *v34; // rcx
__int64 v36; // [rsp+38h] [rbp-59h] BYREF
__int128 v37; // [rsp+40h] [rbp-51h] BYREF
unsigned __int64 v38; // [rsp+50h] [rbp-41h]
__int64 v39; // [rsp+58h] [rbp-39h]
__int64 v40; // [rsp+68h] [rbp-29h]
_BYTE v41[32]; // [rsp+70h] [rbp-21h] BYREF
int pNumArgs; // [rsp+90h] [rbp-1h] BYREF
_QWORD v43[2]; // [rsp+98h] [rbp+7h] BYREF
unsigned __int64 v44; // [rsp+A8h] [rbp+17h]
unsigned __int64 v45; // [rsp+B0h] [rbp+1Fh]
v40 = -2LL;
v36 = (__int64)j__malloc_base(1uLL);
v0 = sub_140002F30(&v36);
v1 = (unsigned __int8 *)v36;
sub_140003530(v43, v36, v0);
v2 = v0 - v1[v0 - 1] - 1;
v3 = HeapCreate(0x40000u, 0LL, 0LL);
v4 = HeapAlloc(v3, 0, 0xDuLL);
v5 = sub_140003FC0(&v37, v43);
sub_140002310(v5);
v6 = sub_140003FC0(&v37, v43);
v36 = v6;
LibraryA = LoadLibraryA(byte_14003DB88);
ProcAddress = GetProcAddress(LibraryA, byte_14003DB30);
v9 = HeapCreate(0x40000u, 0LL, 0LL);
v10 = (char *)HeapAlloc(v9, 0, 0xA35EuLL);
lpMem = v10;
*(_OWORD *)(v10 + 17765) = xmmword_14003BC10;
*(_OWORD *)(v10 + 17781) = xmmword_14003BC20;
*(_OWORD *)(v10 + 17797) = xmmword_14003BC30;
*(_OWORD *)(v10 + 17813) = xmmword_14003BC40;
*(_OWORD *)(v10 + 17829) = xmmword_14003BC50;
*(_OWORD *)(v10 + 17845) = xmmword_14003BC60;
*(_OWORD *)(v10 + 17861) = xmmword_14003BC70;
*(_OWORD *)(v10 + 17877) = xmmword_14003BC80;
*(_QWORD *)(v10 + 17893) = qword_14003BC90;
*(_WORD *)(v10 + 17901) = word_14003BC98;
v11 = sub_140003FC0(v41, v6);
sub_1400020E0((char *)lpMem + 17765, (char *)lpMem + 17765, 138LL, v11);
LOBYTE(ProcAddress) = ((__int64 (__fastcall *)(FARPROC, HANDLE (__stdcall *)(DWORD, SIZE_T, SIZE_T), LPVOID (__stdcall *)(HANDLE, DWORD, SIZE_T)))((char *)lpMem + 17765))(
ProcAddress,
HeapCreate,
HeapAlloc);
HeapFree(v9, 0, lpMem);
HeapDestroy(v9);
if ( !(_BYTE)ProcAddress )
ExitProcess(0);
v12 = *(_QWORD *)(v6 + 24);
if ( v12 >= 0x10 )
{
v13 = *(_QWORD **)v6;
if ( v12 + 1 >= 0x1000 )
{
if ( (unsigned __int64)v13 - *(v13 - 1) - 8 > 0x1F )
invalid_parameter_noinfo_noreturn();
v13 = (_QWORD *)*(v13 - 1);
}
j_j_j__free_base(v13);
}
*(_QWORD *)(v6 + 16) = 0LL;
*(_QWORD *)(v6 + 24) = 15LL;
*(_BYTE *)v6 = 0;
v38 = 0LL;
v39 = 0LL;
v14 = v44;
v15 = (__int128 *)v43;
if ( v45 >= 0x10 )
v15 = (__int128 *)v43[0];
if ( v44 >= 0x10 )
{
v16 = v44 | 0xF;
if ( (v44 | 0xF) > 0x7FFFFFFFFFFFFFFFLL )
v16 = 0x7FFFFFFFFFFFFFFFLL;
if ( (unsigned __int64)(v16 + 1) < 0x1000 )
{
if ( v16 == -1 )
v20 = 0LL;
else
v20 = operator new(v16 + 1);
}
else
{
v17 = v16 + 40;
if ( v16 + 40 < (unsigned __int64)(v16 + 1) )
v17 = -1LL;
v18 = operator new(v17);
v19 = v18;
if ( !v18 )
invalid_parameter_noinfo_noreturn();
v20 = (_QWORD *)(((unsigned __int64)v18 + 39) & 0xFFFFFFFFFFFFFFE0uLL);
*(v20 - 1) = v19;
}
*(_QWORD *)&v37 = v20;
sub_140009AC0(v20, v15, v14 + 1);
v39 = v16;
}
else
{
v37 = *v15;
v39 = 15LL;
}
v38 = v14;
sub_1400030E0(&v37);
ModuleHandleA = GetModuleHandleA(ModuleName);
v22 = GetProcAddress(ModuleHandleA, byte_14003DB98);
v23 = v22();
v24 = LoadLibraryA(byte_14003DAF0);
if ( v24 )
{
v25 = GetProcAddress(v24, byte_14003DAB0);
v26 = GetModuleHandleA(ModuleName);
v27 = GetProcAddress(v26, byte_14003DAD8);
((void (__fastcall *)(__int64, FARPROC, void *, __int64, _QWORD))v27)(v23, v25, &unk_14003DB48, 6LL, 0LL);
}
v28 = GetProcAddress(ModuleHandleA, byte_14003DB00);
v29 = GetProcAddress(ModuleHandleA, byte_14003DB60);
((void (__fastcall *)(HANDLE, _QWORD, LPVOID, _QWORD))v28)(v3, 0LL, v4, v2);
v30 = ((__int64 (__fastcall *)(_QWORD, _QWORD, __int64, __int64))v29)(0LL, v2, 12288LL, 4LL);
v31 = sub_140003FC0(v41, v43);
sub_1400020E0(v1, v30, v2, v31);
pNumArgs = 0;
CommandLineW = GetCommandLineW();
v33 = CommandLineToArgvW(CommandLineW, &pNumArgs);
sub_140001070(v30, v2, (unsigned int)(pNumArgs - 1), v33 + 1);
Sleep(0x3E8u);
if ( v45 >= 0x10 )
{
v34 = (void *)v43[0];
if ( v45 + 1 >= 0x1000 )
{
v34 = *(void **)(v43[0] - 8LL);
if ( (unsigned __int64)(v43[0] - (_QWORD)v34 - 8LL) > 0x1F )
invalid_parameter_noinfo_noreturn();
}
j_j_j__free_base(v34);
}
return 1;
}Однако я хотел бы сконцентрироваться на основных вещах:
Мы видим функции LoadLibrary и GetProcAddress с константами, которые не инициализированы на этапе статического анализа, то есть их нужно смотреть в рантайме. Это хороший индикатор того, что семпл подгружает новые функции и модули:

Мы уже знаем, что в семпле «зашиты» запуск CLR и функция GetProcAddress. Теперь можно пойти в импорты и посмотреть, где они вызываются. Для GetProcAddress это более 100 вызовов:

А вот для CLR всего один:

Шаг 5
Далее общий алгоритм таков:
Мы исследуем все функции, которые импортируются в рантайме, размечаем псевдокод, определяем все особенности семпла, прослеживаем ход выполнения.
Доходим до места использования CLR — после вызова функции CLRCreateInstance нас будет ждать загрузка пейлоада в память, там его можно найти в расшифрованном виде.
Дампим память и исследуем.
Взглянем на константы, которые используются в рантайме для получения адресов функций. Для этого посмотрим, где они применяются помимо основной функции:

У каждой такой константы будет 2 xref. Перейдя в нее, видим следующее:

Очень похоже на инициализацию этих констант, и мы знаем, что в них должны содержаться вменяемые названия функций.
Зайдя в функцию, где, вероятно, происходит инициализация, видим следующее:

Как говорится, ничего непонятно, но очень интересно. Разбирать это руками совсем не хочется. Как раз здесь можно применить LLM. Важное уточнение: перед тем как отдавать облачной модели любые данные, провалидируйте, что они обезличены и не содержат никакой тайны.
Полный ответ можно посмотреть здесь, но если вкратце:
Это «XOR-шифр/дешифратор» по повторяющемуся ключу из std::string. Он побайтно XOR’ит буфер a1 длиной a3 и пишет результат в a2. В конце ключ (строка по адресу a4) очищается и освобождается.
Отлично, мы нашли место, где расшифровываются имена функций. Получается, что sub_140003fc0 — это функция выработки ключа xor-шифрования. Переменные, куда складывается ключ, инициализируются в рантайме, следовательно, и сам ключ шифрования также вырабатывает��я в рантайме.

Далее мы можем выявить список всех вызовов функции расшифрования. На скрине видно, что их всего две: main-функция (два вызова снизу) и функция расшифрования импортов:

Дальнейший план таков:
Ставим breakpoint на места после расшифрования всех импортов и после расшифрования данных в main-функции.
Запускаем ПО в отладке и определяем, что это за данные.
Начнем с функции расшифрования констант
На данный момент мы знаем, что расшифрованное значение кладется во второй аргумент функции, так что мы можем просто посмотреть, чему он равен после вызова расшифрования. Для этого наводим на него курсор:

Теперь остается два вызова в главной функции

После разметки переменных читать псевдокод гораздо удобнее. Мы видим, что здесь создается участок на куче, туда копируются данные, которые затем расшифровываются и выполняются. Так как IDA не распознала явной функции, то, скорее всего, мы имеем дело с участком шеллкода, который был изначально зашифрован для затруднения анализа. Но после разметки переменных мы видим, что в этот код передается в том числе указатель на функцию GetKeyboardLayoutList.
Те, кто часто читает отчеты об атаках, знают, что киберпреступники любят фильтровать жертв по раскладке клавиатуры.
Здесь сделаю небольшое отступление. Работа с расшифрованным шеллкодом чаще всего выходит за рамки задач DFIR-специалиста, потому что защита такого шеллкода может быть достаточно изощренной, а время на реагирование ограничено. Так что если вы умеете это делать, то эта статья изначально не для вас, а если нет — лучше отдать эту задачу реверс-инженеру. Однако все-таки для быстрого анализа можно попробовать использовать LLM.
Важно помнить, что иногда методы защиты ВПО содержат конфиденциальные данные жертвы. Мы о таком рассказывали в нашей предыдущей публикации. Имейте в виду, что бездумно отдавать такие данные LLM может быть опасно. Конкретно в этом случае такой проблемы нет, поэтому мы можем спокойно использовать мощь LLM для интерпретации шеллкода.
Итак, алгоритм тот же: после расшифрования данных переходим по их адресу (здесь это смещение lpMem+17765), копируем расшифрованный фрагмент (здесь это 0x8A байт) и отдаем LLM на интерпретацию. Вот ответ — на мой взгляд, вполне сносный:
Ответ от LLM
Я буду переводить байты в ассемблер и сразу в логику.
1. Пролог функции
48 89 5C 24 08 mov [rsp+8], rbx
48 89 6C 24 10 mov [rsp+10h], rbp
48 89 74 24 18 mov [rsp+18h], rsi
57 push rdi
48 83 EC 20 sub rsp, 20h
Сохраняет rbx, rbp, rsi, rdi и делает стековый фрейм. Обычный пролог x64.
2. Разбор аргументов
Тебе в этот шеллкод приходят три аргумента:
((char *)lpMem + 17765)(
GetKeyboardLayoutList_ptr, // rcx
HeapCreate, // rdx
HeapAlloc // r8
);Дальше код:
48 8B DA mov rbx, rdx ; rbx = HeapCreate
48 8B E9 mov rbp, rcx ; rbp = GetKeyboardLayoutList
33 D2 xor edx, edx ; edx = 0
33 C9 xor ecx, ecx ; ecx = 0
49 8B F8 mov rdi, r8 ; rdi = HeapAlloc
FF D5 call rbp ; call GetKeyboardLayoutList(0, 0)
То есть:
rbp = указатель на GetKeyboardLayoutList
rbx = указатель на HeapCreate
rdi = указатель на HeapAlloc
Первый вызов:
int count = GetKeyboardLayoutList(0, NULL);3. Проверка количества раскладок
48 63 F0 movsxd rsi, eax ; rsi = count
85 C0 test eax, eax
7E 44 jle short fail ; если <= 0 → уход в конецЕсли count <= 0 — дальше не работаем, прыжок в хвост функции.
4. Создание исполняемой кучи (HeapCreate)
45 33 C0 xor r8d, r8d ; dwMaxSize = 0
33 D2 xor edx, edx ; dwInitialSize = 0
B9 00 00 04 00 mov ecx, 40000h ; flOptions = 0x40000
FF D3 call rbx ; HeapCreate(...)Параметры HeapCreate:
HANDLE HeapCreate(
DWORD flOptions, // ecx
SIZE_T dwInitialSize, // rdx
SIZE_T dwMaximumSize // r8
);Тут:
flOptions = 0x40000 → HEAP_CREATE_ENABLE_EXECUTE
размеры 0,0 → стандартная динамическая куча
То есть создаётся исполняемая куча — подозрительно для шеллкода.
5. Выделение буфера под массив HKL
4C 8D 04 F5 00 00 00 00 lea r8, [rsi*8] ; r8 = rsi * 8
33 D2 xor edx, edx ; dwFlags = 0
48 8B C8 mov rcx, rax ; rcx = hHeap
FF D7 call rdi ; HeapAlloc(hHeap, 0, count*8)Здесь хитрый LEA:
4C 8D 04 F5 00 00 00 00 → lea r8, [rsi*8](через SIB: scale=8, index=rsi, base=none)
HeapAlloc:
LPVOID HeapAlloc(
HANDLE hHeap, // rcx
DWORD dwFlags, // edx = 0
SIZE_T dwBytes // r8 = count * 8
);Тип HKL — 64-битный (8 байт),
значит выделяется count * sizeof(HKL).
Дальше:
48 8B D0 mov rdx, rax ; rdx = буфер
8B CE mov ecx, esi ; ecx = count
48 8B F8 mov rdi, rax ; rdi = буфер (потом используем в цикле)
FF D5 call rbp ; GetKeyboardLayoutList(count, buf)Второй вызов:
GetKeyboardLayoutList(count, buf);6. Проверка, что буфер не пустой
85 F6 test esi, esi
7E 1B jle short failЕсли count <= 0 после этого — тоже в отказ.
7. Цикл по раскладкам и поиск языка 0x0419
33 C9 xor ecx, ecx ; i = 0
B8 19 04 00 00 mov eax, 419h ; eax = 0x0419
66 0F 1F 44 00 00 nop dword ptr [rax+rax+00h] ; выравнивание/пустышка
; ---- цикл ----
66 39 04 CF cmp [rdi+rcx*8], ax ; сравнить LOWORD(HKL[i]) с 0x0419
74 1F je short found
48 FF C1 inc rcx ; i++
48 3B CE cmp rcx, rsi ; i < count?
7C F2 jl short loop
; ---- не нашли ----
32 C0 xor al, al ; al = 0
; эпилог
48 8B 5C 24 30
48 8B 6C 24 38
48 8B 74 24 40
48 83 C4 20
5F
C3 ; return
; ---- found: ----
B0 01 found: mov al, 1 ; al = 1
EB E7 jmp short epilog ; прыжок назад, мимо xor al,al
Смысл:
rdi → указатель на массив HKL.
rcx → индекс i.
rsi → count.
Логика цикла:
for (int i = 0; i < count; i++) {
if (LOWORD(buf[i]) == 0x0419) { // 0x0419 = Russian (LANG_RUSSIAN)
return 1;
}
}
return 0;С точки зрения Windows:
LOWORD(HKL) = LANGID
0x0419 — русская раскладка (Russian).
8. Итоговая семантика функции
Функция на (lpMem + 0x4565) делает следующее:
// типичный прототип
bool __fastcall stub(
int (WINAPI pGetKeyboardLayoutList)(int, HKL),
HANDLE(WINAPI *pHeapCreate)(DWORD, SIZE_T, SIZE_T),
LPVOID(WINAPI *pHeapAlloc)(HANDLE, DWORD, SIZE_T)
)
{
int count;
HANDLE heap;
HKL *layouts;
int i;
// 1) Узнаём сколько раскладок загружено
count = pGetKeyboardLayoutList(0, NULL);
if (count <= 0)
return 0;
// 2) Создаём ИСПОЛНЯЕМУЮ кучу
heap = pHeapCreate(0x40000, 0, 0); // HEAP_CREATE_ENABLE_EXECUTE
if (!heap)
return 0;
// 3) Выделяем массив под HKL
layouts = (HKL*)pHeapAlloc(heap, 0, count * sizeof(HKL));
if (!layouts)
return 0;
// 4) Получаем список раскладок
pGetKeyboardLayoutList(count, layouts);
// 5) Проверяем, есть ли русская раскладка
for (i = 0; i < count; i++) {
if ((LOWORD(layouts[i]) == 0x0419)) {
return 1; // русская раскладка найдена
}
}
return 0; // русской раскладки нет
}9. Что это значит в контексте малвари
По сути, это анти-RU чек по раскладкам:
функция проверяет, есть ли в системе русская раскладка клавиатуры;
возвращает:
1 → если найдена русская раскладка (LANGID 0x0419),
0 → если нет.
Дальше по месту вызова обычно:
либо при return 1 малварь останавливается (не работает в РФ/СНГ),
либо наоборот — при 1 включает какую-то специфическую ветку.
Плюс:
Создание исполняемой кучи (HEAP_CREATE_ENABLE_EXECUTE) говорит, что этот же heap, вероятно, будет использоваться далее для загрузки/дешифровки следующего шеллкода.
Если хочешь, можем дальше разобрать, как вызывается результат этого чека (по call-сайтам: что делает код после получения 0/1).
Как видно на скриншоте выше, если результат выполнения шелкода == 0, работа ВПО завершается. И здесь очень важный момент: по этой причине онлайн-песочницы, которые в подавляющем большинстве рассчитаны на англоязычное комьюнити, не подходят для анализа – там нет русской раскладки, и именно поэтому результаты в VT пустые.
Однако можно обойтись и без реверса шеллкода:
Если анализируешь ВПО на виртуалке с русской раскладкой.
Если просто подменить регистр во время проверки if.
Вернемся к функции расшифрования. Вот ее второй вызов:

Здесь я уже разметил переменные, поэтому все понятно. Но даже если бы мы их не размечали, достаточно было бы просто поставить точку остановки после функции расшифрования и посмотреть на второй аргумент. Кстати, попутно открывается документ-приманка, который кладется в директорию %TEMP%.

Перейдя в область памяти payload_mem, видим:

Дальше нам остается сдампить эту память размером с третий аргумент. Удобнее всего делать это через ida_python. Зная начало нагрузки и ее размер, скрипт может написать та же LLM.
Далее этот бинарный файл исполняется через создание CLRCreateInstance и рантайма .NET.
Шаг 6
Начинаем тот же алгоритм — проверяем через DIE:

Натыкаемся на C#, а значит, можно посмотреть код ВПО с помощью dnSpy или ILSpy.
Шаг 7
Получаем конфиг в открытом виде:

Итак, перед нами вариант BrokenDoor от BO Team.
Справка: BO Team также известна как Black Owl, Lifting Zmiy и Hoody Hyena. Участники группировки обозн��чают свою антироссийскую позицию и занимаются преимущественно уничтожением инфраструктуры отечественных компаний, а в некоторых случаях — шифрованием данных и вымогательством.
Внутри BrokenDoor никак не обфусцирован, что делает его анализ эквивалентным чтению кода.
Если коротко, то семлп делает следующее:
Проверяет, если в системе mutex uhvljswltqbbcoof, если да — то завершает работу.
Строит рандомный джиттер общения и промежуток сна перед запуском на базе констант из конфига.
Отправляет hello-пакет с информацией о пользователе, системе, процессах, содержимом рабочего стола и антивирусах. В конфиге может содержаться несколько хостов, тогда клиент будет опрашивать их по порядку, пока не найдет активный.
Общение происходит на plain http, аутентификация клиента ведется по полям BOLO_SECRET и BOLO_USER_AGENT. Список команд:
spi — изменение интервала опроса.
ec — удаленное выполнение команд через powershell или cmd.
rp — запуск файла, который передается сервером и зашифрован с помощью алгоритма xor. Ключ шифрования клиент получает при обмене hello-пакетами.
Первый способ запуска sy или we — это обычный запуск процесса.
Второй способ запуска se или cp — это запуск скрытого процесса, с ProcessWindowStyle = Hidden.
persist — загрузка дополнительной нагрузки по полученному URL от сервера и закрепление ее через планировщик заданий. Директория, файл задания, имя задания и путь до вредоносного файла передаются с C2.
sd — самоудаление семпла.
rl — команда не реализована в текущей версии.
Резюме
Таким образом, данный семпл представляет собой Loader, который проверяет раскладку клавиатуры и, если присутствует русская, расшифровывает полезную нагрузку и запускает ее в памяти. Она также представляет собой backdoor/loader, который предназначен для быстрой разведки и внедрения других инструментов группировки.
Неинтересный путь
Если вдруг у вас есть песочница, агент которой работает с русской раскладкой, можно просто вытащить нагрузку оттуда:

После этого можно сразу переходить к анализу нагрузки, которая расшифровалась в памяти. Такой подход экономит кучу времени и предпочтителен в большинстве случаев, но это не всегда возможно (и не так интересно!).
Кстати говоря, второй семпл, который был на первом скриншоте, делает абсолютно то же самое, просто там другой pdf-документ-приманка.
Заключение
Эта статья написана не столько для того, чтобы поделиться индикаторами или показать какие-то новые тактики злоумышленников, сколько для демонстрации начинающим специалистам вариантов расследования, если им встретился вредоносный семпл, с анализом которого не могут помочь популярные сервисы.
Также мы немного затронули хайповую тему использования LLM в этом всем процессе. На мой взгляд, применять ИИ в различных аспектах кибербезопасности нужно и важно, однако нельзя слепо полагаться на него. Результат работы LLM необходимо каждый раз валидировать — как-нибудь напишу об этих кейсах отельную статью. Надеюсь, специалистам — начинающим и не очень — будет полезно.
Индикаторы
IP: 89.221.203.132:9218
URL: https://89.221.203.132:9218/t
User-Agent: Mozilla/5.0 (X11; U; Linux x86_64; fr; rv:1.8.1.1) Gecko/20060601 Firefox/2.0.0.1 (Ubuntu-edgy)
Mutex: uhvljswltqbbcoof
Sha256:
- Scan_СМК РЕСО-МЕД_248_28.10.2025
.exe 1973522064b61c9894adcb7697577d3d349121345581bbdda8d4c9179b334e19
- Результаты медицинского обследования
.exe 67337c5a985eb820bf5ba0b4efd1d17cd6d10895cd4769b5b74cbba5ea75eca1
Sha256:
Scan… - 1973522064b61c9894adcb7697577d3d349121345581bbdda8d4c9179b334e19