Распаковка исполняемых файлов — одна из задач, возникающих при реверсинге приложений. И если наш сегодняшний экспериментальный объект— это вредонос, то зачастую приходится сталкиваться с кастомными упаковщиками. Сегодня погоровим о распаковке исполняемых файлов и на примере банковского трояна GootKit будем бороться с защитными механизмами банкера, который постоянно развивается и обновляется, к тому же использует разные методы защиты от отладки.
Из инструментария мы будем использовать отладчик x64dbg (его 32-битную версию x32dbg), интерактивный дизассемблер IDA, шестнадцатеричный редактор HxD и детектор пакеров и протекторов DiE. Мы будем противостоять антиотладке при помощи мьютексов и разберемся с нестандартными параметрами функции CreateFileA.
Все описанные в статье действия выполнялись внутри виртуальной машины, которая была изолирована от сети. Повторение действий на основном компьютере может привести к заражению банкером GootKit, способным похитить ваши данные.
Для начала давайте посмотрим на GootKit через программу Detect it Easy.
Детектор не определяет никакой навесной защиты, зато энтропия файла зашкаливает.
Загружаем файл в дизассемблер для более интимного знакомства. Видим, что при входе у нас сразу идет вызов подпрограммы. Прыгаем в него и наблюдаем достаточно интересный код.
На стек помещаются значения, инициализируются переменные, далее идут вызовы. Разумеется, это похоже на динамический вызов функций.
Чтобы самостоятельно убедиться в этом, можете скомпилировать и посмотреть в IDA простую программу, которая использует эту технику вызовов. Конечно, код будет более «чистый» и понятный, но общая суть очевидна.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
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, так будет еще очевиднее.
Инициализация и вызов функций выглядит таким образом:
1 2 3 4 5 |
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].
Псевдокод этого алгоритма такой:
1 2 3 4 5 6 7 |
do { byte_41F000[v30] ^= a89798798798g79[v30 % v29]; ++v30; } while ( v30 < 11 ); v31 = strlen(a89798798798g79); |
Зная ключ, попробуем расшифровать содержимое буфера средствами Python, который встроен в IDA. Набираем
1 |
x = idc.GetManyBytes(0x41F0EC, 0x0B) |
Пишем байты по адресу 0x41F0EC в количестве 0x0B в переменную encrypt. Проверим, просто введя имя переменной и нажав Enter:
1 2 3 |
Python>encrypt = idc.GetManyBytes(0x41F000, 0x9) Python>encrypt hJVIQl]T[ |
Все верно: это именно то, что мы видели в IDA. Теперь присвоим переменной y известный нам пароль и зададим переменную decrypt для выходных данных:
1 2 |
Python>y = '89798798798g79er$' Python>decrypt = '' |
Теперь приступим к циклу дешифровки.
1 2 3 4 |
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 получаются динамически. Скроллим псевдокод ниже и видим:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
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, а третий процесс, вероятно, наш целевой распакованный код. Возвращаемся к исследованию защиты.
Обратите внимание на строки создания мьютексов и на этот код:
1 2 |
ShellExecuteA(0, 0, &v42, "-l", 0, 0); return (FILE *)((int (__stdcall *)(signed int))Sleep)(2000); |
После создания процесса родительский процесс спит две секунды, а потом завершается. Запомним этот момент и идем дальше. Найдем код создания процесса, который создается копией процесса с параметром -l. Для этого в таблице импорта просматриваем вызовы и находим CreateProcessA. Идем по перекрестной ссылке и находим функцию sub_403A56(CHAR *a1, void *a2) и вот такой код в ней (я расшифровал имена вызовов WinAPI и переименовал одну переменную для удобочитаемости кода).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
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, чтобы было удобнее и понятнее в дальнейшем.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
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(). Получается, основная распаковка выполняется именно в этом месте приложения. Давайте попробуем разобраться, зачем тут мьютексы. Я сократил код, оставив самую суть, и переименовал известную нам функцию.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
... 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.
1 2 3 4 5 6 7 8 9 |
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. Один из образов нам уже знаком, а вот второй и есть наш искомый распакованный банкер.
В IDA видно, что код отличается от исходного семпла, а также просматривается таблица импорта. Бинго! Вот мы и научились распаковывать исполняемый файл.