Механизм структурированной обработки исключений (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 из памяти:

Delta = 0x7FF7A16D1992 – 0x7FF7A16D197E = 0x14 = 20

Таким образом, для повторного считывания значения 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)


  1. kozlyuk
    16.08.2022 22:53

    Как это применяется на практике? На ум приходит только динамический стек в среде выполнения какого-нибудь языка, когда нужно добавить памяти и продолжить. В остальных случаях логика "у нас нарушен инвариант, но мы его восстановим в фоне и как-нибудь продолжим" выглядит очень сомнительно. А обработчики, зависящие от того, как скомпилируется код, хрупкие и сложные.


    1. KernelCore Автор
      17.08.2022 00:27
      +3

      Как это применяется на практике?

      Например, при реализации отладчика - продолжение выполнения при достижении программной точки останова (int 3). Выполнив инструкцию int 3, попадаем в обработчик (остановились, делаем, что нужно, например, смотрим значения в регистрах). Для продолжения выполнения перезаписываем установленный breakpoint исходными данными (разумеется они должны быть ранее сохранены) и снова выполняем инструкцию, вызвавшую исключение. Теперь выполняется код уже без breakpoint'a и выполнение идет дальше, например, до следующей точки останова.


    1. assad77
      17.08.2022 20:05
      +2

      Еще как вариант, для выполнения запрещенных команд, погрузки страниц в легковесном эмуляторе. Но сейчас, я думаю, это не так актуально, наверное, так как есть wsl.


  1. TheCalligrapher
    16.08.2022 23:01
    -1

    Для того, чтобы функция CustomFilter работала для обоих разрядностей (x86 и x64) можно воспользоваться директивами условной компиляции

    Совершенно не понятно, почему вы ассоциируете разницу в коде с разрядностью платформы. Разрядность платформы тут совершенно ни при чем, а все зависит от настроек кодогенератора и в первую очередь от настроек оптимизации. Зависимость от разрядности платформы - это не более чем паразитная/случайная зависимость. И судя по вашим экспериментам, вы выполняли их в отладочной конфигурации, что является весьма странной затеей.

    Это проблему такими директивами условной компиляции не решить. Или, точнее, это будет "решение", которое очень запросто "превратится в тыкву" в самый неожиданный момент.


    1. KernelCore Автор
      16.08.2022 23:35
      +2

      https://habr.com/ru/post/682958/#:~:text=Разрядность платформы тут совершенно ни при чем

      Не соглашусь. Например, поля структуры EXCEPTION_POINTERS будут иметь различные имена и смещения в x86 и x64.

      а все зависит от настроек кодогенератора и в первую очередь от настроек оптимизации

      Разумеется, создаваемый машинный код будет зависеть и от этого. В данной статье я привел пример с анализом машинного кода для конкретного описанного случая и соответствующей настройкой в обработчике исключения.

      Или, точнее, это будет "решение", которое очень запросто "превратится в тыкву" в самый неожиданный момент.

      Разумеется, при пересборке с другими параметрами компилятора, без анализа сгенерированного машинного кода данное решение работать не будет.


  1. fk0
    17.08.2022 10:55

    Остался не раскрыт вопрос, как оно всё работает на суперскалярном процессоре. Когда в момент сбоя проблемой инструкции успели выполниться пара других параллельно. Процессор должен как-то "откатить" результат, что представляется нетривиальной задачей.


    1. ihost
      17.08.2022 11:11
      +2

      Спекулятивное выполнение и теневые регистры же. Вас же не удивляет, что обе ветки if-else выполняются, а когда результат условия становится известен, выбирается только один путь. Спекуляций видимых за MMU/IOMMU видимо все-таки не существует по определению (Кроме хитрых lockless алгоритмов, но там другое - алгоритм умеет rollback-ить определенные жестко фиксированные случаи, которые сам же и спекулирует :)


      1. Alexey_Sharapov
        18.08.2022 01:46

        А точно обе ветки if else выполняются? Вроде, branch predictor выбирает брать переход или нет. Выполнение обеих веток обычно в VLIW процессорах делается.

        Могу ошибаться, поэтому прошу скинуть ссылку на материал, который докажет, что типичный x86-64 процессор выполняет обе ветки, а затем отбрасывает результат той ветки, которую брать не надо.