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

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

Хакер #195. Атаки на Oracle DB
Диспетчеризація винятків
де EXCEPTION_RECORD - структура з інформацією про виключення, а CONTEXT - структура стану контексту потоку на момент виникнення виключення. Обидві структури документовані в MSDN, втім, ти вже напевно знайомий із ними. Покажчики на ці дані передаються в ntdll!RtlDispatchException , де й виробляється реальна диспетчеризація, причому в 32-бітних і 64-бітових системах механіка обробки винятків відрізняється.
Основний механізм для x86-платформи - Structured Exception Handling (SEH), що базується на однозв'язному списку обробників винятків, розташованому в стеку і завжди доступному з NT_TIB.ExceptionList. Основи цього механізму були багаторазово описані в різних працях (див. врізання «Корисні матеріали»), тому не будемо повторюватися, а лише загостримо увагу на тих моментах, які перетинаються з нашим завданням.
Дамп ланцюжка SEH
Спрощений псевдокод основної процедури диспетчеризації RtlDispatchException для x86-версії бібліотеки ntdll.dll у Windows 8.1 можна представити (з деякими припущеннями) таким чином:
З представленого псевдокода можна дійти невтішного висновку, що з успішної передачі управління SEH-хендлеру при диспетчеризації виключення мають бути виконані такі условия:
- Ланцюжок SEH-фреймів повинен бути коректним (закінчуватися хендлером ntdll! FinalException Handler). Перевірка проводиться за умови включення SEHOP для процесу.
- SEH-фрейм повинен розташовуватись у стеку.
- SEH-фрейм повинен містити покажчик на валідний хендлер.
Для Vectored Exception Handling жодних перевірок у диспетчері не робиться, що робить VEH відповідним інструментом, коли немає потреби морочитися з підтримкою SEH у програмі.
Якщо з першими двома пунктами все ясно і ніяких додаткових дій для їх виконання не потрібно, то процедуру перевірки хендлера на «валідність» розберемо детальніше. Перевірка хендлера проводиться функцією ntdll!RtlIsValidHandler, псевдокод якої для версії Vista SP1 був вперше представлений широкому загалу ще в далекому 2008 році на конференції Black Hat у Штатах. Нехай він і містив деякі неточності, це не заважало йому кочувати як копіпасти з одного ресурсу на інший протягом кількох років. З того часу код цієї функції не зазнав значних змін, а аналіз її версії для Windows 8.1 дозволив скласти наступний псевдокод:
У наведеному вище псевдокоді змінено порядок перевірки умов (в оригіналі деякі умови перевіряються двічі, деякі перевіряються у вкладених функціях). Проаналізувавши псевдокод, можна дійти невтішного висновку, що з успішного проходження валідації має бути виконано одне із наборів умов, у якому хендлер належить:
- образу без SafeSEH, без прапора NO_SEH, без прапора ILonly;
- образ з SafeSEH, без прапора NO_SEH, без прапора ILonly, образ повинен бути зареєстрований в LdrpInvertedFunctionTable (не потрібно, якщо виняток стався в момент ініціалізації процесу);
- невиконуваної області пам'яті, прапор ExecuteDispatchEnable (ExecuteOptions) повинен бути встановлений (працюватиме тільки при вимкненому No Execute для процесу);
- Виконується область пам'яті, прапор ImageDispatchEnable повинен бути встановлений.
При цьому область пам'яті вважається чином, якщо для неїатрибути регіону встановлено прапором MEM_IMAGE (атрибути виходять функцією NtQueryVirtualMemory ), а вміст відповідає PE-структурі. Прапори процесу виходять функцією NtQueryInformationProces з KPROCESS.KEXECUTE_OPTIONS . Виходячи з отриманої інформації, для реалізації підтримки винятків в коді, що динамічно розміщується на x86 платформі можна виділити мінімум три способи:
- Встановлення/заміна прапора ImageDispatchEnable для процесу.
- Підміна типу регіону пам'яті на MEM_IMAGE (для PE-образу без SafeSEH).
- Реалізація власного диспетчера винятків в обхід усіх перевірок.
Кожен із цих варіантів ми докладно розглянемо далі. Окремо варто згадати про підтримку SafeSEH, яка може знадобитися, якщо ти пишеш, наприклад, звичайний легальний PE-пакувальник або протектор. Для її реалізації доведеться подбати про ручне додавання запису про смапований образ (з вказівником на SafeSEH) у глобальну таблицю ntdll! ОС вони однаково вимагають покажчик саму таблицю. Знайшовши певним чином покажчик, доведеться також подбати про блокування доступу до таблиці для безпечного внесення змін. Альтернативним варіантом може бути розпакування файлу в одну з секцій розпакувальника і перенесення таблиці SafeSEH з файлу, що розпаковується в основний образ. На жаль, докладний розгляд цих та інших технік виходить за межі цієї статті, тут розглянуті варіанти, що не передбачають підтримки SafeSEH (цю таблицю, до речі, завжди можна просто обнулити).
Підміна ExecuteOptions процесу
ExecuteOptions ( KEXECUTE_OPTIONS ) - частина структури ядраKPROCESS , де знаходяться налаштування DEP для процесу. Структура має вигляд:

Значення цих налаштувань (прапорів) на рівні користувача виходять функцією NtQueryInformationProcess з параметром класу інформації, рівним 0x22 (ProcessExecuteFlags). Встановлюються прапори аналогічним чином функцією NtSetInformationProcess. Починаючи з Vista SP1, для процесів з увімкненим DEP за замовчуванням встановлюється прапор Permanent, який забороняє вносити зміни в налаштування після ініціалізації процесу. Фрагмент процедури KeSetExecuteOptions, що викликається в режимі ядра з NtSetInformationProcess, це підтверджує:
Таким чином, перебуваючи в user-mode, ExecuteOptions при активованому DEP змінити неможливо. Але залишається варіант просто «обдурити» RtlIsValidHandler, встановивши хук на NtQueryInformationProcess, де прапори підмінятимуться потрібними. Установка такого перехоплення зробить працездатними винятки в коді, розміщеному поза модулями, завантаженими системою. Приклад коду перехоплювача:
Підміна атрибутів пам'яті
Альтернативним варіантом заміни прапорів процесу є заміна атрибутів регіону пам'яті, в якому розміщений хендлер. Як було зазначено, RtlIsValidHandler перевіряє тип виділеної області пам'яті, і, якщо він відповідає MEM_IMAGE , область вважається чином. Присвоїти MEM_IMAGE виділеній VirtualAlloc області неможливо, цей тип може бути встановлений тільки для відображення секції (NtCreateSection), для якої вказано коректний файловий хендл. Так само як і з заміною ExecuteOptions, потрібен буде перехоплення, на цей раз функції NtQueryVirtualMemory :
Спосіб підходить для виключень при інжекті PE-образу цілком або для смапованих образів вручну. До того жцей варіант дещо кращий, ніж попередній, хоча б тому, що не знижує безпеку процесу частковим відключенням DEP (адже тобі не потрібні додаткові зловреди?). Як бонус цей метод дозволяє пройти внутрішню перевірку хендлера в сучасних версіях CRT при використанні try-except і try-finally конструкцій (ці конструкції можна використовувати і без CRT, докладніше про це - у відповідній врізці). Перевірка в CRT виконується функцією __ValidateEH3RN , що викликається з _except_handler3 , вона передбачає встановлений тип MEM_IMAGE для регіону, а також коректну структуру PE.
Власний диспетчер винятків
Якщо варіанти з установкою хука не годяться з якоїсь причини або просто не подобаються, можна піти ще далі і повністю замінити диспетчеризацію SEH своїм кодом, реалізувавши всю логіку необхідну диспетчера SEH всередині векторного хендлера. З наведеного псевдокоду RtlDispatchException видно, що VEH викликається раніше, ніж починається обробка ланцюжка SEH. Ніщо не заважає захопити контроль над винятком векторним хендлером і вирішити, що з ним робити і які обробники для нього викликати. Встановлюється VEH-обробник лише одним рядком:
де VectoredSEH - хендлер, що є насправді диспетчером SEH. Повний ланцюжок викликів для цього хендлера виглядатиме так: KiUserExceptionDispatcher -> RtlDispatchException -> RtlpCallVectoredHandlers -> ВекторнийSEH. При цьому управління функції, що викликала, можна і не повертати, а самому викликати NtContinue або NtRaiseException в залежності від успіху диспетчеризації. Повні вихідники реалізації SEH через VEH дивись у матеріалах, що додаються до статті, або на GitHub. Код реалізації повністю робітничий, а логіка диспетчеризації відповідаєсистемної.

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

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

Низькорівневе програмування під Windows із використанням нативного API не нав'язуєвинятки як метод обробки помилок, і розробники «специфічного софту» часто або просто нехтують, або обмежуються установкою фільтра необроблених винятків або простим використанням VEH. Тим не менш, винятки все одно залишаються потужним механізмом, за допомогою якого ти зможеш отримати тим більший виграш, чим складніша архітектура твоєї програми. А завдяки розглянутим у статті способам ти зможеш користуватися винятками навіть у неординарних умовах.
Корисні матеріали
- Матчастина SEH для x86 у трьох частинах: «SEH зсередини»
- Матчастина винятків для x64: «Exceptional Behavior — x64»
- Офіційна документація від Microsoft: "Exception Handling (x64)"
Також рекомендую придбати Windows Research Kernel (основна частина вихідних джерел ядра NT5.2). WRK поширюється для університетів та академічних організацій, але не мені тебе вчити, як і де шукати подібні речі.