Исключения для хардкорщиков. Особенности обработки эксепшенов в динамически размещаемом коде
Современные версии ОС налагают на исполняемый код ограничения, связанные с требованиями безопасности. В таких условиях использование механизма исключений в инжектированном коде или, скажем, во вручную спроецированном образе может стать нетривиальной задачей, если не быть в курсе некоторых нюансов. В этой статье речь пойдет о внутреннем устройстве юзермодного диспетчера исключений ОС 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-хендлеру при диспетчеризации исключения должны быть выполнены следующие условия:
- Цепочка SEH-фреймов должна быть корректной (заканчиваться хендлером ntdll! FinalExceptionHandler). Проверка производится при включенном SEHOP для процесса.
- SEH-фрейм должен располагаться в стеке.
- 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 платформе можно выделить минимум три способа:
- Установка/подмена флага ImageDispatchEnable для процесса.
- Подмена типа региона памяти на MEM_IMAGE (для PE-образа без SafeSEH).
- Реализация собственного диспетчера исключений в обход всех проверок.
Каждый из этих вариантов мы подробно рассмотрим далее. Отдельно стоит упомянуть о поддержке 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 с параметром класса информации, равным 0×22 (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 == 0×22) /* ProcessExecuteFlags */ *(PDWORD)ProcessInformation |= 0×20; /* 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|0×03 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-файлом.
Впервые опубликовано в журнале Хакер #195.Автор: Teq
Подпишись на «Хакер»