-
-
[原创]浅析64位Windows的SEH机制
-
发表于: 2小时前 76
-
浅析64位Windows的SEH机制
笔者花费了一些时间,学习了64位WindowsSEH机制的底层原理。现写为博客,希望对大家有帮助。
注意:本文是从逆向工程的角度分析64位Windows的SEH机制,阅读本文的前提是能够从程序员(开发)的角度理解SEH机制。
如果读者从未了解过SEH机制,请先阅读以下文章:
要使SEH发挥作用,必须得到编译器、硬件和操作系统的配合支持。SEH 在特定平台上的具体实现方式可能因架构而异。
下图总结了Windows异常处理机制的基本框架(摘自《加密与解密》):

示例程序
这里先给出一个基本的示例程序,便于我们讨论:
// 示例程序 test.c
#include <windows.h>
#include <stdio.h>
int main() {
__try {
printf("__try block\n");
}
__except (EXCEPTION_EXECUTE_HANDLER) {
printf("__except block\n");
}
return 0;
}
RUNTIME_FUNCTION结构体
64位中的SEH机制不再把异常处理下相关信息放到栈上,而是由编译器生成相关信息,并在放到PE文件中一个特定的节区中:.pdata。在64位SEH机制中需要学习的第一个结构就是RUNTIME_FUNCTION。若干RUNTIME_FUNCTION结构体组成一个数组,放在.pdata节区中。该结构体定义如下:
typedef struct _RUNTIME_FUNCTION {
DWORD BeginAddress; // Start RVA of SEH code chunk
DWORD EndAddress; // End RVA of SEH code chunk
DWORD UnwindData; // Rva of an UNWIND_INFO structure that describes this code frame
} RUNTIME_FUNCTION, *PRUNTIME_FUNCTION;
该结构体包含三个字段:
- BeginAddress:函数的起始地址
- EndAddress:函数的结束地址
- UnwindData:指向UNWIND_INFO结构体的指针
通过PE工具,我们可以查看到该结构体数组。例如,对于上面的程序,使用cff程序查看异常目录:

这个结构体的数量和__try块的数量无关,而与包含__try块的函数有关(不管函数中有多少个__try块)。并且可以看到,RUNTIME_FUNCTION在.pdata中的排序方式是以BeginAddress为准的升序排序。
为什么这样排列?这是为了支持 O(log n) 时间复杂度的二分查找算法。当异常发生时,内核异常分发器(RtlLookupFunctionEntry)需要通过异常地址 RIP快速定位所属函数。二分查找是唯一能在对数时间内完成此任务的高效算法,而二分查找的前提条件就是数据集有序
注意,上面的三个地址都是相对于ImageBase的偏移量。所以尽管是在64位系统上,这三个地址也只有32位。
我们使用IDA来查看main函数对应的RUNTIME_FUNCTION条目。通过快捷键g就可以快速定位到main函数的RUNTIME_FUNCTION:

.pdata:000000014000400C dd rva main ; FunctionStart
.pdata:0000000140004010 dd rva byte_140001096 ; FunctionEnd
.pdata:0000000140004014 dd rva stru_140002804 ; UnwindInfo
可以将前两个字段的值放到放到反汇编窗口中查看,就是main函数的起始和结束地址。
接下来说说UnwindData指向的UNWIND_INFO结构体。
UNWIND_INFO结构体
这个结构体十分关键,其中的内容是SEH的核心。UNWIND_INFO结构体定义如下:
// Unwind info flags
#define UNW_FLAG_NHANDLER 0x0
#define UNW_FLAG_EHANDLER 0x01
#define UNW_FLAG_UHANDLER 0x02
#define UNW_FLAG_CHAININFO 0x04
// UNWIND_CODE 3 bytes structure
typedef union _UNWIND_CODE {
struct {
UBYTE CodeOffset;
UBYTE UnwindOp : 4;
UBYTE OpInfo : 4;
};
USHORT FrameOffset;
} UNWIND_CODE, *PUNWIND_CODE;
typedef struct _UNWIND_INFO {
UBYTE Version : 3; // + 0x00 - Unwind info structure version
UBYTE Flags : 5; // + 0x00 - Flags (see above)
UBYTE SizeOfProlog; // + 0x01
UBYTE CountOfCodes; // + 0x02 - Count of unwind codes
UBYTE FrameRegister : 4; // + 0x03
UBYTE FrameOffset : 4; // + 0x03
UNWIND_CODE UnwindCode[1]; // + 0x04 - Unwind code array
UNWIND_CODE MoreUnwindCode[((CountOfCodes + 1) & ~1) - 1];
union {
OPTIONAL ULONG ExceptionHandler; // Exception handler routine
OPTIONAL ULONG FunctionEntry;
};
OPTIONAL ULONG ExceptionData[]; // C++ Scope table structure
} UNWIND_INFO, *PUNWIND_INFO;
UNWIND_INFO结构体定义较为复杂,我们只关注其中的关键部分。
Flags字段
根据Flags的不同,UNWIND_INFO结构体的匿名联合体字段有不同的解释,ExceptionData字段是否有效也于Flags字段有关。
下面的UNWIND_INFO结构体的定义更好的展示了Flags字段的影响:
#define UNW_FLAG_NHANDLER 0x0
#define UNW_FLAG_EHANDLER 0x1
#define UNW_FLAG_UHANDLER 0x2
#define UNW_FLAG_CHAININFO 0x4
typedef struct _UNWIND_INFO {
UBYTE Version : 3;
UBYTE Flags : 5;
UBYTE SizeOfProlog;
UBYTE CountOfCodes;
UBYTE FrameRegister : 4;
UBYTE FrameOffset : 4;
UNWIND_CODE UnwindCode[1];
union {
//
// If (Flags & UNW_FLAG_EHANDLER)
//
OPTIONAL ULONG ExceptionHandler;
//
// Else if (Flags & UNW_FLAG_CHAININFO)
//
OPTIONAL ULONG FunctionEntry;
};
//
// If (Flags & UNW_FLAG_EHANDLER)
//
OPTIONAL ULONG ExceptionData[];
} UNWIND_INFO, *PUNWIND_INFO;
**为了快速讲清楚重点,这里我们只讨论Flags设置为UNW_FLAG_EHANDLER的情况。**一个典型的Flags被设置为UNW_FLAG_EHANDLER的函数如下:
void ExceptFunc() { __try {} __except(1) {} }
当Flags设置为UNW_FLAG_EHANDLER时,此时union中的ExceptionHandler(编程语言相关)字段有效,由编译器负责填写。如果发生异常且指令指针为>= BeginAddress和< EndAddress,就调用该ExceptionHandler。
ExceptionHandler会解析ExceptionData字段来确定如何处理发生的异常。
C_SCOPE_TABLE结构体
SCOPE译为范围,能力
ExceptionData是指向C_SCOPE_TABLE结构体的指针的偏移量(也就是指向C_SCOPE_TABLE的指针),该结构体定义如下(务必仔细阅读):
// C Scope table entry
typedef struct _C_SCOPE_TABLE_ENTRY {
ULONG Begin; // +0x00 - Begin of guarded code block,__try块第一条指令的偏移量
ULONG End; // +0x04 - End of target code block,__try块内最后一条指令之后指令的偏移量
ULONG Handler; // +0x08 - Exception filter function (or “__finally” handler)
ULONG Target; // +0x0C - Exception handler pointer (the code inside __except block)
} C_SCOPE_TABLE_ENTRY, *PC_SCOPE_TABLE_ENTRY;
// C Scope table
typedef struct _C_SCOPE_TABLE {
ULONG NumEntries; // +0x00 - Number of entries
C_SCOPE_TABLE_ENTRY Table[1]; // +0x04 - Scope table array
} C_SCOPE_TABLE, *PC_SCOPE_TABLE;
RUNTIME_FUNCTION描述了包含SEH的函数的整个范围,而SCOPE_TABLE描述了函数内每个单独的__try/__except块。
整体展示
VOID FrobThePointer(PUCHAR UserAddress)
{
__try
{
*UserAddress = 0;
*UserAddress = 1;
}
__except (EXCEPTION_EXECUTE_HANDLER)
{
DbgPrint("Bad Address\n");
}
}
以上面的代码为例,生成的汇编代码大致如下:
<00> mov [rsp+0x8],rcx
<05> sub rsp,0x28
<09> mov rax,[rsp+0x30] // Move UserAddress into RAX
<0e> mov byte ptr [rax],0x0 // *UserAddress = 0;
<11> mov rax,[rsp+0x30] // Move UserAddress into RAX
<16> mov byte ptr [rax],0x1 // *UserAddress = 1;
<19> jmp FrobThePointer+0x28 // Success!
<1b> lea rcx,"Bad Address\n" // Begin of code in except block...
// prepare to DbgPrint
<22> call DbgPrint
<27> nop
<28> add rsp,0x28
<2c> ret
套到RUMTIME_FUNCTION和UNWIND_INFO结构体如下:

定位到异常位置的流程(以除0异常为例)
到现在为止,我们可以总结一下x64的SEH机制如何定位到发生异常的地方:假设发生异常的地址为EA,先通过二分查找在RUNTIME_FUNCTION(比较规则:BeginAddress < EA < EndAddress)定位到发生异常的函数,然后遍历ExceptionData指向的C_SCOPE_TABLE_ENTRY数组,定位到发生异常的try块(比较规则:Begin < EA < End)。
这是查找的基本流程,但是我们可以再深入一些。
每当发生异常时,就会调用内部 Windows 函数 RtlDispatchException。该函数在用户模式异常的 NTDLL 模块中实现,在内核模式异常的 NTOSKRNL 模块中实现,方式略有不同。该函数通过执行一些初始检查来开始执行:如果存在用户模式VEH,则将调用该异常处理程序;否则将进行标准 SEH 处理。
标准SEH处理:复制异常时的线程上下文,并利用RtlLookupFunctionEntry函数来执行一项重要任务:获取PE文件的ImageBase和RUNTIME_FUNCTION结构。
现在,我们来追踪一个 64 位 Windows 程序发生除零异常 (#DE) 后,从 CPU 陷阱到最终执行 __except 块的完整、详细的系统级调用链和数据结构操作。这将深入到内核和运行时库的内部。
第一阶段:硬件陷阱与内核接管
1. 触发异常
; 在用户态执行
mov eax, 1
cdq
idiv dword ptr [rcx] ; 假设 [rcx]=0,触发 #DE
2. CPU 硬件操作
- CPU 检测到除零,中断号 0 (
#DE) 被触发。 - CPU 自动切换到内核模式(通过预设的 TSS 或 MSR 加载
GS基址)。 - CPU 将关键的用户态上下文压入当前线程的内核栈,形成一个
KTRAP_FRAME结构。这包括:RIP:指向idiv的下一条指令。RSP:用户态栈指针。RFLAGS、CS、SS等段寄存器。- 通用寄存器
RAX、RCX、RDX等。
- CPU 根据 IDT(中断描述符表) 的条目 0,跳转到预设的中断处理程序入口。在 Windows 中,这是
KiDivideErrorFault(或类似的陷阱处理程序)。
3. 内核陷阱处理 (KiDivideErrorFault)
// 伪代码,位于 ntoskrnl.exe
VOID KiDivideErrorFault() {
// 1. 建立更完整的陷阱帧
KTRAP_FRAME* pTrapFrame = GetCurrentTrapFrame();
// 2. 将异常信息打包为 EXCEPTION_RECORD
EXCEPTION_RECORD ExceptionRecord = {0};
ExceptionRecord.ExceptionCode = STATUS_INTEGER_DIVIDE_BY_ZERO; // 0xC0000094
ExceptionRecord.ExceptionAddress = pTrapFrame->Rip;
ExceptionRecord.NumberParameters = 0;
// 3. 调用公共的异常分发例程
KiExceptionDispatch(&ExceptionRecord, pTrapFrame);
}
第二阶段:内核异常分发 (KiExceptionDispatch / KiDispatchException)
这是最核心的调度器。其内部逻辑复杂,但关键步骤如下:
1. 构建 CONTEXT 和 EXCEPTION_POINTERS
// 在 KiDispatchException 内部
CONTEXT ContextRecord = {0};
// 从 KTRAP_FRAME 填充 ContextRecord 的所有寄存器
RtlpCaptureContext(&ContextRecord, pTrapFrame);
EXCEPTION_POINTERS ExceptionPointers = {0};
ExceptionPointers.ExceptionRecord = &ExceptionRecord;
ExceptionPointers.ContextRecord = &ContextRecord;
2. 首次尝试:用户模式异常分发内核检查异常地址,发现 RIP 在用户空间。于是调用 RtlDispatchException 函数(这是用户态异常分发的内核入口)。
BOOLEAN RtlDispatchException(PEXCEPTION_RECORD pExceptionRecord, PCONTEXT pContextRecord) {
// 【关键点1】检查 VEH(向量化异常处理程序)
if (RtlCallVectoredExceptionHandlers(pExceptionRecord, pContextRecord)) {
return TRUE; // VEH 处理了异常
}
// 【关键点2】定位 RUNTIME_FUNCTION
PRUNTIME_FUNCTION pRuntimeFunction = RtlLookupFunctionEntry(
pContextRecord->Rip, // 控制点
&ImageBase, // 输出:模块基址
NULL // 历史表
);
if (pRuntimeFunction == NULL) {
// 没有函数表,通常是 JIT 代码,无法展开
return FALSE;
}
// 【关键点3】获取 UNWIND_INFO
PUNWIND_INFO pUnwindInfo = (PUNWIND_INFO)(ImageBase + pRuntimeFunction->UnwindInfoAddress);
// 【关键点4】调用语言特定的异常处理程序(例如 __C_specific_handler)
if (pUnwindInfo->Flags & (UNW_FLAG_EHANDLER | UNW_FLAG_UHANDLER)) {
// 计算 ExceptionData 地址
PVOID pHandlerData = RtlpGetHandlerData(pUnwindInfo);
// 这个函数是 MSVC 运行时提供的,负责解析 SCOPE_RECORD
EXCEPTION_DISPOSITION disposition = __C_specific_handler(
pExceptionRecord,
(PVOID)pContextRecord->Rip,
pContextRecord,
pHandlerData
);
if (disposition == ExceptionExecuteHandler) {
// 找到了处理器,准备展开
return TRUE;
}
}
// 【关键点5】如果没有处理器,尝试展开一帧(用于 C++ 异常传播)
RtlUnwindEx(...);
return FALSE;
}
3. RtlLookupFunctionEntry 的查找过程这个函数是第一阶段查找的具体实现。
PRUNTIME_FUNCTION RtlLookupFunctionEntry(
IN ULONG64 ControlPc,
OUT PULONG64 ImageBase,
IN OUT PT_RUNTIME_FUNCTION* HistoryTable
) {
// 1. 通过 ControlPc 找到所属的 PE 模块 (DLL/EXE)
PLDR_DATA_TABLE_ENTRY pModule = LdrFindEntryForAddress(ControlPc);
*ImageBase = pModule->DllBase;
// 2. 从 PE 头定位 .pdata 节
PIMAGE_NT_HEADERS pNtHeaders = (PIMAGE_NT_HEADERS)(*ImageBase + ((PIMAGE_DOS_HEADER)*ImageBase)->e_lfanew);
PIMAGE_SECTION_HEADER pSection = IMAGE_FIRST_SECTION(pNtHeaders);
for (WORD i = 0; i < pNtHeaders->FileHeader.NumberOfSections; i++, pSection++) {
if (memcmp(pSection->Name, ".pdata", 6) == 0) {
break;
}
}
// 3. 计算 RUNTIME_FUNCTION 数组的起始和结束
PRUNTIME_FUNCTION pFunctionTable = (PRUNTIME_FUNCTION)(*ImageBase + pSection->VirtualAddress);
ULONG NumberOfFunctions = pSection->Misc.VirtualSize / sizeof(RUNTIME_FUNCTION);
// 4. 【核心】二分查找
return RtlpBinarySearch(pFunctionTable, ControlPc - *ImageBase, NumberOfFunctions);
}
4. __C_specific_handler 的工作(用户态回调!)这是 MSVC 运行时库 (vcruntimexxx.dll) 提供的函数。注意:此时 CPU 仍在内核模式,但该函数是用户态代码,内核会临时切换到用户态执行它。
EXCEPTION_DISPOSITION __C_specific_handler(
PEXCEPTION_RECORD pExceptionRecord,
PVOID EstablisherFrame, // 实际上是发生异常的函数栈帧指针
PCONTEXT pContextRecord,
PEXCEPTION_REGISTRATION_RECORD pDispatcherContext
) {
// 1. 从 DispatcherContext 中提取 SCOPE_RECORD 数组
PSCOPE_RECORD pScopeRecords = (PSCOPE_RECORD)pDispatcherContext;
DWORD scopeCount = pScopeRecords->Count;
pScopeRecords++; // 指向第一个 SCOPE_RECORD
// 2. 计算异常在函数内的偏移
DWORD_PTR ImageBase = ...;
PRUNTIME_FUNCTION pRF = ...;
DWORD offsetInFunction = (DWORD)(pContextRecord->Rip - ImageBase - pRF->BeginAddress);
// 3. 线性遍历 SCOPE_RECORD
for (DWORD i = 0; i < scopeCount; i++) {
if (offsetInFunction >= pScopeRecords[i].BeginAddress &&
offsetInFunction < pScopeRecords[i].EndAddress) {
// 4. 找到匹配的 try 块!调用过滤器
if (pScopeRecords[i].HandlerAddress) {
int filterResult = ((FILTER_FUNC)pScopeRecords[i].HandlerAddress)();
if (filterResult == EXCEPTION_EXECUTE_HANDLER) {
// 5. 设置要跳转的目标地址
pDispatcherContext->TargetIp = ImageBase + pRF->BeginAddress + pScopeRecords[i].JumpTarget;
return ExceptionExecuteHandler;
}
}
}
}
return ExceptionContinueSearch;
}
第三阶段:栈展开与最终派发
1. 内核收到 ExceptionExecuteHandler 后的操作内核的 KiDispatchException 收到 RtlDispatchException 返回 TRUE,知道找到了处理程序。
// 在 KiDispatchException 中
if (FirstPassSuccess) { // 即 RtlDispatchException 返回 TRUE
// 1. 获取目标地址(由 __C_specific_handler 设置)
ULONG64 TargetIp = GetTargetIpFromDispatcherContext();
// 2. 执行栈展开
RtlUnwindEx(
TargetFrame, // 展开到哪个帧
TargetIp, // 要跳转的目标地址
pExceptionRecord,
ReturnValue, // 返回值
pContextRecord,
HistoryTable
);
// 3. 修改陷阱帧中的 RIP
pTrapFrame->Rip = TargetIp;
// 4. 【关键】恢复执行
KiContinuePreviousMode(pContextRecord, pTrapFrame, PreviousMode);
}
2. RtlUnwindEx 的展开过程这是实际的栈帧展开器,它解释执行 UNWIND_CODE 数组。
VOID RtlUnwindEx(...) {
// 循环展开每一帧,直到到达目标帧
while (CurrentFrame < TargetFrame) {
// 对当前帧,查找其 RUNTIME_FUNCTION
PRUNTIME_FUNCTION pRF = RtlLookupFunctionEntry(CurrentRip, &ImageBase, NULL);
PUNWIND_INFO pUnwindInfo = (PUNWIND_INFO)(ImageBase + pRF->UnwindInfoAddress);
// 解释执行 UNWIND_CODE
for (int i = 0; i < pUnwindInfo->CountOfCodes; i++) {
switch (pUnwindInfo->UnwindCode[i].UnwindOp) {
case UWOP_PUSH_NONVOL:
// 模拟 pop 操作,恢复寄存器
Context->Rsp += 8;
Context->Rbx = *(PULONG64)(Context->Rsp - 8);
break;
case UWOP_ALLOC_SMALL:
Context->Rsp += (pUnwindInfo->UnwindCode[i].OpInfo * 8) + 8;
break;
// ... 其他展开操作码
}
}
// 如果有终止处理器(__finally),现在调用它
if (pUnwindInfo->Flags & UNW_FLAG_UHANDLER) {
CallTerminationHandler(pUnwindInfo, Context);
}
// 移动到调用者帧
CurrentRip = *(PULONG64)Context->Rsp;
Context->Rsp += 8;
}
}
第四阶段:返回用户态,执行处理块
1. 最终返回 (KiContinuePreviousMode)内核将修改后的 CONTEXT 写回陷阱帧,然后执行 iretq(或 sysret)指令,返回到用户态。但返回的 RIP 不是原来的 idiv 之后,而是 __except 块的地址。
2. 用户态恢复执行
; 这是用户态,但 RIP 已指向 __except 块
; 假设 __except 块地址是 0x140001050
0x140001050: mov rcx, str_inside_except
0x140001057: call printf
; ... 异常处理代码
关键数据结构和调用链总结:
| 阶段 | 关键函数 | 关键数据结构 | 操作 |
|---|---|---|---|
| 硬件陷阱 | KiDivideErrorFault |
KTRAP_FRAME |
保存用户上下文 |
| 内核分发 | KiDispatchException |
EXCEPTION_RECORD, CONTEXT |
构建异常信息 |
| 用户分发 | RtlDispatchException |
- | 协调分发流程 |
| 函数查找 | RtlLookupFunctionEntry |
RUNTIME_FUNCTION 数组 |
二分查找函数 |
| 展开信息 | - | UNWIND_INFO, UNWIND_CODE |
描述栈帧布局 |
| 作用域查找 | __C_specific_handler |
SCOPE_RECORD 数组 |
线性查找 try 块 |
| 栈展开 | RtlUnwindEx |
UNWIND_CODE |
解释执行展开指令 |
| 返回 | KiContinuePreviousMode |
KTRAP_FRAME |
恢复用户态上下文 |
调用链总结如下:
KiUserExceptionDispatcher // Windows Kernel Internal (KI) API
-> RtlDispatchException // main logic for exception handling
-> RtlpCallVectoredHandlers // call any VEH
-> RtlLookupFunctionEntry // look for valid PRUNTIME_FUNCTION entry in ExceptionDirectory
-> RtlpLookupDynamicFunctionEntry // if no valid PRUNTIME_FUNCTION, run any dynamic callbacks
-> RtlVirtualUnwind / RtlpxVirtualUnwind // perform stack frame unwinding
-> RtlpExecuteHandlerForException // execute exception handler!
上面的代码涉及到了较多的结构体,大家可以参考钱松林老师的《C++反汇编与逆向分析技术揭秘》一书。
UNWIND_CODE结构体
在UNWIND_INFO结构体中有一个UnwindCode字段,指向UNWIND_CODE结构体。
UNWIND_CODE 结构体是 64 位 SEH 机制中的“展开脚本”或“逆操作指令集”。它不直接参与异常处理的决策(这是 SCOPE_RECORD 的工作),而是负责异常处理流程中的执行环节——即如何安全、正确地将栈帧恢复到函数调用前的状态。这是实现栈展开(Stack Unwind)的基石。
UNWIND_CODE 的核心角色:栈帧的“构建蓝图”与“拆卸手册”
你可以将函数调用过程想象成搭积木:
- 函数序言 (Prolog):是“搭积木”的过程。它在栈上分配空间、保存寄存器。
- 函数尾声 (Epilog):是“拆积木”的过程。它恢复寄存器、释放栈空间,然后返回。
- 异常发生时:程序突然中断,积木搭到一半。
UNWIND_CODE就是一份紧急拆卸手册,告诉系统如何从这个“半成品”状态,安全地拆回到调用前的样子,而不管积木搭到了哪一步。
关键点:异常可能发生在函数序言之后、尾声之前的任何位置。系统必须能够从任意点将栈恢复到函数入口之前的状态。UNWIND_CODE 提供了完成此操作所需的精确、逐步的逆操作指令。
UNWIND_CODE 结构体详解
定义位于 winnt.h:
typedef union _UNWIND_CODE {
struct {
UBYTE CodeOffset; // 距离序言开始的偏移量(以字节为单位)
UBYTE UnwindOp:4; // 展开操作码
UBYTE OpInfo:4; // 操作码附加信息
};
USHORT FrameOffset; // 当 UnwindOp 为某些值时,整个USHORT表示帧偏移
} UNWIND_CODE, *PUNWIND_CODE;
字段含义:
CodeOffset:这是最关键、最精妙的设计。 它表示本展开操作应该在函数序言的哪个“进度点”之后执行。系统通过比较异常地址(RIP)与函数起始地址的偏移量,来决定执行UNWIND_CODE数组中的哪些指令。这确保了无论异常发生在序言后、函数体中还是尾声前,栈都能被正确恢复。UnwindOp:展开操作码。定义了具体的恢复操作(如pop寄存器、增加RSP等)。OpInfo:操作信息,通常是寄存器编号或大小参数。
UNWIND_CODE 在异常处理流程中的具体工作
让我们结合一个具体例子,跟踪流程。假设有以下函数:
// 对应的汇编序言
MyFunc:
push rbx ; 保存非易失寄存器
push rsi
sub rsp, 0x20 ; 分配局部变量空间
... ; 函数体
编译器会为它生成以下逻辑的 UNWIND_CODE 数组(伪代码表示):
UNWIND_CODE UnwindCode[] = {
{CodeOffset: 0x0, UnwindOp: UWOP_PUSH_NONVOL, OpInfo: RBX}, // 对应 push rbx
{CodeOffset: 0x1, UnwindOp: UWOP_PUSH_NONVOL, OpInfo: RSI}, // 对应 push rsi
{CodeOffset: 0x2, UnwindOp: UWOP_ALLOC_SMALL, OpInfo: 4} // 对应 sub rsp,0x20 (4*8=0x20)
};
注意:CodeOffset 是累加的。第一条对应序言第0字节(push rbx),第二条对应第1字节(push rsi),第三条对应第2字节(sub rsp,0x20)。
场景分析:异常发生在不同位置
场景1:异常发生在序言之后,函数体内部(例如在 sub rsp, 0x20 之后)
- 系统计算
RIP偏移,假设为0x05。 - 遍历
UNWIND_CODE数组,执行所有CodeOffset <= 0x05的指令(即全部三条):- 执行逆操作:
add rsp, 0x20(恢复栈分配) - 执行逆操作:模拟
pop rsi(从栈上恢复rsi的值到CONTEXT结构) - 执行逆操作:模拟
pop rbx(恢复rbx)
- 执行逆操作:
- 栈指针
RSP和寄存器RBX、RSI被恢复到进入函数时的状态。
场景2:异常发生在序言执行过程中(例如在 push rsi 之后,sub rsp,0x20 之前)
RIP偏移假设为0x01。- 只执行
CodeOffset <= 0x01的指令(前两条):- 执行逆操作:模拟
pop rsi - 执行逆操作:模拟
pop rbx
- 执行逆操作:模拟
- 注意:
UWOP_ALLOC_SMALL对应的操作(add rsp,0x20)不会被执行,因为对应的sub rsp,0x20指令还没执行!这就是CodeOffset机制的精髓:只撤销那些已经执行了的序言操作。
场景3:用于 __finally 的展开
当为 __finally 展开时,过程相同。UNWIND_CODE 确保栈被正确恢复,然后系统会跳转到 __finally 块执行清理代码,最后继续展开。
主要的 UnwindOp 操作码及其逆操作
| 操作码 | 含义 | 对应的序言指令示例 | 展开时的逆操作(由 RtlUnwindEx 执行) |
|---|---|---|---|
UWOP_PUSH_NONVOL |
压入非易失寄存器 | push rbx |
从栈上弹出值,并恢复 CONTEXT 中对应寄存器的值。RSP += 8。 |
UWOP_ALLOC_SMALL |
分配小栈空间 | sub rsp, 0x20 |
RSP += 分配大小。 |
UWOP_ALLOC_LARGE |
分配大栈空间 | sub rsp, 0x1000 |
RSP += 分配大小。 |
UWOP_SAVE_NONVOL |
保存非易失寄存器到栈上 | mov [rsp+0x10], rbx |
从栈上指定偏移处读取值,恢复 CONTEXT 中对应寄存器的值。 |
UWOP_SAVE_XMM128 |
保存 XMM 寄存器 | movaps [rsp+0x20], xmm6 |
恢复 CONTEXT 中的 XMM 寄存器。 |
UWOP_SET_FPREG |
建立帧指针 | mov rbp, rsp |
将 RBP 恢复为 CONTEXT 中保存的调用者值。 |
UWOP_PUSH_MACHFRAME |
压入机器帧(用于硬件中断) | 由硬件中断自动完成 | 特殊中断恢复。 |
重要提示:展开时的“pop”操作是逻辑上的。实际过程是:
- 从当前
RSP和CodeOffset计算原始值在栈上的位置。 - 将该值读入
CONTEXT结构中的对应寄存器字段。 - 更新
CONTEXT中的RSP值,模拟栈指针移动。
栈内存本身的内容不会被修改,只是 CONTEXT 被更新,为后续恢复执行做准备。