Разбираемся с EXCEPTION_CONTINUE_EXECUTION
Механизм структурированной обработки исключений (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;
}
При работе нашего приложения снова получаем исправленный результат:
Как видим, структурированная обработка исключений очень мощный и полезный механизм, который не только позволяет «отловить» исключение, но и исправить данные, которые привели к его генерации.