64 位结构化异常处理 (SEH) 在 ASM 中





5.00/5 (27投票s)
滚出一个实际解决方案
引言
Windows 操作系统提供了一个结构化异常处理 (SEH) 基础设施。
另一方面,一些高级语言提供了对它的内部支持,即运行时库,可以轻松处理操作系统实现。
当抛出异常时,会发生自动的展开,这意味着通过函数调用堆栈向后搜索,直到找到异常处理程序。
这个过程对开发者来说是完全透明的(而且内部工作原理也大多未公开),开发者通常只需要关注界定异常处理程序的范围。范围使用 __try/__except 或类似子句来定义。
在 C/C++ 或 .NET 中编程时,这对于开发者期望的操作来说是可以的,即插入 __try/__except 子句然后忽略它。
汇编语言的情况
ASM 程序员没有可用的运行时库来处理SEH。特别是对于MASM(我们从现在开始只关注MASM模型,尽管其他汇编器,尤其是下面提到的那些,情况大致相同),他们所能获得的是一组原始伪指令(也有一组宏,但几乎模仿了原始伪指令的功能),它们会在 PE Coff 的 .pdata 和 .xdata 段中创建展开信息(参见下面的参考文献 1)。
在描述原始伪指令的说明中提供了一个基本示例,但它隐含地假设 PROC(实际上是 PROCEDURE)将从 C/C++ 程序调用,否则将无法按预期工作。
构建我们自己的运行时
1. 挂钩回调函数
当发生异常时,操作系统会提供一个回调函数,在MASM中,其原型可以简单地声明为
_except_handler PROTO :PTR, :PTR, :PTR, :PTR
MASM 提供了一种挂钩此回调函数的方法,只需在 PROC 的 FRAME 属性后添加某个处理程序的名称即可。
在 C/C++ 中,编译器会在您建立 __try/__except 受保护块范围时在后台挂钩回调函数——但在MASM中,事情并非如此简单。也就是说,您还需要找到一种方法来在每个 PROC 中布局受保护块范围(这也有些困难,尽管可以像我一样自动化)。最终结果是必须在回调处理程序本身中找到正确的受保护块,因为它们是独立布局的(稍后将详细介绍,并在我们的源代码中进行说明)。
2. 理解回调函数
从其原型可以看出,回调函数提供了四个参数指针(其中三个指向结构)。
- 第一个参数是指向 EXCEPTION_RECORD结构的指针,该结构定义为EXCEPTION_RECORD STRUCT ExceptionCode DWORD ? ExceptionFlags DWORD ? ExceptionRecord LPVOID ? ExceptionAddress LPVOID ? NumberParameters DWORD ? ExceptionInformation QWORD EXCEPTION_MAXIMUM_PARAMETERS dup (?) EXCEPTION_RECORD ENDS此结构中有两个字段特别有趣,即 ExceptionCode和ExceptionAddress字段。
- 第二个参数是指向建立者帧的指针。尽管它可能对调试很重要(实际上在汇编中不如在 C/C++ 中),但我们不会使用它,因为我们的方法是尝试恢复执行而不是调试异常。
- 第三个参数是指向 CONTEXT结构的指针,该结构代表异常发生时线程的 CPU 寄存器值。这是一个很大的结构,无法在此处全部显示,但可在附带的源代码中找到。
- 第四个参数是指向 DISPATCHER_CONTEXT结构的指针,定义为DISPATCHER_CONTEXT STRUCT ControlPc QWORD ? ImageBase QWORD ? FunctionEntry LPVOID ? EstablisherFrame QWORD ? TargetIp QWORD ? ContextRecord LPVOID ? LanguageHandler LPVOID ? HandlerData LPVOID ? HistoryTable LPVOID ? ScopeIndex DWORD ? Fill0 DWORD ? DISPATCHER_CONTEXT ENDS我不会在这里详细介绍所有这些结构的细节,它们非常庞大且大多未公开。 
3. 回调函数的处理程序
微软将回调函数的处理程序指定为语言特定处理程序 (LSH)。虽然我更喜欢“异常处理程序”这个名称,但这个名称已经被用于展开过程结束后恢复执行的部分。
如前所述,LSH 接收大量信息,可以以多种方式处理。
我们的目标仅仅是(在可能的情况下)恢复程序的执行,因此我们将重点关注必要的内容并采取最可靠的行动。
从现在开始,我们的解释将基于我们自己的 MASM LSH 的工作方式,但请注意,存在其他替代方法,甚至可能更好,尽管我并不知晓。
因此,在进行一些准备工作后,LSH 首先要定位异常发生的位置。最佳选择是依赖 DISPATCHER_CONTEXT 结构中的 ControlPc 字段。它告诉我们异常发生在该 PROC 内的地址的 RIP 寄存器值,或者如果异常无法在该 PROC 中处理,则是它离开该 PROC 的地址。
然后它调用 RtlLookupFunctionEntry API,以获取与 ControlPc RIP 寄存器值对应的函数表中的一个条目。每当将某个 LSH 的名称添加到该 PROC 的 FRAME 属性时,就会将一个 PROC 添加到函数表中。如果 RtlLookupFunctionEntry 返回一个有效值(通常如此),我们将从中获得某个 PROC 的开始地址和结束地址。
有了这些信息,LSH 所要做的就是查找受保护块(如前所述,对于 MASM,需要手动建立)。
如果找不到受保护块,LSH 将返回一个值,告诉操作系统继续搜索。
如果找到受保护块,LSH 将调用 RtlUnwindEx API 来处理展开。此函数执行大量展开工作(参见下面的参考文献 3),否则手动执行将非常容易出错。
最后,请注意,对于每个异常,LSH 将被调用多次,主要用于满足 C/C++ 的清理需求。
我们的代码
defAsmSpecificHandler
我们的源代码提供了一个 LSH,我将其命名为 defAsmSpecificHandler。我可以为不同的 PROC 使用不同的 LSH,但我决定将所有内容集中到一个处理程序中。
defAsmSpecificHandler 执行上一章中提到的所有任务。此外,它还会收集一些可选信息,以便在 faulty program 中恢复执行时进行报告。请注意,如果 LSH 设计为线程安全,则不应以这种方式收集信息。这留作练习,我不想通过使事情复杂化来分散对重点的注意力。
这是 defAsmSpecificHandler
; ***This is a "catch-all" Language Specific Handler for all our PROCs***
defAsmSpecificHandler PROC USES rbx rsi rdi r12 r13 r14 r15 pExceptionRecord:PTR, pEstablisherFrame:PTR, pContextRecord:PTR, pDispatcherContext:PTR
    
    LOCAL imgBase : PTR
    LOCAL targetGp : PTR
    LOCAL BeginAddress : PTR
    LOCAL EndAddress : PTR
    LOCAL catchHandler : PTR
    mov pExceptionRecord, rcx
    mov pEstablisherFrame, rdx
    mov pContextRecord, r8
    mov pDispatcherContext, r9
    ; Copy Contexts as they unwind. This serves also for reporting purposes.
    mov rdi, OFFSET originalExceptContext
    mov rax, pDispatcherContext
    mov rsi, (DISPATCHER_CONTEXT ptr [rax]).ContextRecord
    mov rcx, SIZEOF CONTEXT / 8
    rep movsq
    mov rcx,pExceptionRecord  
    cmp DWORD PTR [rcx].EXCEPTION_RECORD.ExceptionFlags, EXCEPTION_NONCONTINUABLE
    jne @F
        ; Bail out
        mov rcx,0
        call ExitProcess
@@:    
    test DWORD PTR [rcx].EXCEPTION_RECORD.ExceptionFlags, EXCEPTION_UNWIND
    jnz @F 
        ; On first pass of each exception, save data structures
        ; pointed to by arguments, so we can report if wanted, 
        ; otherwise may be skipped. 
        sub rsp, 20h
        mov rcx, pExceptionRecord
        mov rdx, pContextRecord
        call saveContextAndExceptRecs
        add rsp, 20h
@@:
    ; De-nest Catch Blocks.
    cmp blocksDenested,0 ; Previously done?
    jne @F
        sub rsp, 20h
        call denestCatchBlocks
        add rsp, 20h
        mov blocksDenested, 1
@@:
    mov rcx,pExceptionRecord 
    test DWORD PTR [rcx].EXCEPTION_RECORD.ExceptionFlags, EXCEPTION_UNWIND
    mov eax, ExceptionContinueSearch
    jnz @exit
    
    ; Search for a valid IMAGE_RUNTIME_FUNCTION_ENTRY
    ; that corresponds to the RIP value of the exception
    ; or where it left the PROCEDURE.
    mov rax, pDispatcherContext
    mov rcx, (DISPATCHER_CONTEXT PTR [rax]).ControlPc
    lea rdx, imgBase
    lea r8, targetGp
    sub rsp, 20h
    call RtlLookupFunctionEntry
    add rsp, 20h
    cmp rax, 0 ; Is return value valid?
    jnz @F
        ; We shouldn't come here (even with leaf functions).
        mov ecx, 1
        call ExitProcess 
@@:    
    mov r13, imgBase
    mov r11d, (IMAGE_RUNTIME_FUNCTION_ENTRY PTR [rax]).BeginAddress
    add r11, r13
    mov BeginAddress, r11
    mov r11d, (IMAGE_RUNTIME_FUNCTION_ENTRY PTR [rax]).EndAddress
    add r11, r13
    mov EndAddress, r11
    ; Search for the (innermost) Catch Block in range (in case of nested blocks).
    mov rsi, dataexcpStart
    mov r11, pDispatcherContext
    mov r11, (DISPATCHER_CONTEXT ptr [r11]).ControlPc
    
    mov catchHandler,0 ; Zero it, in order to know in the end if we got something.
    mov r12, 7FFFFFFFh ; Enter a big enough number in r12.
@loopStart:
    cmp QWORD PTR [rsi], 204E4445h ; End of all Blocks?
    je @loopEnd
    cmp QWORD PTR [rsi], 544F4C53h ; New slot signature?
    jne @ifEnd
        mov r14, QWORD PTR [rsi].CATCHBLOCKS.try
        cmp r14, BeginAddress
        jb @ifEnd
        cmp r14, r11
        ja @ifEnd
            mov r13, QWORD PTR [rsi].CATCHBLOCKS.catch
            cmp r13, EndAddress
            ja @ifEnd
            cmp r13, r11
            jb @ifEnd
                mov rax, r13
                sub rax, r14
                cmp r12, rax
                jbe @ifEnd
                    ; Got one
                    mov r12, rax
                    mov catchHandler, r13
                    jmp @ifEnd
@ifEnd:            
    add rsi, SIZEOF CATCHBLOCKS
    jmp @loopStart
@loopEnd:
    
    cmp catchHandler, 0 
    jne @F
        ; No Catch Block, continue searching in parent procedures.
        mov eax, ExceptionContinueSearch
        jmp @exit
@@:    
    mov rcx, pEstablisherFrame
    mov rdx, catchHandler
    mov r8, pExceptionRecord
    mov LSHretValue, 66h ; Any value. Let's test it in Proc1.
    lea r9, LSHretValue 
    
    sub rsp, 30h
    mov rax, pDispatcherContext
      mov rax, [rax].DISPATCHER_CONTEXT.HistoryTable
      mov [rsp+28h], rax    
    lea rax, originalExceptContext 
    mov [rsp+20h], rax
    call RtlUnwindEx ; Must not return. If it returns there is an error.
    add rsp, 30h ; We don't expect to come here, but anyway.
    mov ecx, 1
    call ExitProcess 
@exit:
    ; The default epilog will restore non-volatile registers.    
    ret
defAsmSpecificHandler ENDP
受保护块是如何布局的?
我使用一组三个宏来定义受保护块(在代码中,它们由一个名为 CATCHBLOCKS 的结构定义),并使用我们自己的PE 段,称为 dataexcp,来存储有关它们的信息。
这是我们的PE 段的开头
dataexcp SEGMENT PARA ".data"
    blocksDenested QWORD 0
    dataexcpStart LABEL near
    QWORD 204E4445h ; Signature for END of all blocks
    ORG $-8 ; Overwrite END, if there are catch blocks
dataexcp ENDS
这是那三个宏
_ExceptionBlock TEXTEQU <0>
__TRY MACRO
    LOCAL tryPos, level
    
    tryPos EQU $
    level TEXTEQU @SizeStr(%_ExceptionBlock)
    level TEXTEQU %(level -1)
    _ExceptionBlock CATSTR _ExceptionBlock, level
    
    dataexcp SEGMENT
        QWORD 544F4C53h ;; Signature for new slot
        QWORD level
        QWORD tryPos
    dataexcp ENDS
ENDM
__EXCEPT MACRO
    LOCAL catchPos, level
    
    catchPos EQU $+5 ;; 5 is the size of the "jmp near" instruction
    level TEXTEQU @SizeStr(%_ExceptionBlock)
    level TEXTEQU %(level -2)
    
    dataexcp SEGMENT
        QWORD level    
        QWORD catchPos
    dataexcp ENDS
    .code
     %jmp near ptr @catch&_ExceptionBlock&_end
     %@catch&_ExceptionBlock&_start:
ENDM    
__FINALLY MACRO
    LOCAL endCatchPos, count, level, temp
    endCatchPos EQU $
    
    level TEXTEQU @SizeStr(%_ExceptionBlock)
    level TEXTEQU %(level -2)
    dataexcp SEGMENT
        QWORD level    
        QWORD endCatchPos
        QWORD 204E4445h ;; Signature for end of all blocks
        ORG $-8 ;; Ready to be overwriten, if more Blocks
    dataexcp ENDS
    count TEXTEQU @SizeStr(%_ExceptionBlock)
    count TEXTEQU %(count -1)
    .code
%@catch&_ExceptionBlock&_end:
    .data
    _ExceptionBlock TEXTEQU @SubStr(%_ExceptionBlock, 1, count)
    IF count EQ 1
        temp TEXTEQU %(_ExceptionBlock +1)
        BYTE temp
        IF temp EQ 9
         _ExceptionBlock TEXTEQU <0>
        ELSE 
            _ExceptionBlock TEXTEQU <temp>
        ENDIF 
    ENDIF    
    .code
ENDM
有了这些小巧的宏,我们在 PROC 中要做的就是插入这三个宏来界定一个受保护块,类似于这样
someProcedure PROC FRAME:defAsmSpecificHandler
    push rbp
    .pushreg rbp
    mov rbp, rsp
    .setframe rbp, 0    
    .endprolog
__TRY
    ; Guarded Section of code (level 0)
    _TRY
        ; Another Guarded Section of code (level 1)
    _EXCEPT
        ; Another Exception Handler (level 1)
    _FINALLY
__EXCEPT
    ; Exception Handler (level 0)
__FINALLY
    mov rsp, rbp
    pop rbp
    ret
someProcedure ENDP
非常简单,而且与 C/C++ 的语义非常相似。
执行的测试
我设计了一系列六个测试,几乎涵盖了 ASM 程序员在现实生活中遇到的所有情况。还有一些我没有处理的边缘情况。不过,如果任何读者认为它们相关并提供代码来说明他们的情况,我可能会更新文章以处理它们。

如前所述,defAsmSpecificHandler 不是线程安全的,但使其线程安全的更改很少。这留作练习。
测试的源代码在本文的附件中。
与 C/C++ 的兼容性
defAsmSpecificHandler 与 Visual Studio C 和 C++ **SEH** 机制兼容。如果异常无法在 ASM 模块中处理,它将被传递到 C/C++ 中,在那里可以被处理。
我在附件的一个示例中对此进行了说明,这是相关的 C/C++ 部分
#include <stdio.h>
#include <excpt.h>
#include <windows.h> // for EXCEPTION_ACCESS_VIOLATION
#ifdef __cplusplus
extern "C"
{
#endif
    void proc1();
    void proc2_0();
    void proc3_0();
    void proc4();
    void proc5();
    void proc6_0();
#ifdef __cplusplus
}
#endif
int filter(unsigned int code, struct _EXCEPTION_POINTERS *ep) {
#ifdef __cplusplus
    printf("***Exception 0x%x at 0x%.8llx trapped in main***\n", 
            code, reinterpret_cast<intptr_t>(ep->ExceptionRecord->ExceptionAddress));
#else
    printf("***Exception 0x%x at 0x%.8llx trapped in main***\n", 
            code, (unsigned __int64)(ep->ExceptionRecord->ExceptionAddress));
#endif
    return EXCEPTION_EXECUTE_HANDLER;
}
int main()
{
    __try
    {
        proc1();
        proc2_0();
        proc3_0();
        proc4();
        proc5();
        proc6_0();
    }
    __except(filter(GetExceptionCode(), GetExceptionInformation()))
    {
        printf("\nExecuting Handler\n");
    }
    
    return 0;
}
最终注释
- 虽然此 ASM 代码主要针对使用 **MASM** 进行汇编,但它也可以在不更改的情况下使用 **JWASM** 或 **UASM**(2.43 及更高版本)进行汇编。 **JWASM** 和 **UASM** 具有其他 **MASM** 不支持的功能,但本质上向后兼容 **MASM**。
- 该代码已在从 Windows XP-64bit 到 Windows 10 64bit 的所有操作系统上进行了测试。但是,对于像 Windows XP-64bit 这样老的操作系统,您将需要一个合适的链接器(严格来说,您甚至可以使用一个最近的链接器),您可以在历史悠久的 MASM32.COM 网站提供的 64 位资源中找到它。
参考文献
历史
- 2017 年 10 月 26 日:初始发布
- 2017 年 11 月 2 日:更新 - 测试集 (Proc1)、C/C++ 示例以及文本中的各种错别字已修复


