Винятки для хардкорщиків. Особливості обробки эксепшенов в динамічно розміщується коді

image

Сучасні версії ОС накладають на виконуваний код обмеження, пов'язані з вимогами безпеки. В таких умовах використання механізму виключень в инжектированном коді або, скажімо, у вручну спроецированном образі може стати нетривіальним завданням, якщо не бути в курсі деяких нюансів. У цій статті мова піде про внутрішній устрій юзермодного диспетчера винятків ОС Windows x86/x64/IA64, а також будуть розглянуті варіанти реалізації обходу системних обмежень.

__try


Припустимо, що в твоїй практиці виникла задача, що вимагає реалізації повноцінної обробки винятків у вбудованому в чужій процес коді, або ти робиш черговий PE-пакувальник/криптор, який повинен забезпечити працездатність виключень в распаковываемом образі. Так чи інакше, все зводиться до того, що код, який використовує винятку, виконується поза спроецированного системним завантажувачем образу, що і буде основною причиною труднощів. В якості демонстрації проблеми розглянемо простий приклад коду, що копіює свій власний образ у нову область у межах поточного АП-процесу:

void exceptions_test()
{
__try {
int i = 0;
*i = 0;
} __except (EXCEPTION_EXECUTE_HANDLER) {
/* Тут ми можемо і не потрапити */
MessageBoxA(0, "Виключення перехоплено", "", 0);
}
}
void main()
{
/* Перевіряємо працездатність винятків */
exceptions_test();

/* Копіюємо поточний образ цілком в нову область */
PVOID ImageBase = GetModuleHandle(NULL);
DWORD SizeOfImage = RtlImageNtHeader(ImageBase)->OptionalHeader.SizeOfImage;
PVOID NewImage = VirtualAlloc NULL, SizeOfImage, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
memcpy(NewImage, ImageBase, SizeOfImage);
/* Правимо релоки */
ULONG_PTR Delta = (ULONG_PTR) NewImage - ImageBase; 
RelocateImage(NewImage, Delta);

/* Викликаємо exceptions_test в копії образу */
void (*new_exceptions_test)() = (void (*)()) ((ULONG_PTR) &exceptions_test + Delta);
new_exceptions_test();
}

У процедурі exceptions_test спроба доступу до нульового покажчика обгорнута в MSVC-розширення try-except, замість фільтра винятків — заглушка, яка повертає EXCEPTION_EXECUTE_HANDLER, що має одразу приводити до виконання коду в блоці except. При першому виклику exceptions_test відпрацьовує, як і очікувалося: виключення перехоплюється, виводиться меседж-бокс. Але після копіювання коду на нове місце і виклику копії exceptions_test виняток перестає оброблятися, і додаток просто «падає» з характерним для конкретної версії ОС повідомленням про необробленому виключення. Конкретна причина такої поведінки буде залежати від платформи, на якій проводився тест, і, щоб її визначити, потрібно буде розібратися з механізмом диспетчеризації винятків.


Необроблене виняток

Диспетчеризація винятків

Незалежно від платформи і типу виключення диспетчеризація user-mode завжди починається з точки KiUserExceptionDispatcher в модулі ntdll, управління якої передається з ядерної диспетчера KiDispatchException (якщо виняток було викликано user-mode і не було опрацьовано відладчиком). В наведеному раніше прикладі управління диспетчеру передається для обох випадків виникнення виключення (у процесі виконання exceptions_test та її копії за новою адресою), переконатися в цьому можна, встановивши breakpoint на ntdll!KiUserExceptionDispatcher. Код KiUserExceptionDispatcher дуже простий і має приблизно наступний вигляд:

VOID NTAPI KiUserExceptionDispatcher (EXCEPTION_RECORD *ExceptionRecord, CONTEXT *Context)
{
NTSTATUS Status;
if (RtlDispatchException(ExceptionRecord, Context)) {
/* Виключення оброблено, можна продовжувати виконання */
Status = NtContinue(Context, FALSE);
}
else {
/* Повторно викидаємо виняток, але без спроби знайти хендлер в цей раз */
Status = NtRaiseException(ExceptionRecord, Context, FALSE);
}
...
RtlRaiseException(&NestedException);
}

де EXCEPTION_RECORD — структура з інформацією про виключення, а CONTEXT — структура стану контексту потоку на момент виникнення виключення. Обидві структури документовані в MSDN, втім, ти вже напевно знайомий з ними. Покажчики на ці дані передаються в ntdll!RtlDispatchException, де і проводиться реальна диспетчеризація, при цьому у 32-бітних і 64-бітних системах механіка обробки виключень розрізняється.

x86

Основний механізм для x86-платформи — Structured Exception Handling (SEH), що базується на односвязном списку обробників винятків, розташованому в стеку і завжди доступному з NT_TIB.ExceptionList. Основи цього механізму були неодноразово описані в різних працях (див. врізку «Корисні матеріали»), тому не будемо повторюватися, а лише загостримо увагу на тих моментах, які перетинаються з нашою завданням.


Дамп ланцюжка SEH

Справа в тому, що в SEH всі елементи списку хендлеров обов'язково повинні перебувати в стеку, а це значить, що вони потенційно схильні до перезапису при переповненні буфера в стеку. Що з успіхом експлуатувалося творцями експлойтів: покажчик на хендлер перезаписывался потрібне для виконання шелл-коду адресою, при цьому також перезаписывался і покажчик на наступний елемент списку, що призводило до порушення цілісності ланцюжка хендлеров. Для збільшення стійкості перед атаками на програми, що використовують SEH, Microsoft розробили такі механізми, як SafeSEH (таблиця з адресами «безпечних» хендлеров, розташована в директорії IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG PE-файлу), SEHOP (проста перевірка цілісності ланцюга фреймів), а також інтегрували відповідні системної політики DEP перевірки, вироблені в процесі диспетчеризації винятку.

Спрощений псевдокод основної процедури диспетчеризації RtlDispatchException для x86-версії бібліотеки ntdll.dll у Windows 8.1 можна представити (з деякими допущеннями) наступним чином:

void RtlDispatchException(...) // NT 6.3.9600
{
/* Виклик ланцюжка Vectored Exception Handlers */
if (RtlpCallVectoredHandlers(exception, 1)) return 1;
ExceptionRegistration = RtlpGetRegistrationHead();
/* ECV (SEHOP) */
if (!DisableExceptionChainValidation && 
!RtlpIsValidExceptionChain(ExceptionRegistration, ...)) { 
if (_RtlpProcessECVPolicy != 2) 
goto final;
else
RtlReportException();
}
/* Перебираємо ланцюжок хендлеров, поки не знайдемо підходящий */
while (ExceptionRegistration != EXCEPTION_CHAIN_END) {
/* Перевірка кордонів стека */
if (!STACK_LIMITS(ExceptionRegistration)) {
ExceptionRecord->ExceptionFlags |= EXCEPTION_STACK_INVALID;
goto final;
}
/* Валідація хендлера */
if (!RtlIsValidHandler(ExceptionRegistration, ProcessFlags)) goto final;
/* Передаємо управління хендлеру */
RtlpExecuteHandlerForException(..., ExceptionRegistration->Handler);
...
ExceptionRegistration = ExceptionRegistration->Next;
}
...
final:
/* Виклик ланцюжка Vectored Continue Handlers */
RtlpCallVectoredHandlers(exception, 1);
}

З представленого псевдокода можна зробити висновок, що для успішної передачі управління SEH-хендлеру при диспетчеризації виключення повинні бути виконані наступні умови:

  1. Ланцюжок SEH-фреймів повинна бути коректною (закінчуватися хендлером ntdll!FinalExceptionHandler). Перевірка проводиться при включеному SEHOP для процесу.
  2. SEH-кадр повинен розташовуватися в стеку.
  3. SEH-кадр повинен містити покажчик на «валідний» хендлер.

INFO

Для Vectored Exception Handling ніяких перевірок в диспетчері не здійснюється, що робить VEH підходящим інструментом, коли немає потреби морочитися з підтримкою SEH в програмі.

Стек викликів для фільтра винятків

Якщо з першими двома пунктами все гранично ясно і ніяких додаткових дій для їх виконання не потрібно, то процедуру перевірки хендлера на валідність розберемо детальніше. Перевірка хендлера проводиться функцією ntdll!RtlIsValidHandler, псевдокод для якої версії Vista SP1 був вперше представлений широкій публіці ще в далекому 2008 році на конференції Black Hat в Штатах. Нехай він і містив деякі неточності, це не заважало йому кочувати у вигляді копипасты з одного ресурсу на інший протягом декількох років. З тих пір код цієї функції не зазнав значних змін, а аналіз її версії для Windows 8.1 дозволив скласти наступний псевдокод:

BOOL RtlIsValidHandler(Handler) // NT 6.3.9600
{
if (/* Handler в межах способу */) {
if (DllCharacteristics&IMAGE_DLLCHARACTERISTICS_NO_SEH)
goto InvalidHandler;
if (/* Образ є .Net складанням, встановлений ILonly прапор */)
goto InvalidHandler; 
if (/* Знайдена таблиця SafeSEH */) {
if (/* Образ зареєстрований в LdrpInvertedFunctionTable (або її кеші), або ініціалізація процесу не завершена */) {
if (/* Handler знайдений в таблиці SafeSEH */)
return TRUE;
else
goto InvalidHandler;
}
return TRUE;
} else {
if (/* ExecuteDispatchEnable і ImageDispatchEnable прапори встановлені в ExecuteOptions процесу */) 
return TRUE;
if (/* Handler знаходиться в неисполняемой області пам'яті */) {
if (ExecuteDispatchEnable) return TRUE;
}
else if (ImageDispatchEnable) return TRUE;
}
InvalidHandler:
RtlInvalidHandlerDetected(...);
return FALSE;
}

У наведеному вище псевдокоде змінено порядок перевірки умов (в оригіналі деякі умови перевіряються двічі, деякі перевіряються під вкладених функціях). Проаналізувавши псевдокод, можна зробити висновок, що для успішного проходження валідації має бути виконаний один з наборів умов, при якому хендлер належить:

  • образом без SafeSEH, без прапора NO_SEH, без прапора ILonly;
  • образом з SafeSEH, без прапора NO_SEH, без прапора ILonly, образ повинен бути зареєстрований в LdrpInvertedFunctionTable (не потрібно, якщо виключення відбулося в момент ініціалізації процесу);
  • неисполняемой області пам'яті, прапор ExecuteDispatchEnable (ExecuteOptions) повинен бути встановлений (буде працювати тільки при відключеному No Execute для процесу);
  • виконуваної області пам'яті, прапор ImageDispatchEnable повинен бути встановлений.
При цьому область пам'яті вважається чином, якщо для неї в атрибутах регіону встановлено прапор MEM_IMAGE (атрибути виходять функцією NtQueryVirtualMemory), а вміст відповідає PE-структурі. Прапори процесу виходять функцією NtQueryInformationProces з KPROCESS.KEXECUTE_OPTIONS. Виходячи з отриманої інформації, для реалізації підтримки виключень в динамічно розміщується коді на платформі x86 можна виділити щонайменше три способи:

  1. Установка/заміна прапора ImageDispatchEnable для процесу.
  2. Підміна типу регіону пам'яті на MEM_IMAGE (для PE-образу без SafeSEH).
  3. Реалізація власного диспетчера виключень в обхід всіх перевірок.
Кожен з цих варіантів ми докладно розглянемо далі. Окремо варто згадати про підтримку SafeSEH, яка може знадобитися, якщо ти пишеш, наприклад, звичайний легальний PE-пакувальник або протектор. Для її реалізації доведеться подбати про ручне додавання запису про смаппированном образі (з покажчиком на SafeSEH) в глобальну таблицю ntdll!LdrpInvertedFunctionTable, при цьому функції, що працюють з цією таблицею безпосередньо, не експортуються бібліотекою ntdll.dll і шукати їх вручну сенсу небагато: в старих ОС вони все одно вимагають вказівник на саму таблицю. Знайшовши яким-небудь чином покажчик, доведеться також подбати про блокування доступу до таблиці для безпечного внесення змін. Альтернативним варіантом може бути розпакування файлу в одну із секцій распаковщика і перенесення таблиці SafeSEH з распаковываемого файлу в основний образ. На жаль, докладний розгляд цих та інших технік виходить за рамки цієї статті, тут розглянуті варіанти, не передбачають підтримку SafeSEH (цю таблицю, до речі, завжди можна просто обнулити).

Підміна ExecuteOptions процесу

ExecuteOptions (KEXECUTE_OPTIONS) — частина структури ядра KPROCESS, в якій знаходяться налаштування DEP для процесу. Структура має вигляд:

typedef struct _KEXECUTE_OPTIONS {
UCHAR ExecuteDisable : 1;
UCHAR ExecuteEnable : 1;
UCHAR DisableThunkEmulation : 1;
UCHAR Permanent : 1;
UCHAR ExecuteDispatchEnable : 1;
UCHAR ImageDispatchEnable : 1;
UCHAR Spare : 2;
} KEXECUTE_OPTIONS, PKEXECUTE_OPTIONS;


ExecuteOptions процесу при включеному DEP

Значення цих параметрів (прапорів) на рівні користувача виходять функцією NtQueryInformationProcess з параметром класу інформації, рівним 0x22 (ProcessExecuteFlags). Встановлюються прапори аналогічним чином функцією NtSetInformationProcess. Починаючи з Vista SP1, для процесів з включеним DEP за замовчуванням встановлюється прапор Permanent, що забороняє вносити зміни в налаштування після ініціалізації процесу. Фрагмент процедури KeSetExecuteOptions, викликається в режимі ядра з NtSetInformationProcess, це підтверджує:

@PermanentCheck: ; KeSetExecuteOptions +2Fh
mov al, [edi+6Ch] ; current KEXECUTE_OPTIONS
mov byte ptr [ebp+arg_0+3], al
test al, 8 ; test Permanent
jnz short @Fail ; повертається 0C0000022h (STATUS_ACCESS_DENIED)

Таким чином, перебуваючи в user-mode, ExecuteOptions при активованому DEP змінити буде неможливо. Але залишається варіант просто «обдурити» RtlIsValidHandler, встановивши хук на NtQueryInformationProcess, де прапори підмінятимуться потрібними. Установка подібного перехоплення зробить працездатними виключення в коді, розміщеному поза модулів, завантажених системою. Приклад коду перехоплювача:

NTSTATUS __stdcall xNtQueryInformationProcess(HANDLE ProcessHandle, INT ProcessInformationClass, PVOID ProcessInformation, ULONG ProcessInformationLength, PULONG ReturnLength)
{
NTSTATUS Status = org_NtQueryInformationProcess(ProcessHandle, ProcessInformationClass, ProcessInformation, ProcessInformationLength, ReturnLength);

if (!Status && ProcessInformationClass == 0x22) /* ProcessExecuteFlags */
*(PDWORD)ProcessInformation |= 0x20; /* ImageDispatchEnable */
return Status;
}

Підміна атрибутів пам'яті

Альтернативним варіантом підміні прапорів процесу виступає підміна атрибутів регіону пам'яті, в якому розміщений хендлер. Як вже було зазначено, RtlIsValidHandler перевіряє тип виділеної області пам'яті, і, якщо він відповідає MEM_IMAGE, область вважається чином. Присвоїти MEM_IMAGE виділеної VirtualAlloc області неможливо, цей тип може бути встановлений тільки для відображення секції (NtCreateSection), для якої зазначений коректний файловий хендл. Так само як і з підміною ExecuteOptions, потрібен буде перехоплення, на цей раз функції NtQueryVirtualMemory:

NTSTATUS NTAPI xNtQueryVirtualMemory(HANDLE ProcessHandle, PVOID BaseAddress, INT MemoryInformationClass, PMEMORY_BASIC_INFORMATION MemInformation, ULONG Length, PULONG ResultLength)
{
NTSTATUS Status = org_NtQueryVirtualMemory(ProcessHandle, BaseAddress, MemoryInformationClass, Buffer, Length, ResultLength);
if (!Status && !MemoryInformationClass) /* MemoryBasicInformation */
{
if((UINT_PTR)MemInformation->AllocationBase == g_ImageBase) MemInformation->Type = MEM_IMAGE;
}
return Status;
}

Спосіб підходить для винятків при инжекте PE-образу цілком або вручну смаппированных образів. До того ж цей варіант дещо кращий, ніж попередній, хоча б тому, що не знижує безпеку процесу частковим відключенням DEP (адже тобі не потрібні додаткові ушкодження?). В якості бонусу цей метод дозволяє пройти внутрішню перевірку хендлера в сучасних версіях CRT при використанні try-except та try-finally конструкцій (ці конструкції можна використовувати і без CRT, детальніше про це — у відповідній врізки). Перевірка CRT виконується функцією __ValidateEH3RN, що викликається з _except_handler3, вона передбачає встановлений тип MEM_IMAGE для регіону, а також коректну PE-структуру.

Власний менеджер винятків

Якщо варіанти з установкою хука не годяться будь-якої причини або просто не подобаються, можна піти ще далі і повністю замінити диспетчеризацію SEH своїм кодом, реалізувавши всю необхідну логіку диспетчера SEH всередині векторного хендлера. З наведеного псевдокода RtlDispatchException видно, що VEH викликається раніше, ніж починається обробка ланцюжка SEH. Ніщо не заважає захопити контроль над винятком векторним хендлером і самому вирішити, що з ним робити і які обробники для нього викликати. Встановлюється VEH-обробник всього одним рядком:

AddVectoredExceptionHandler(0, (PVECTORED_EXCEPTION_HANDLER) &VectoredSEH);

де VectoredSEH — хендлер, що є насправді диспетчером SEH. Повна ланцюжок викликів для цього хендлера буде виглядати так: KiUserExceptionDispatcher -> RtlDispatchException -> RtlpCallVectoredHandlers -> VectoredSEH. При цьому управління викликала функції можна і не повертати, а самому викликати NtContinue або NtRaiseException в залежності від успіху диспетчеризації. Повні вихідні коди реалізації SEH через VEH дивись прикріплений до статті матеріалах, або на GitHub. Код реалізації повністю робочий, а логіка диспетчеризації відповідає системній.


Диспетчер SEH всередині векторного хендлера

x64 і IA64

В 64-бітових версіях Windows для платформ x64 і Itanium застосовується зовсім інший спосіб обробки винятків, ніж у x86-версіях. Спосіб заснований на таблицях, що містять всю необхідну для диспетчеризації винятку інформацію, включаючи зміщення початку і кінця блоку коду, для якого проводиться обробка винятку. Тому в коді, скомпільованому для цих платформ, немає ніяких операцій з встановлення та зняття обробника для кожного try-except блоку. Статична таблиця винятків розташований у Exception Directory PE-файлу і являє собою масив елементів структур RUNTIME_FUNCTION, виглядають наступним чином:

typedef struct _RUNTIME_FUNCTION {
ULONG BeginAddress;
ULONG EndAddress;
ULONG UnwindData;
} RUNTIME_FUNCTION, *PRUNTIME_FUNCTION;

Приємний момент: на рівні системи реалізована підтримка винятків для динамічного коду. Якщо код знаходиться в області пам'яті, яка не є чином, у цьому образі відсутня згенерована компілятором таблиця винятків, то інформація для обробки винятків береться з динамічних таблиць винятків (DynamicFunctionTable). Покажчик на список зберігається в ntdll!RtlpDynamicFunctionTable, ntdll.dll експортуються кілька функцій для роботи зі списком. Побіжний аналіз лістингів цих функцій дозволив отримати наступну структуру елементів списку DynamicFunctionTable:

struct _DynamicFunctionTable {
/* +0h */
PVOID Next;
PVOID Prev; // Перший елемент вказує сам на себе
/* +10h */
PRUNTIME_FUNCTION Table;// Покажчик на таблицю, для колбэка поле використовується як ID|0x03
PVOID TimeCookie; // ZwQuerySystemTime
/* +20h */
PVOID RegionStart; // Зміщення відносно BaseAddress
DWORD RegionLength; // Охоплювана таблицею (колбэком) область
/* +30h */
DWORD64 BaseAddress; 
PGET_RUNTIME_FUNCTION_CALLBACK Callback;
/* +40h */
PVOID Context; // Користувальницький аргумент для колбэка
DWORD64 CallbackDll; // Вказує на +58h, якщо DLL визначена
/* +50h */
DWORD Type; // 1 — table, 2 — callback
DWORD EntryCount;
WCHAR DllName[1];
};


Алгоритм пошуку RUNTIME_FUNCTION

Додаються елементи функціями RtlAddFunctionTable і RtlInstallFunctionTableCallback, видаляються за допомогою RtlDeleteFunctionTable. Всі ці функції добре документовані в MSDN і дуже прості у використанні. Приклад додавання динамічної таблиці для тільки що відображеного вручну образу:

ULONG Size, Length;
/* Отримуємо таблицю, згенеровану компілятором, для екранного образу */
PRUNTIME_FUNCTION Table = (PRUNTIME_FUNCTION) RtlImageDirectoryEntryToData(NewImage, TRUE, IMAGE_DIRECTORY_ENTRY_EXCEPTION, &Size);
Length = Size/sizeof(PRUNTIME_FUNCTION);
/* Додаємо таблицю образу в список DynamicFunctionTable */
RtlAddFunctionTable(Table, Length, (UINT_PTR)NewImage);

От і все, ніяких хуків або власних диспетчерів винятків, ніяких обходів системних перевірок. Варто тільки відзначити, що DynamicFunctionTable глобальна для процесу, тому якщо код, для якого додана запис, відпрацював і повинен бути видалений, то відповідний запис з таблиці також варто прибрати. Замість додавання таблиці можна встановити колбек для певного діапазону адрес в АП, який буде отримувати управління щоразу, коли необхідна запис RUNTIME_FUNCTION для коду з цієї області. Версію з установкою колбэка дивись прикріплений до статті основи.


Виняток опрацьовано

__finally

Низькорівневе програмування під Windows з використанням нативного API не нав'язує виключення як метод обробки помилок, і розробники «специфічного софта» часто або ними просто нехтують, або обмежуються установкою фільтру необроблених виключень або простим використанням VEH. Проте винятки все одно залишаються потужним механізмом, за допомогою якого ти зможеш витягнути тим більший виграш, ніж більш складна архітектура твоєї програми. А завдяки розглянутих у статті способам ти зможеш користуватися винятками навіть в самих неординарних умовах.

Корисні матеріали

Також рекомендую обзавестися Windows Research Kernel (основна частина вихідного ядра NT5.2). WRK поширюється для університетів та академічних організацій, але не мені тебе вчити, як і де шукати подібні речі.

Конструкції try-except та try-finally без CRT

Якщо ти збираєшся користуватися конструкціями блоків винятків та фіналізації, то тобі слід подбати про наявність у програмі процедури, яку компілятор підставляє замість реального хендлера: для x86-це проектів __except_handler3, а для x64 — __C_specific_handler. В цих процедурах виробляється власна диспетчеризація: пошук і виклик необхідних хендлеров, а також розкрутка стека. Немає особливої потреби писати їх самостійно, для х86-проекту можна просто підключити expsup3.lib з старого DDK (ntdll.lib з DDK також містить у собі необхідні функції), для x64 все ще простіше: __C_specific_handler експортується 64-бітною версією ntdll.dll, достатньо скористатися правильним lib-файл.
image

Вперше надруковано в журналі Хакер #195.
Автор: Teq


Підпишись на «Хакер»

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.