Распаковка исполняемых файлов на примере банковского трояна GootKit

Распаковка исполняемых файлов на примере банковского трояна GootKit

https://t.me/CyberLifes

Из инструментария мы будем использовать отладчик x64dbg (его 32-битную версию x32dbg), интерактивный дизассемблер IDA, шестнадцатеричный редактор HxD и детектор пакеров и протекторов DiE. Мы будем противостоять антиотладке при помощи мьютексов и разберемся с нестандартными параметрами функции CreateFileA.

Все описанные в статье действия выполнялись внутри виртуальной машины, которая была изолирована от сети. Повторение действий на основном компьютере может привести к заражению банкером GootKit, способным похитить твои данные.

Для начала давай посмотрим на GootKit через программу Detect it Easy.

Нераспакованный образец GootKit

Детектор не определяет никакой навесной защиты, зато энтропия файла зашкаливает.

Энтропия GootKit

Загружаем файл в дизассемблер для более интимного знакомства. Видим, что при входе у нас сразу идет вызов подпрограммы. Прыгаем в него и наблюдаем достаточно интересный код.

распаковка exe файла

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

Чтобы самостоятельно убедиться в этом, можешь скомпилировать и посмотреть в IDA простую программу, которая использует эту технику вызовов. Конечно, код будет более «чистый» и понятный, но общая суть очевидна.

int main()
{
 
  typedef NTSTATUS(WINAPI *pNtQueryInformationProcess)(HANDLE, UINT, PVOID, ULONG, PULONG);
  ULONG dProcessInformationLength = 0;
  PVOID DbgPort;
 
  pNtQueryInformationProcess pNtQueryInfoProcess = (pNtQueryInformationProcess)GetProcAddress(LoadLibrary(L"ntdll.dll"), "NtQueryInformationProcess");
  NTSTATUS Status = pNtQueryInfoProcess(GetCurrentProcess(),7,&DbgPort,dProcessInformationLength,NULL);
  if (Status == 0x00000000) return 0;
 
    return 0;
}


Давай переключимся в псевдокод, нажав кнопку F5, так будет еще очевиднее.

распаковка exe файла

Инициализация и вызов функций выглядит таким образом:

v3 = v1(byte_41F00C, byte_41F0F8);
v33 = (void (__stdcall *)(char *, char *))v2(v3);
v4 = v1(byte_41F00C, byte_41F0EC);
v32 = (int (__stdcall *)(char *))v2(v4);
v5 = v1(byte_41F0A4, byte_41F0B0);


И тем не менее код кажется странным, хоть и очевидно, как он работает и что делает. Посмотрим перекрестные ссылки на буферах, которые передаются в функции. Вот один из буферов.


Очевидно, применяется шифрование строк при помощи XOR по ключу 89798798798g79er$. Видим шифротекст byte_41F000[edi].


Псевдокод этого алгоритма такой:


 do
  {
    byte_41F000[v30] ^= a89798798798g79[v30 % v29];
    ++v30;
  }
  while ( v30 < 11 );
  v31 = strlen(a89798798798g79);


Зная ключ, попробуем расшифровать содержимое буфера средствами Python, который встроен в IDA. Набираем

x = idc.GetManyBytes(0x41F0EC, 0x0B)

Пишем байты по адресу 0x41F0EC в количестве 0x0B в переменную encrypt. Проверим, просто введя имя переменной и нажав Enter:

 Python>encrypt = idc.GetManyBytes(0x41F000, 0x9)
Python>encrypt
hJVIQl]T[


Все верно: это именно то, что мы видели в IDA. Теперь присвоим переменной y известный нам пароль и зададим переменную decrypt для выходных данных:

 Python>y = '89798798798g79er$'
Python>decrypt = ''


Теперь приступим к циклу дешифровки.

 Python>for i in range(0 , len(encrypt)):
Python>decrypt += chr(ord(encrypt[i]) ^ ord(y[i%len(y)]))
Python>decrypt
Psapi.dll


Мы смогли расшифровать строку, и у нас получилось Psapi.dll. Таким образом можно расшифровать все зашифрованные строки и соотнести их с переменными в псевдокоде. Листинг преобразился и стал намного более понятным. Теперь нам ясно, какие WinAPI получаются динамически. Скроллим псевдокод ниже и видим:

 if ( !(unsigned __int8)sub_401454() || !v36(v18, 0, 1000, 12288, 64, 0) || !(unsigned __int8)sub_401454() )
{
  CreateMutexA(0, 1, "fz7ef7z9e7f98ze7f97ze");
  result = (FILE *)GetLastError();
  if ( result != (FILE *)183 )
    return result;
  return (FILE *)sub_401E47();
}
CreateMutexA(0, 1, "321e89r7g98e7rg89er");
if ( GetLastError() == 183 )
  return (FILE *)sub_401E47();
GetModuleFileNameA(0, &v42, 260);
v21 = strlen(&v42);
v22 = strlen(byte_41F090);
v23 = (char *)GlobalAlloc(0x40u, v21 + 2 + v22);
v33(v23, &v42);
strcat(v23, byte_41F090);
v24 = v32;
while ( !v24(v23) && v9 < 300 )
{
  Sleep(1);
  ++v9;
}
ShellExecuteA(0, 0, &v42, "-l", 0, 0);
return (FILE *)((int (__stdcall *)(signed int))Sleep)(2000);


Здесь заметна работа с мьютексами и запуск еще одной копии собственного процесса через ShellExecuteA с параметром -l. Вообще, когда есть подобный код, неплохо запустить наш вирус в какой-нибудь анализирующей песочнице, чтобы видеть, какие процессы в каком порядке создаются. Давай это и сделаем.

Есть несколько онлайновых песочниц, которые подходят для этих целей, многие из них платные. Но есть и бесплатные, например hybrid-analysis.com. Закидываем семпл в песочницу и смотрим на результат.

Результат гибридного анализа

В этом дереве процессов нас интересуют три первых: сначала запускается основной семпл, потом он же с параметром командной строки -l, а третий процесс, вероятно, наш целевой распакованный код. Возвращаемся к исследованию защиты.

Обрати внимание на строки создания мьютексов и на этот код:

 ShellExecuteA(0, 0, &v42, "-l", 0, 0);
return (FILE *)((int (__stdcall *)(signed int))Sleep)(2000);


После создания процесса родительский процесс спит две секунды, а потом завершается. Запомним этот момент и идем дальше. Найдем код создания процесса, который создается копией процесса с параметром -l. Для этого в таблице импорта просматриваем вызовы и находим CreateProcessA. Идем по перекрестной ссылке и находим функцию sub_403A56(CHAR *a1, void *a2) и вот такой код в ней (я расшифровал имена вызовов WinAPI и переименовал одну переменную для удобочитаемости кода).


 memset(&StartupInfo, 0, 0x44u);
memset(&ProcessInformation, 0, 0x10u);
for ( i = CreateProcessA(0, v2, 0, 0, 0, 4u, 0, 0, &StartupInfo, &ProcessInformation);
    ;
    i = CreateProcessA(0, lpCommandLine, 0, 0, 0, 4u, 0, 0, &StartupInfo, &ProcessInformation) )
{
if ( !i )
  return 1;
if ( *(_WORD *)inject_code != 23117 )
{
  NtTerminateProcess(ProcessInformation.hProcess, 1);
  return 1;
}
v5 = (int)inject_code + *((_DWORD *)inject_code + 15);
NtGetContextThread(ProcessInformation.hThread, &v15);
NtReadVirtualMemory(ProcessInformation.hProcess, v16 + 8, &v11, 4, 0);
if ( v11 == *(_DWORD *)(v5 + 52) )
  NtViewUnmapOfSection(ProcessInformation.hProcess, v11);
v6 = VirtualAllocEx(ProcessInformation.hProcess, *(_DWORD *)(v5 + 52), *(_DWORD *)(v5 + 80), 12288, 64);
if ( v6 )
  break;
GetLastError();
if ( !v14 )
{
  NtTerminateProcess(ProcessInformation.hProcess, 1);
  return 1;
}
--v14;
NtTerminateProcess(ProcessInformation.hProcess, 1);
}
NtWriteVirtualMemory(ProcessInformation.hProcess, v6, inject_code, *(_DWORD *)(v5 + 84), 0);
v14 = 0;
if ( *(_WORD *)(v5 + 6) > 0u )
{
v7 = 0;
lpCommandLine = 0;
do
{
  NtWriteVirtualMemory(
    ProcessInformation.hProcess,
    v6 + *(_DWORD *)&v7[*((_DWORD *)inject_code + 15) + 260 + (_DWORD)inject_code],
    (char *)inject_code + *(_DWORD *)&v7[*((_DWORD *)inject_code + 15) + 268 + (_DWORD)inject_code],
    *(_DWORD *)&v7[*((_DWORD *)inject_code + 15) + 264 + (_DWORD)inject_code],
    0);
  v8 = *(unsigned __int16 *)(v5 + 6);
  v7 = lpCommandLine + 40;
  ++v14;
  lpCommandLine += 40;
}
while ( v14 < v8 );
}
v17 = v6 + *(_DWORD *)(v5 + 40);
NtWriteVirtualMemory(ProcessInformation.hProcess, v16 + 8, v5 + 52, 4, 0);
NtSetContextThread(ProcessInformation.hThread, &v15);
NtResumeThread(ProcessInformation.hThread, 0);
VirtualFree(inject_code, 0, 0x8000u);
return 0;


Пытливый читатель сразу поймет, что это стандартная инжекция кода в процесс, который здесь же и создается. Да, это тот процесс, который порождается экземпляром приложения, запущенного с параметром -l. Мы видим, как данные записываются в память процесса, устанавливается контекст потока, затем поток возобновляется.

Естественно, мы можем увидеть в вызове NtWriteVirtualMemory, какие именно данные записываются. Давай проследим, откуда вызывается функция sub_403A56(CHAR *a1, void *a2). Кроме того, я рекомендую переименовать эту функцию в inject, чтобы было удобнее и понятнее в дальнейшем.


 sub_402133();
v0 = f_GetModuleFileNameA();
sub_401A77(v0);
v1 = (int *)sub_401BCE((char *)dword_41FBCC);
v3 = v2;
inject_code = v1;
*(_BYTE *)(v2[3] + v1[3]) = 0;
v5 = sub_401CFB(*v1, v1[3], *v2, v2[3]);
v6 = inject_code[3];
v7 = inject_code[6];
*inject_code = v5;
v8 = sub_401CFB(v7, v6, v3[6], v3[3]);
v9 = inject_code[3];
v10 = inject_code[7];
inject_code[6] = v8;
v11 = sub_401CFB(v10, v9, v3[7], v3[3]);
v12 = inject_code[6];
inject_code[7] = v11;
sub_401EFF(v12, v3[6]);
sub_401EFF(inject_code[7], v3[7]);
proc_name = (CHAR *)f_GetModuleFileNameA();
inject(proc_name, (void *)*inject_code);
return 0;


В этой небольшой функции в памяти создается буфер и в него дешифруется код, а затем происходит инжект. Посмотрим перекрестную ссылку на эту функцию, и… мы возвращаемся в код с мьютексами. А функция, из которой мы вернулись, называется sub_401E47(). Получается, основная распаковка выполняется именно в этом месте приложения. Давай попробуем разобраться, зачем тут мьютексы. Я сократил код, оставив самую суть, и переименовал известную нам функцию.

  
CreateMutexA(0, 1, "fz7ef7z9e7f98ze7f97ze");
result = (FILE *)GetLastError();
if ( result != (FILE *)183 )
  return result;
return (FILE *)wrapper_create_and_inject();
}
CreateMutexA(0, 1, "321e89r7g98e7rg89er");
if ( GetLastError() == 183 )
return (FILE *)wrapper_create_and_inject();
...
ShellExecuteA(0, 0, &v42, "-l", 0, 0);
return (FILE *)((int (__stdcall *)(signed int))Sleep)(2000);


Антиотладочный трюк состоит в том, что родительский процесс создает мьютекс и проверяет, не создан ли он ранее. Далее порождается дочерний процесс, и он тоже проверяет мьютекс. Дело в том, что родительский процесс умирает спустя две секунды, и если бы мы отлаживали этот код, то в тот момент, когда бы мы приступили к отладке потомка, родительский процесс был бы уже мертв и созданного им мьютекса не было бы. Получается простой, но интересный способ проверить целостность своих процессов и отследить факт отладки.

Итак, теперь мы знаем, как устроена антиотладка. Давай попробуем заполучить распакованный образец банкера. Для этого мы загружаем семпл в отладчик и останавливаемся на точке входа. Теперь нам нужно установить точку останова в тот момент, когда создается новый процесс (помнишь, что процессов порождается несколько?). Установим точку останова на функцию CreateProcessInternalW.

Мы установили точку останова на функцию CreateProcessInternalW потому, что все функции, порождающие процессы (CreateProcess, CreateProcessAsUser, CreateProcessWithTokenW и CreateProcessWithLogonW), в итоге вызывают именно эту недокументированную функцию.

Отладчик остановился, и мы можем видеть, что создано два процесса и мы стоим на точке распаковки малвари в память. Нужно понимать, что сейчас мьютексы созданы и мы не даем приложению завершиться.

Стек вызовов на точке останова

Нам необходимо свернуть отладчик и запустить еще один его экземпляр, загрузить туда наш банкер и поставить точку останова на функцию CreateFileA. Мы остановились на моменте создания файла, теперь давай посмотрим параметры функции CreateFileA. Я выделил интересующее нас поле красным.

Стек вызовов на точке останова

Вот прототип функции CreateFileA.

 HANDLE CreateFileA(
LPCSTR                lpFileName,
DWORD                dwDesiredAccess,
DWORD                dwShareMode,
LPSECURITY_ATTRIBUTES lpSecurityAttributes,
DWORD                dwCreationDisposition,
DWORD                dwFlagsAndAttributes,
HANDLE                hTemplateFile
);


Третий параметр, dwShareMode, установлен в ноль, что задает «режим совместного доступа». Нулевое значение запрещает всяческий доступ извне или повторный доступ из самого приложения, пока дескриптор файла не будет закрыт. Изменим на 7 — это разрешит все доступы. После этого даем доработать функции и останавливаемся сразу после возврата из нее.

Теперь запускаем приложение и не забываем о точке останова на CreateProcessInternalW. После останова в стеке — параметры создания процесса с флагом CREATE_SUSPENDED. Значит, это именно наша инъекция из буфера в процесс. Перейдем к карте памяти и поищем M.Z. — ведь в данный момент распакованный файл находится в памяти. Точнее, даже два файла — ты ведь еще не забыл дерево процессов? Но интересует нас только один.

Первое вхождение — по адресу 0017BBB6. Ищем в карте памяти по базовому адресу 700xxx нужный образ. Осталось только снять дамп, в котором будет два образа PE-файла, которые мы разделяем при помощи hex-редактора HxD. Один из образов нам уже знаком, а вот второй и есть наш искомый распакованный банкер.

как распаковать файл exe


Распакованный GootKit

В IDA видно, что код отличается от исходного семпла, а также просматривается таблица импорта. Бинго! Вот мы и научились распаковывать исполняемый файл.


Report Page