Введение
В этой истории я расскажу вам об увлекательном приключении, которое привело меня к решению одной загадки, которую я сам себе загадал. Разгадка являет собой небольшую подробность в механизме загрузчика 32-х разрядных приложений в системе Windows 7 и выше, а процесс разгадки - длинное путешествие воина, который следует по пути сердца.Если вы попали на эту страницу в поисках ответа на вопрос, то смотрите спойлер ниже, потому что основное содержание может быть интересно тем, кто только пытается разобраться в механизме SEH.
С чего всё началось
Задумавшись о способе защиты своей программы от копирования, я вспомнил о статье Криса Касперского, в которой я узнал о существовании антиотладочных приёмов. Тщетно пытаясь реализовать разобранный Крисом пример в своей программе, я натыкался на стену непонимания со стороны Windows. Проблема заключалась в том, что Windows отказывалась пользоваться моим обработчиком исключений, посчитав его «небезопасным».Более менее подробно о SEH мне удалось почерпнуть из этой статьи. Но, тем не менее, пока я сам не рассмотрел этот механизм c лупой и инструментами, у меня оставались очень смутные представления о его устройстве. Я разматывал цепочки обработчиков, ставил на них точки останова, наблюдал порядок их выполнения, рассматривал возвращаемые значения и т.д.
Ближе к делу
Рассмотрим маленький пример (я пользуюсь компилятором Microsoft Visual C++ )int main()
{
__asm
{
mov eax, DWORD PTR SS : [0]
}
}
Ничего особенного, пытаемся положить в регистр eax содержимое по адресу 0.
Компилируем, запускаем под отладчиком OllyDbg.
Получаем грустное сообщение «access violation when reading 0x00000000».
Берем на заметку, что размер инструкции, которая пытается прочитать по адресу 0 - равен 6 байтам. Обратим также внимание на регистр Eip, который указывает на проблемную инструкцию (совпадает с её адресом слева). Сама же инструкция выглядит как последовательность из 6 байт: 36 A1 00 00 00 00.
Теперь я хочу обработать это исключение, не используя ключевых слов __try, __except т.к. мне показалось это интересной и заманчивой идеей.
Напишем же свой собственный обработчик для обработки этого исключения!
Есть информация, что при возникновении исключения в функцию-обработчик система отправляет следующие аргументы:
struct _EXCEPTION_RECORD* exceptionRecord,
void* establisherFrame,
struct _CONTEXT* contextRecord,
void* dispatcherContext
где exceptionRecord – структура, содержащая код ошибки, адрес исключения, а contextRecord – структура содержащая контекст регистров (включая регистр Eip). Соглашение о вызове функции-обработчика должно быть __cdecl, а возвратить она должна одно из следующих значений:
typedef enum _EXCEPTION_DISPOSITION
{
ExceptionContinueExecution,
ExceptionContinueSearch,
ExceptionNestedException,
ExceptionCollidedUnwind
} EXCEPTION_DISPOSITION;
Я решил обработать это исключение таким нехитрым образом:
EXCEPTION_DISPOSITION __cdecl ExceptionHanler(
struct _EXCEPTION_RECORD* exceptionRecord,
void* establisherFrame,
struct _CONTEXT* contextRecord,
void* dispatcherContext
)
{
contextRecord->Eip += 6;
MessageBoxA(0, "Exteption was handled", "Success!", 0);
return ExceptionContinueExecution;
}
Сместив регистр Eip на 6 байт, мы обойдём проблемную инструкцию! И говорим: ExceptionContinueExecution – продолжить выполнение. Соответственно, выполнение продолжится с инструкции по адресу Eip + 6 и исключений больше возникнуть не должно.
Просто поместить код обработчика в программе - этого мало. Необходимо дать понять системе, что нужно вызывать именно его. И ещё я хочу, чтобы он вызвался самым первым, среди прочих. Для этого нам необходимо где-то создать структуру, состоящую из двух полей: Next и Handler. Поле Next будет содержать указатель на такую же структуру, но с предыдущим обработчиком (с тем, который мы потесним). Поле Handler будет содержать указатель на нашу функцию-обработчик. Указатель на текущую такую структуру находится в TIB (thread information block), иначе говоря, по адресу fs:[0]. Создадим её в стеке.
Преобразуем нашу функцию main:
int main()
{
__asm
{
push ExceptionHanler //положим в стек адрес функции-обработчика
push fs:[0] //положим в стек указатель на структуру текущего обработчика
//теперь в стеке лежит структура, содержащая 2 поля: указатель на
//следующий обработчик и адрес нашего обработчика
mov fs:[0] , esp //положим указатель на свою структуру, вместо текущего
//обработчика по адресу fs:[0]
mov eax, DWORD PTR SS:[0] //пытаемся прочитать по адресу 0
add esp, 8 //чистим стек, удалив 8 байт (размер структуры)
}
}
По незнанию, я компилирую это с настройками по умолчанию - с флагом /SAFESEH и игнорирую непонятные рекомендации компилятора о том, что мне неплохо было бы отключить этот флаг, раз я записываю что-то по адресу fs:[0]. Да что там говорить, я далеко не сразу обратил на это внимание.
Результат – все та же ошибка чтения по адресу 0 и приложение «падает» как ни в чем не бывало!
Хорошо, что все позади. Теперь я знаю об этом флаге. Пробую компилировать с настройкой /SAFESEH:NO.
Компилируем, выполняем наш код, видим наше развесёлое сообщение:
Жмем «ок» программа успешно завершается. Фух.
Переломный момент
Но нет, подождите-ка, но что ИМЕННО сделал в тот раз компилятор, чтобы проигнорировать мой обработчик исключения? Что это за фокусы? И как мне быть, если вдруг… ну чисто теоретически, мне вдруг однажды захочется сделать так, чтобы моё приложение имело флаг /SAFESEH, но при этом, чтобы оно заработало, я должен буду встроить в него код mov fs:[0], esp, с указателем на свою функцию-обработчик, вызвать в произвольном месте исключение и обработать его так, как моей душе угодно? Не считаю, что флаг /SAFESEH должен быть помехой для моих фантазий.Пытаясь получить информацию о работе этого флага, я увидел что-то размытое про «таблицы безопасных обработчиков». Если они есть, то как мне на них посмотреть? Как добавить в них свой обработчик? Каким образом нужно изменить исполняемый файл, чтобы заставить систему при его исполнении выбирать обработчик именно из этой таблицы или наоборот, отключить выбор обработчика? Столько вопросов и так мало ответов…
Не падая духом, в надежде разгадать загадку…
Полистав срабатывающие обработчики, я увидел те, которые пушит компилятор на стадии выполнения crt0 (код до функции main), а также обработчик из библиотеки ntdll.dll. Все эти обработчики исправно срабатывают в порядке своей очереди, если я не помещаю свой обработчик по адресу fs:[0]! Но как система узнает, какая функция безопасный обработчик, а какая нет?
В своем путешествии я провёл серию экспериментов. Например, компилируя с флагом /SAFESEH, ставил точку останова перед проблемной инструкцией, брал первый обработчик, на который указывала первая структура по адресу fs:[0] и менял начало функции прямо в отладчике на JMP ExceptionHandler (мой самописный обработчик). Срабатывает! Срабатывает именно мой обработчик! И к чему тогда эта неведомая таблица безопасных, когда я могу легко подставить вместо любого безопасного прыжок на свой «опасный»? Впрочем, не важно. Это не даёт ответа на мои изначальные вопросы.
Теперь я пробую записать лог от entry point до функции main двух EXE файлов, скомпилированных с флагом /SAFESEH и /SAFESEH:NO, наивно предполагая, что компилятор на этой стадии вызывает некие WinApi функции, с помощью которых можно добавить в систему адреса безопасных обработчиков. Все тщетно. Два лога практически не отличаются, разве что серией дополнительных невнятных инструкций, не имеющих ничего общего с адресами безопасных обработчиков. Но подозрения все равно остались.
Чтобы окончательно исключить crt0 из списка подозреваемых мне необходимо выяснить еще кое-что. Я придумал следующий эксперимент, результат которого даст мне точный ответ на вопрос! Меняю инструкцию точки входа в программу на следующие команды push main, ret. Таким образом, первой командой в стек попадает адрес функции main, а вторая команда ret «возвращает» регистр указатель на выполнение функции main и функция main выполняется сразу, без crt0! (можно было обойтись и простым JMP, но его немного сложнее реализовать на лету в отладчике)
Я снова был удивлён, ведь мой обработчик вновь не вызвался! Несмотря на неудачу, это дало очень важный ответ: тайна кроется внутри EXE, скомпилированного хитрым образом, а не в коде, который выполняет компилятор.
Может быть, они спрятали этот флаг в PE хедере? В каком-нибудь поле LoaderFlags? И тут неудача… У обоих файлов абсолютно идентичные структуры, за исключением разницы в размере секции rdata. Но даже если в секции rdata и лежит несколько дополнительных байт, разве это может быть способом взаимодействия с загрузчиком, кардинально изменяющим работу программы? Я был уверен, что нет…
Ведь в секциях можно размещать что угодно, главное - соблюсти ключевые правила, думаю я. Там ведь полно мусора! Всякие строки, цифры, дни недели, это ведь не монолитная структура какая-то, а блоки данных, на которые указывают указатели. Эти блоки располагаются в произвольном месте, но в диапазоне секции. При сравнении двух файлов, никаких указателей на область rdata я не увидел, кроме известной importTable, debug и неизвестного указателя на boundImport. Кроме того, такой же указатель был и в файле без флага /SAFESEH! Его размер в таблице dataDirectories был очень маленьким и он был заполнен нулями. Это не вызывало моих подозрений, потому что я был уверен, что если бы это каким-то образом включало в себя таблицу обработчиков, то в файле без флага /SAFESEH это поле просто напросто отсутствовало бы, что было бы знаком для загрузчика «у этого EXE таблицы нет».
Победа близка
Отчаявшись, я беру адрес первого «безопасного» обработчика, который подсовывает в мой EXE компилятор. А точнее - первые два младших байта его адреса и прошелся поиском по всему файлу. То, что я решил взять именно 2 байта, включая те значения, которые не являются виртуальными адресами, является везением. И вот, я выписал все сырые адреса, по которым располагаются эти значения, преобразовал адреса в виртуальные (их было около 3-х) и приступил к задуманному.Имея несколько адресов в памяти, предположительно намекающих на «законный» обработчик от компилятора, я ставлю точку останова перед командой mov eax, DWORD PTR SS : [0] и жму play. Срабатывает. Я расставляю точки останова на чтение памяти по записанным адресам и снова жму play.
Удивительно! О брекпоинт споткнулась некая безымянная функция в библиотеке ntdll.dll.
Смотрим. Функция пытается считать по адресу 0x1341d20 из диапазона rdata число 0x1be0, а затем сравнить его с числом, находящимся в EBX – 0x1000. Так, а ведь по адресу imageBase + 0x1000 расположился мой подставной обработчик! Он - то сейчас и находится в числе первых обработчиков. А по адресам 0x1be0, 0x2080 и 0x2351 располагаются безопасные обработчики от компилятора. Вот она эта таблица! Она всё это время была в моём файле!
В отладчике я меняю содержимое адреса 0x1341d20 с 0x1be0 на 0x1000 и… мой обработчик стал вдруг безопасным и послушно обработал исключение!
Реализация проверки безопасных обработчиков в открытом виде! Она не находится внутри ядра Windows, как я ожидал. Так что можно даже подкорректировать код в ntdll.dll, чтобы в нужное время он разрешал или запрещал выбранные нами обработчики!
Вернёмся к нашему файлу еще раз внимательно посмотрим, что находится в окрестностях.
Ага, таблица на месте. Кроме того, случайно мне бросился в глаза адрес-указатель на начало таблицы, а также странное совпадение – число 3, равное количеству обработчиков.
Попробую-ка я заполнить нулями этот адрес-указатель.
Успех! Программа, которую я скомпилировал с флагом /SAFESEH теперь выполняется так, как будто бы был установлен флаг флага /SAFESEH:NO!
А сравним с файлом, который был скомпилирован без этого флага?
Чёрт… ни адреса-указателя на таблицу в этом месте, ни таблицы обработчиков… И как я сразу не додумался сравнить два файла скомпилированных с разными флагами в Total Commander?!
Тем не менее, что указывает на это место в PE хедере? Самым близким является boundImport. И вот только теперь я открываю сайт Microsoft с описанием PE формата.
В общем… я предлагаю вам самим ознакомиться с тем, что парни из Microsoft пишут там про boundImport. Что-то о таблице каталога отложенной загрузки на этапе связывания пост-обработки? Что?
В этом большом и непонятном документе, в котором описаны разные различия этого формата для разных платформ и т.д. Но меня уже не остановить, я начинаю упорно вводить в поиске слово except… прыгаю по результатам поиска.. Вот, .sxdata - содержит индекс символа каждого из обработчиков. Какая . sxdata? У меня такого нет. Есть только виртуальный указатель в поле boundImport, относительно которого неподалёку размещается адрес-указатель и таблица с адресами функций-обработчиков.
Ищу дальше... Вот, ещё говорят, что в разделе pdata содержится массив обработчиков и что таблица может быть в нескольких форматах. Даже если и так, как загрузчик попадает на начало этой таблицы? В том месте, куда указывает boundImport, нет ничего похожего на тот формат, который представлен.
Смотрю дальше... The Load Configuration Structure. Судя по описанию очень похоже! Они говорят, что структура IMAGE_LOAD_CONFIG_DIRECTORY где-то присутствует и описывают ее предназначение, связанное с SEH.
Думаю, а попробую-ка. Взял, да и написал, что по адресу boundImport находится структура IMAGE_LOAD_CONFIG_DIRECTORY32 и рассмотрел её поля. Все сошлось как пазл! Это опять же было большим везением, что я решился на это.
Вот и всё, весь секрет заключался в том, что boundImport в PE хедере (разложенном по таблице из Википедии) указывает на структуру IMAGE_LOAD_CONFIG_DIRECTORY32. Эта структура содержит поля, которые кардинальным образом меняют работу программы! Теперь мы с уверенностью можем сами создать свою таблицу обработчиков и добавить в неё только то, что посчитаем нужным!
Источник статьи: https://habr.com/ru/post/563106/