Механизм структурированной обработки исключений (Structured Exception Handling, SEH) позволяет вернуться к инструкции, сгенерировавшей исключение и попробовать выполнить ее заново. Для этого в блок __except нужно передать значение EXCEPTION_CONTINUE_EXECUTION. Важно помнить, что возврат происходит к ассемблерной инструкции, а не инструкции высокоуровневого языка.
Рассмотрим пример 32-битного приложения, в котором происходит деление на 0:
DWORD a = 0, b = 1, res = 0;
__try
{
res = b / a;
printf("res = %d\n", res);
}
__except (CustomFilter(&a, GetExceptionInformation())){ }
Взглянем на ассемблерный код:
Исследуя код в отладчике, можно увидеть, что исключение генерируется командой div. В первом аргументе этой инструкции, находящемся в регистре EAX, находится делимое, а делитель (второй аргумент) берется из памяти (dword ptr [a]). Таким образом, для «исправления» исключения нужно изменить значение, хранящееся по адресу dword ptr [a], т.е. в переменной a.
Наша функция-фильтр исключений будет принимать первым аргументом адрес региона памяти (переменной a), значение в котором нужно изменить. Далее функция разыменовывает указатель и помещает в заданный регион памяти ненулевое значение:
DWORD WINAPI CustomFilter(PVOID Arg, PEXCEPTION_POINTERS ExPtrs)
{
// Only for x86, NOT x64!
if (ExPtrs->ExceptionRecord->ExceptionCode == EXCEPTION_INT_DIVIDE_BY_ZERO)
{
DWORD* ptr = (DWORD*)Arg;
(*ptr)++;
printf("Argument with zero value has been incremented\n");
return EXCEPTION_CONTINUE_EXECUTION;
}
return EXCEPTION_CONTINUE_SEARCH;
}
Как видно, после наших исправлений, вычисления произведены корректно и выполнена следующая за делением инструкция – вывод результата на экран:
Теперь скомпилируем наш пример для x64 и запустим:
Теперь наша программа входит в бесконечный цикл. В чем же дело?
Взглянем на ассемблерный код блока __try:
Теперь второй аргумент инструкции div хранится в регистре ECX. Поэтому, даже изменив значение в памяти (переменную a), значение в регистре ECX остается нетронутым. Поэтому, когда из обработчика исключения произойдет возврат к инструкции div, снова будет произведено деление на нуль, сгенерировано исключение, а потом будет вызван наш обработчик исключения. Таким образом, получили бесконечный цикл.
Для «исправления» исключения необходимо изменить значение в регистре ECX, а потом заново выполнить инструкцию div. К счастью, сделать это довольно просто. Обработчик исключения восстанавливает контекст, который был за момент генерации исключения, а затем повторно выполняет инструкцию, вызвавшую исключение. Контекст хранит, в том числе, и значения регистров. Контекст потока хранится в структуре CONTEXT, указатель на которую хранится в структуре EXCEPTION_POINTERS, которая может быть получена в блоке __except при помощи макроса GetExceptionInformation. GetExceptionInformation может быть вызвана только в фильтре исключений. В структуре CONTEXT имеется поле Rcx, в котором сохранено значение регистра RCX, младшей частью которого является регистр ECX. Данное значение будет восстановлено при повторном выполнении инструкции, сгенерировавшей исключение.
Тогда функция CustomFilter должна быть модифицирована следующим образом:
DWORD WINAPI CustomFilter(PVOID Arg, PEXCEPTION_POINTERS ExPtrs)
{
// Only for x64, NOT x86!
if (ExPtrs->ExceptionRecord->ExceptionCode == EXCEPTION_INT_DIVIDE_BY_ZERO)
{
ExPtrs->ContextRecord->Rcx = 1;
printf("Argument with zero value has been incremented\n");
return EXCEPTION_CONTINUE_EXECUTION;
}
return EXCEPTION_CONTINUE_SEARCH;
}
Теперь приложение не входит в бесконечный цикл, корректно производит вычисления и выполняет следующую за делением инструкцию – вывод результата на экран:
Для того, чтобы функция CustomFilter работала для обоих разрядностей (x86 и x64) можно воспользоваться директивами условной компиляции:
DWORD WINAPI CustomFilter(PVOID Arg, PEXCEPTION_POINTERS ExPtrs)
{
if (ExPtrs->ExceptionRecord->ExceptionCode == EXCEPTION_INT_DIVIDE_BY_ZERO)
{
#if _WIN64
ExPtrs->ContextRecord->Rcx = 1;
#elif _WIN32
DWORD* ptr = (DWORD*)Arg;
(*ptr)++;
#endif
printf("Argument with zero value has been incremented\n");
return EXCEPTION_CONTINUE_EXECUTION;
}
return EXCEPTION_CONTINUE_SEARCH;
}
Применяя аналогичный подход, реализуем обработку ошибок обращения по нулевому указателю. Код исключения, возникающего при попытке разыменования нулевого указателя – EXCEPTION_ACCESS_VIOLATION. Однако, данное исключение может возникнуть и в других случаях, например, обращение по невалидному адресу. Мы же рассмотрим простейший случай. Для отличия обращения по нулевому адресу от других исключительных ситуаций нужно выполнить дополнительные проверки.
Код, генерирующий исключение:
BYTE* mem = NULL;
__try
{
mem[0] = 'A';
printf("mem = %s\n", mem);
HeapFree(GetProcessHeap(), HEAP_ZERO_MEMORY, mem);
}
__except(CustomFilter2(&mem, GetExceptionInformation())) { }
Разыменование нулевого указателя произойдет в строке:
mem[0] = 'A';
Данный код преобразуется в следующие инструкции:
Адрес, записанный в переменную mem, помещается в регистр EDX. В регистр ECX помещается индекс, точнее смещение в байтах, кратное размеру элементов массива, относительно начала региона памяти. Код 41h – шестнадцатеричное значение кода символа ‘A’ в таблице ASCII. Исключение нарушения доступа будет сгенерировано инструкцией
mov byte ptr [edx+ecx], 41h
Таким образом, для «исправления» нам необходимо поместить в регистр EDX адрес корректного региона памяти.
На x64 код, генерирующий исключение выглядит так:
Здесь смещение хранится в регистре RAX, а адрес региона памяти – в регистре RCX. Для «исправления» адрес корректного региона памяти должен быть помещен в регистр RCX.
В обработчике исключения выделим в Heap регион памяти и запишем туда данные – символы слова “Hello”. Адрес данного региона должен быть сохранен не только в соответствующем регистре в контексте, но и в переменной mem, для того, чтобы иметь возможность освободит выделенную память.
Код функции CustomFilter2, работающей для обоих разрядностей (x86 и x64):
DWORD WINAPI CustomFilter2(PVOID Arg, PEXCEPTION_POINTERS ExPtrs)
{
if (ExPtrs->ExceptionRecord->ExceptionCode == EXCEPTION_ACCESS_VIOLATION)
{
BYTE** ptr = (BYTE**)Arg;
*ptr = (BYTE*)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, 100);
#if _WIN64
ExPtrs->ContextRecord->Rcx = (ULONG_PTR)*ptr;
#elif _WIN32
ExPtrs->ContextRecord->Edx = (DWORD)*ptr;
#endif
(*ptr)[0] = 'H';
(*ptr)[1] = 'e';
(*ptr)[2] = 'l';
(*ptr)[3] = 'l';
(*ptr)[4] = 'o';
printf("Memory has been allocated\n");
return EXCEPTION_CONTINUE_EXECUTION;
}
return EXCEPTION_CONTINUE_SEARCH;
}
После запуска приложения видим, что после генерации исключения происходит «исправление» - выделение региона памяти и запись туда символов “Hello”, после этого вновь выполняется инструкция, сгенерировавшая исключение, в результате чего первый символ заменяется на ‘A’. Далее происходит освобождение выделенной памяти.
А что еще можно сделать? Можно вернуться не к машинной инструкции, сгенерировавшей исключение, а к одной из предыдущих. Например, к инструкции, помещающей значение делителя из памяти в регистр. Адрес следующей для выполнения инструкции хранится в регистре EIP (x86) или RIP (x64). Следовательно, значение этого регистра хранится в поле Eip (x86) или Rip (x64) структуры CONTEXT. Также адрес инструкции, сгенерировавшей исключение хранится в поле ExceptionAddress вложенной в EXCEPTION_POINTERS структуры EXCEPTION_RECORD.
Рассмотрим уже известный пример с делением на нуль:
DWORD a = 0, b = 1, res = 0;
__try
{
res = b / a;
printf("res = %d\n", res);
}
__except (CustomFilter3(&a, GetExceptionInformation())){ }
Для определения адреса инструкции, на которую нам нужно вернуть управление из обработчика воспользуемся дизассемблированным листингом.
х86:
Как мы уже говорили ранее, значение делителя в x86 коде берется непосредственно из памяти. Поэтому изменять значение регистра EIP нам не нужно. Нужно только изменить значение переменной a, что мы уже и делали в предыдущем примере.
х64:
А вот с x64 кодом ситуация интереснее. Значение переменной a берется из памяти и помещается в регистр EAX:
mov eax, dword ptr [a]
А далее это значение помещается в стэк по адресу [rbp+174h]:
mov dword ptr [rbp+174h], eax
Далее значение, хранящееся по этому адресу, помещается в регистр ECX:
mov ecx, dword ptr [rbp+174h]
И, наконец, значение в регистре ECX используется в качестве делителя в команде div:
div eax, ecx
Следовательно, после изменения значения переменной a, нужно вернутся к инструкции, загружающей значение a из памяти, т.е. к:
mov eax, dword ptr [a]
Рассчитаем разницу адресов между командой, вызвавшей исключение (div) и инструкцией, считывающей значение a из памяти:
Таким образом, для повторного считывания значения a из памяти необходимо из значения регистра RIP вычесть 20.
Код функции-фильтра CustomFilter3:
DWORD WINAPI CustomFilter3(PVOID Arg, PEXCEPTION_POINTERS ExPtrs)
{
if (ExPtrs->ExceptionRecord->ExceptionCode == EXCEPTION_INT_DIVIDE_BY_ZERO)
{
DWORD* ptr = (DWORD*)Arg;
(*ptr)++;
#if _WIN64
ExPtrs->ContextRecord->Rip -= 20;
#endif
printf("Argument with zero value has been reinitialized\n");
return EXCEPTION_CONTINUE_EXECUTION;
}
return EXCEPTION_CONTINUE_SEARCH;
}
При работе нашего приложения снова получаем исправленный результат:
Как видим, структурированная обработка исключений очень мощный и полезный механизм, который не только позволяет «отловить» исключение, но и исправить данные, которые привели к его генерации.
Комментарии (8)
TheCalligrapher
16.08.2022 23:01-1Для того, чтобы функция CustomFilter работала для обоих разрядностей (x86 и x64) можно воспользоваться директивами условной компиляции
Совершенно не понятно, почему вы ассоциируете разницу в коде с разрядностью платформы. Разрядность платформы тут совершенно ни при чем, а все зависит от настроек кодогенератора и в первую очередь от настроек оптимизации. Зависимость от разрядности платформы - это не более чем паразитная/случайная зависимость. И судя по вашим экспериментам, вы выполняли их в отладочной конфигурации, что является весьма странной затеей.
Это проблему такими директивами условной компиляции не решить. Или, точнее, это будет "решение", которое очень запросто "превратится в тыкву" в самый неожиданный момент.
KernelCore Автор
16.08.2022 23:35+2https://habr.com/ru/post/682958/#:~:text=Разрядность платформы тут совершенно ни при чем
Не соглашусь. Например, поля структуры EXCEPTION_POINTERS будут иметь различные имена и смещения в x86 и x64.
а все зависит от настроек кодогенератора и в первую очередь от настроек оптимизации
Разумеется, создаваемый машинный код будет зависеть и от этого. В данной статье я привел пример с анализом машинного кода для конкретного описанного случая и соответствующей настройкой в обработчике исключения.
Или, точнее, это будет "решение", которое очень запросто "превратится в тыкву" в самый неожиданный момент.
Разумеется, при пересборке с другими параметрами компилятора, без анализа сгенерированного машинного кода данное решение работать не будет.
fk0
17.08.2022 10:55Остался не раскрыт вопрос, как оно всё работает на суперскалярном процессоре. Когда в момент сбоя проблемой инструкции успели выполниться пара других параллельно. Процессор должен как-то "откатить" результат, что представляется нетривиальной задачей.
ihost
17.08.2022 11:11+2Спекулятивное выполнение и теневые регистры же. Вас же не удивляет, что обе ветки if-else выполняются, а когда результат условия становится известен, выбирается только один путь. Спекуляций видимых за MMU/IOMMU видимо все-таки не существует по определению (Кроме хитрых lockless алгоритмов, но там другое - алгоритм умеет rollback-ить определенные жестко фиксированные случаи, которые сам же и спекулирует :)
Alexey_Sharapov
18.08.2022 01:46А точно обе ветки if else выполняются? Вроде, branch predictor выбирает брать переход или нет. Выполнение обеих веток обычно в VLIW процессорах делается.
Могу ошибаться, поэтому прошу скинуть ссылку на материал, который докажет, что типичный x86-64 процессор выполняет обе ветки, а затем отбрасывает результат той ветки, которую брать не надо.
kozlyuk
Как это применяется на практике? На ум приходит только динамический стек в среде выполнения какого-нибудь языка, когда нужно добавить памяти и продолжить. В остальных случаях логика "у нас нарушен инвариант, но мы его восстановим в фоне и как-нибудь продолжим" выглядит очень сомнительно. А обработчики, зависящие от того, как скомпилируется код, хрупкие и сложные.
KernelCore Автор
Например, при реализации отладчика - продолжение выполнения при достижении программной точки останова (int 3). Выполнив инструкцию int 3, попадаем в обработчик (остановились, делаем, что нужно, например, смотрим значения в регистрах). Для продолжения выполнения перезаписываем установленный breakpoint исходными данными (разумеется они должны быть ранее сохранены) и снова выполняем инструкцию, вызвавшую исключение. Теперь выполняется код уже без breakpoint'a и выполнение идет дальше, например, до следующей точки останова.
assad77
Еще как вариант, для выполнения запрещенных команд, погрузки страниц в легковесном эмуляторе. Но сейчас, я думаю, это не так актуально, наверное, так как есть wsl.