Гайд Принцип работы и обработки исключений ОС Windows

websocket

Новичок
Автор темы
8
15
Всем привет, в этой статье я расскажу о том, как Windows обрабатывает исключения на уровне ядра. Эта тема необходима для тех, кто собирается заниматься низкоуровневой разработкой.

Содержание статьи:

1. Основные компоненты
2. KiDispatchException
3. SEH в ядре
4. Практическое использование обработчиков исключений
5. Вывод

Основные компоненты:

Ключевые структуры

Прежде чем разбирать конкретные компоненты, необходимо понять, какие структуры данных и механизмы участвуют в обработке исключений в ядре. Ниже приведены упрощённые определения - оригинальные имена полей соответствуют WDK.

EXCEPTION_RECORD - главная структура, где содержится информация об исключении:
С++:
typedef struct _EXCEPTION_RECORD {
    NTSTATUS  ExceptionCode;            // Код исключения
    ULONG     ExceptionFlags;           // Флаги
    struct _EXCEPTION_RECORD *ExceptionRecord;
    PVOID     ExceptionAddress;         // Адрес инструкции, вызвавшей исключение
    ULONG     NumberParameters;         // Количество параметров
    ULONGLONG ExceptionInformation[ EXCEPTION_MAXIMUM_PARAMETERS ];
} EXCEPTION_RECORD, *PEXCEPTION_RECORD;
- ExceptionCode - определяет тип исключения, например: STATUS_ACCESS_VIOLATION, STATUS_BREAKPOINT и так далее
- ExceptionAddress - адрес инструкции, где произошло исключение

CONTEXT - также немаловажная структура, где хранится полное состояние регистров на момент исключения.
C++:
typedef struct _CONTEXT {
   ULONGLONG Rax;
   ULONGLONG Rcx;
   ULONGLONG Rdx;
   ULONGLONG Rbx;
   ULONGLONG Rsp;
   ULONGLONG Rbp;
   ULONGLONG Rsi;
   ULONGLONG Rdi;
   ULONGLONG R8;
   ULONGLONG R9;
   ULONGLONG R10;
   ULONGLONG R11;
   ULONGLONG R12;
   ULONGLONG R13;
   ULONGLONG R14;
   ULONGLONG R15;
   ULONGLONG Rip;

   // Дальше идут XMM регистры, флаги, DR
   // ...
} CONTEXT, *PCONTEXT;
Обработчик исключения может модифицировать CONTEXT, например, изменить Rip, чтобы перенаправить выполнение. Чтобы изменение вступило в силу, обработчик должен вернуть EXCEPTION_CONTINUE_EXECUTION - тогда ядро восстановит регистры из модифицированной структуры.

KTRAP_FRAME - структура, содержащая текущее состояние во время переключения из юзермод в ядро ( например, через прерывание, syscall, исключение ):
C++:
struct _KTRAP_FRAME
{   // ...

    CHAR      PreviousMode;
    ULONGLONG Rax;
    ULONGLONG Rcx;
    ULONGLONG Rdx;
    ULONGLONG R8;
    ULONGLONG R9;
    ULONGLONG R10;
    ULONGLONG R11;

    // ...

    struct _M128A Xmm0;
    struct _M128A Xmm1;
    struct _M128A Xmm2;
    struct _M128A Xmm3;
    struct _M128A Xmm4;
    struct _M128A Xmm5;

    // ...

    ULONGLONG Dr0;
    ULONGLONG Dr1;
    ULONGLONG Dr2;
    ULONGLONG Dr3;
    ULONGLONG Dr6;
    ULONGLONG Dr7;

    // ...

    ULONGLONG TrapFrame;
    ULONGLONG Rbx;
    ULONGLONG Rdi;
    ULONGLONG Rsi;
    ULONGLONG Rbp;

    union
    {
        ULONGLONG ErrorCode;
        ULONGLONG ExceptionFrame;
        ULONGLONG TimeStampKlog;
    };

    ULONGLONG Rip;
};
IDT ( Interrupt Descriptor Table )

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

- #PF ( Page Fault, вектор 14 ) - KiPageFault
- #GP ( General Protection, вектор 13 ) - KiGeneralProtectionFault
- #UD (I nvalid Opcode, вектор 6 ) - KiInvalidOpcodeFault
- #BP ( Breakpoint, вектор 3 ) - KiBreakpointTrap
- #DB ( Debug, вектор 1 ) - KiDebugTrapOrFault

Каждый из этих обработчиков сохраняет состояние в KTRAP_FRAME, выполняет первичный анализ исключения и передаёт управление в KiDispatchException.

KiDispatchException

KiDispatchException - это центральный диспетчер исключений ядра Windows. Любые исключения так или иначе проходят через неё. Прототип этой функции:
C++:
void KiDispatchException(
    PEXCEPTION_RECORD  ExceptionRecord,
    PKEXCEPTION_FRAME  ExceptionFrame,
    PKTRAP_FRAME       TrapFrame,
    KPROCESSOR_MODE    PreviousMode,
    BOOLEAN            FirstChance
);
FirstChance определяет, первая ли это попытка обработать исключение. Если обработчики не справились, функция вызывается повторно с FirstChance = FALSE.

Логика работы KiDispatchException зависит от значения PreviousMode ( 0 = KernelMode, 1 = UserMode, получение его происходит из KTRAP_FRAME ).

Алгоритм при PreviousMode == KernelMode =>

1. Происходит исключение в ядре
2. Восстановление контекста из KTRAP_FRAME
3. First chance - отладчик: вызывается KdTrap. Если подключен отладчик ядра ( KdDebuggerEnabled == TRUE ), вызывается KdpTrap, иначе - KdpStub. Если отладчик обработал исключение - восстанавливается контекст и происходит возврат.
4. First chance - SEH: если отладчик не обработал исключение, вызывается RtlDispatchException. Эта функция ищет зарегистрированные обработчики исключений ( SEH ) и вызывает их. Каждый обработчик возвращает значение типа EXCEPTION_DISPOSITION:
- ExceptionContinueExecution - обработчик справился, выполнение продолжается с места исключения
- ExceptionContinueSearch - обработчик не справился, поиск продолжается по стеку
- ExceptionNestedException - при обработке возникло вложенное исключение, система пытается обработать его отдельно

Сама RtlDispatchException возвращает BOOLEAN: TRUE - исключение обработано, FALSE - не обработано.

5. Second chance - отладчик: если RtlDispatchException вернула FALSE, система повторно вызывает KdTrap ( уже с FirstChance = FALSE ), давая отладчику последний шанс обработать исключение.
6. BSOD: если и second chance через отладчик не помог, вызывается KeBugCheckEx с кодом KERNEL_MODE_EXCEPTION_NOT_HANDLED.

Алгоритм при PreviousMode == UserMode =>

1. Происходит исключение в юзермоде
2. Восстановление контекста из KTRAP_FRAME
3. First chance - отладчик ядра: исключение передаётся в KdTrap. Если отладчик ядра обработал - возврат и продолжение выполнения.
4. First chance - юзермодный отладчик: если отладчик ядра не обработал, исключение передаётся через DbgkForwardException юзермодному отладчику (если он подключен). Успех - возврат и продолжение выполнения.
5. Передача в юзермод: если все вышеперечисленные шаги закончились неудачно, система копирует EXCEPTION_RECORD и CONTEXT в стек юзермода, устанавливает Rip на ntdll!KiUserExceptionDispatcher и возвращает управление в юзермод. Там RtlDispatchException ищет обработчики (SEH/VEH) в юзермоде.
6. Second chance: если юзермодная обработка тоже не справилась, вызывается NtRaiseException, исключение передаётся через DbgkForwardException (уже на second chance). Если и это не помогло - процесс завершается.


1785057386147.png


SEH в ядре

SEH ( Structured Exception Handling ) - это механизм ( __try, __except и так далее ), который позволяет коду перехватывать исключения, не допуская BSOD.

На x64 информация об обработчиках хранится не в стеке ( как на x86 ), а в специальных таблицах в секции приложения. Ключевые структуры:

- RUNTIME_FUNCTION - запись в секции .pdata, описывающая диапазон адресов функции ( BeginAddress, EndAddress ) и ссылку на UNWIND_INFO
- UNWIND_INFO - содержит информацию для раскрутки стека и указатель на exception handler
- SCOPE_TABLE - таблица, описывающая блоки __try/__except внутри функции: диапазоны адресов и соответствующие обработчики

Поиск обработчика происходит через RtlDispatchException с таким алгоритмом:

1. Получение Rip из контекста
2. Поиск записи RUNTIME_FUNCTION в .pdata для функции, которая содержит этот Rip ( BeginAddress <= Rip && EndAddress > Rip )
3. Через RUNTIME_FUNCTION находится UNWIND_INFO. Если у функции есть exception handler - он вызывается. Обработчик может вернуть следующие значения:
- EXCEPTION_EXECUTE_HANDLER ( 1 ) - выполнение блока __except
- EXCEPTION_CONTINUE_SEARCH ( 0 ) - поиск дальше по стеку
- EXCEPTION_CONTINUE_EXECUTION ( -1 ) - продолжить выполнять код с места исключения
4. Если обработчик не найден - происходит раскрутка стека ( RtlUnwindEx ) на один фрейм и повторяется попытка поиска обработчика для вызывающей функции. Если весь стек пройден и исключение не было обработано, RtlDispatchException возвращает FALSE и цепочка продолжает идти к BSOD.

Практическое использование обработчиков исключений:

Обработчики исключений применяются практически везде, например:

1. Использование SEH для безопасного чтения памяти:
C++:
NTSTATUS read_buffer( void* address, void* buffer, size_t size ) {
    __try {
        RtlCopyMemory( buffer, address, size );
    }
    __except ( EXCEPTION_EXECUTE_HANDLER ) {
        return GetExceptionCode( );
    }

    return STATUS_SUCCESS;
}

2. Перехват различных типов исключений для выполнения своей логики перед обработкой, например, перехват MmAccessFault для обработки #PF. Об этом более подробно расскажу в следующей статье.

Вывод

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