目录
Win异常体系分析(4):64位下SEH异常分析(PE)
目录
x64 SEH原理
- 相比于x86 SEH变动很大,
x64 SEH不再将异常信息保存在堆栈中,而是在编译时期,静态保存到PE文件中,所以也有些帖子称x64 SEH是基于PE文件的
IMAGE_RUNTIME_FUNCTION_ENTRY
- x64程序在编译时,会将几乎所有的过程函数信息,都保存到
.pdata(过程数据)节区中,这个.pdata节区中保存数据,可以认为是一个数组,其中的每一项都是IMAGE_RUNTIME_FUNCTION_ENTRY,该结构是文档化导出的,如下所示:
// x64程序,几乎每个函数都会生成一个如下的RuntimeFunction对象,并保存到pdata section
typedef struct _IMAGE_RUNTIME_FUNCTION_ENTRY
{
DWORD BeginAddress; // 该函数的起始RVA
DWORD EndAddress; // 该函数的结束RVA
union
{
DWORD UnwindInfoAddress; // 指向UnwindInfo的RVA
DWORD UnwindData;
} DUMMYUNIONNAME;
} _IMAGE_RUNTIME_FUNCTION_ENTRY, *_PIMAGE_RUNTIME_FUNCTION_ENTRY;IMAGE_RUNTIME_FUNCTION_ENTRY这个结构中包含3个DW类型的数据,是因为其保存的都是RVA,是相对于ImgBase的偏移,所以4字节足矣
简单例子
- 我们写一个简单例子,代码如下:
int main()
{
__try
{
printf("11111111111 \n");
}
__except (EXCEPTION_EXECUTE_HANDLER)
{
printf("2222222222 \n");
}
__try
{
printf("11111111111 \n");
}
__finally
{
printf("2222222222 \n");
}
}- 编译为x64程序,使用IDA反汇编如下所示:

- 此时查看
main函数所对应的RUNTIME_FUNCTION结构,IDA交叉引用:

.pdata:000000014001F818 80 1C 01 00 E0 1C 01 00 E4 CA 01 00
RUNTIME_FUNCTION <rva main, rva byte_140011CE0, rva stru_14001CAE4>- 此时可以发现,IDA还很智能地帮我们解析出了
RUNTIME_FUNCTION结构,对照原生字节集前8字节,的确指向了main函数的起始、终止地址的RVA
UNWIND_INFO展开信息
- 我们现在继续学习
RUNTIME_FUNCTION最后一个成员UnwindInfo,该成员指向的是一个UNWIND_INFO对象,该对象结构、部分字段成员解析如下: - 这里有一点需要专门说明:是由VS编译时(一般是MSVC编译器),那么
UNWIND_INFO.ExceptionHandler这个对象是由编译器来填充的,一般是ExceptionHandle = __C_specific_handler
// Unwind info flags
#define UNW_FLAG_NHANDLER 0x0 // 该函数体内部,没有使用SEH异常
#define UNW_FLAG_EHANDLER 0x01 // 该函数体内部,至少有一个try except代码块
#define UNW_FLAG_UHANDLER 0x02 // 该函数体内部,至少有一个try finally代码块
#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 见顶部宏
UBYTE SizeOfProlog; // + 0x01 序言代码字节数
UBYTE CountOfCodes; // + 0x02 - Count of unwind codes 需要展开的代码行数
UBYTE FrameRegister : 4; // + 0x03
// 当前基准栈帧使用哪个寄存器保存的?一般是5,即Rbp寄存器
UBYTE FrameOffset : 4; // + 0x03
// 表示偏移量的单位,即上一个栈帧的栈顶与Rbp的偏移
// 虚拟展开VirtualUnwind时,使用方法如下:
// 上一个栈帧栈顶Rsp = Rbp - FrameOffset * 0x10
UNWIND_CODE UnwindCode[1]; // + 0x04 - Unwind code array
UNWIND_CODE MoreUnwindCode[((CountOfCodes + 1) & ~1) - 1];
// 表示是一个变长数组
// 因为函数序言代码的行数是不确定的
// 这个联合体的解析方式是根据Flags决定的
union
{
OPTIONAL ULONG ExceptionHandler; // Exception handler routine
// Flags & UNW_FLAG_EHANDLER
// 如果是MSVC编译器,那么该函数是由编译器填充为__C_specific_handler
OPTIONAL ULONG FunctionEntry; // 这个用得少
// Flags & UNW_FLAG_CHAININFO
};
OPTIONAL ULONG ExceptionData[]; // C++ Scope table structure
// 泛型尾巴,SEH、C++异常时解释不一样
// 本文档仅讨论常规SEH异常
// 一般只有一项,保存指向C_SCOPE_TABLE的RVA
// C_SCOPE_TABLE对象解析在下文
} UNWIND_INFO, *PUNWIND_INFO;C_SCOPE_TABLE
- 目前已知
RUNTIME_FUNCTION中前2个成员可以描述一个函数的起始、终止地址 - 那么当一个函数内使用了
try代码块,我们是不是需要一个数据结构来描述每一个try代码块的起始、终止地址?C_SCOPE_TABLE就是被设计出来解决这个问题的,其结构、字段成员解析如下所示:
// C Scope table entry
typedef struct _C_SCOPE_TABLE_ENTRY
{
ULONG Begin; // +0x00 - Begin of guarded code block,
// __try块第一条指令的RVA
ULONG End; // +0x04 - End of target code block
// __try块内最后一条指令之后指令的RVA
ULONG Handler; // +0x08 - Exception filter function (or “__finally” handler)
// 异常过滤器(或__finally)地址RVA
ULONG Target; // +0x0C - Exception handler pointer (the code inside __except block)
// __except代码块地址RVA
// 当try finally组合时,Target = NULL
} C_SCOPE_TABLE_ENTRY, *PC_SCOPE_TABLE_ENTRY;
// C Scope table
typedef struct _C_SCOPE_TABLE
{
ULONG NumEntries; // +0x00 - Number of entries
// Table对象数组有多少个成员
C_SCOPE_TABLE_ENTRY Table[1]; // +0x04 - Scope table array
// 变长数组
} C_SCOPE_TABLE, *PC_SCOPE_TABLE;- 由于
C_SCOPE_TABLE对象中的Table为变长数组,那么一个函数中,不管是单个、多个、多重嵌套try都可以顺利描述了
无嵌套异常
- 一个函数体内部,平铺写了单个、多个
try代码块时,此时MSVC编译器处理就比较简单了,直接按照其代码顺序保存到C_SCOPE_TABLE.Table变长数组中
嵌套异常
- 当一个函数体内部,存在
try代码块嵌套时,此时就需要额外处理了 - 由于每个Scope保存的都是
当前try代码块的[Begin, End),假设存在一个3重嵌套try的代码块,当#3触发异常时,___C_specific_handler遍历时就会发现3个Scope中的范围都会命中,这时___C_specific_handler就懵了,该从哪一个Scope开始处理呢? - 按照我们的SEH常识,多重try嵌套,应该从最内层的try开始处理,所以编译器是这样处理的:按照try代码块从里到外的顺序将Scope放置到变长数组中,这样就解决了try嵌套的问题
如何区分try except / try finally
UNWIND_INFO.Flags = 3时,即UNW_FLAG_EHANDLER | UNW_FLAG_UHANDLER, 表示这个函数体内存在try except、try finally两种代码块,那么该如何细化标识是哪一种呢?- 这里区分的方式很简单,
C_SCOPE_TABLE_ENTRY.Target = NULL时,表示这是一个try finally
虚拟展开(VirtualUnwind)
- 什么是虚拟展开,举一个简单例子,伪代码如下所示:
- 在如下代码中,
func1中发生了异常,但是filter返回了ContinueSearch继续往外搜索,但是func1函数内已经没有try保护了,就需要往外搜索,此时就需要恢复上一层栈帧(即Caller栈帧),找到调用者Caller的Rip,通过Rip就能继续搜索RUNTIME_FUNCTION对象,重复异常处理流程
void func1()
{
__try
{
// 发生异常
}
__except(filter())
{
}
}
int main()
{
__try
{
func1();
}
__except(filter())
{
}
}展开流程(清理栈帧)
- 一些教程也将这个过程称为
清理栈帧,其实也合理,清理当前栈帧,将状态恢复到上一层的状态 - 这个过程需要用到
UNWIND_INFO这个对象中的部分字段成员,如下所示:
// UNWIND_CODE 3 bytes structure
typedef union _UNWIND_CODE
{
struct
{
UBYTE CodeOffset; // 展开代码在函数中的RVA
UBYTE UnwindOp : 4; // 展开操作码
UBYTE OpInfo : 4 // 展开信息
};
USHORT FrameOffset; // 原始代码为分配堆栈空间时用到,FrameOffset*8=分配堆栈空间大小
} UNWIND_CODE, *PUNWIND_CODE;
typedef struct _UNWIND_INFO
{
UBYTE Version : 3; // + 0x00 - Unwind info structure version
UBYTE Flags : 5; // + 0x00 - Flags 见顶部宏
UBYTE SizeOfProlog; // + 0x01 序言代码字节数
UBYTE CountOfCodes; // + 0x02 - Count of unwind codes 需要展开的代码行数
UBYTE FrameRegister : 4; // + 0x03
// 当前基准栈帧使用哪个寄存器保存的?一般是5,即Rbp寄存器
UBYTE FrameOffset : 4; // + 0x03
// 表示偏移量的单位,即上一个栈帧的栈顶与Rbp的偏移
// 虚拟展开VirtualUnwind时,使用方法如下:
// 上一个栈帧栈顶Rsp = Rbp - FrameOffset * 0x10
UNWIND_CODE UnwindCode[1]; // + 0x04 - Unwind code array
// 忽略.......
} UNWIND_INFO, *PUNWIND_INFO;- 在本文文初的例子中,
main函数的序言代码、以及对应的UNWIND_CODE如下所示(IDA解析出来了): - 如下所示的
UNWIND_CODE,细心观察就会发现,其顺序是反过来的,其实很好理解,跟push、pop保存现场时一样
.text:0000000140011C80 40 55 push rbp
.text:0000000140011C82 57 push rdi
.text:0000000140011C83 48 81 EC E8 00 00 00 sub rsp, 0E8h
.text:0000000140011C8A 48 8D 6C 24 20 lea rbp, [rsp+20h]
.rdata:000000014001CAE8 0F 23 UNWIND_CODE <<0Fh, 3, 2>> ; UWOP_SET_FPREG
.rdata:000000014001CAEA 0A 01 UNWIND_CODE <<0Ah, 1, 0>> ; UWOP_ALLOC_LARGE
.rdata:000000014001CAEC 1D 00 dw 1Dh
.rdata:000000014001CAEE 03 70 UNWIND_CODE <<3, 0, 7>> ; UWOP_PUSH_NONVOL
.rdata:000000014001CAF0 02 50 UNWIND_CODE <<2, 0, 5>> ; UWOP_PUSH_NONVOL- 我们根据上述的
UNWIND_CODE一行行解释:
lea rbp, [rsp+20h] <<---->> UNWIND_CODE <<0Fh, 3, 2>>
1. 该代码在函数内偏移为0xF
2. 操作码为3:对应MSDN手册UWOP_SET_FPREG,设置栈帧Rbp寄存器
3. 操作信息为2:表示偏移量,即2 * 16 = 0x20(x64下的堆栈是16字节对齐)
sub rsp, 0E8h <<---->> UNWIND_CODE <<0Ah, 1, 0>> \ dw 1Dh
这种代码比较特殊,分配堆栈空间,一行汇编对应2行展开代码
1. 该代码在函数内偏移为0xA
2. 操作码为1:UWOP_ALLOC_LARGE,表示分配大空间,第2行的0x1D * 8 = 0xE8计算出分配堆栈空间大小
3. 操作信息为0:说明分配空间大小的区间,具体区间分级看MSDN文档
push rdi <<---->> UNWIND_CODE <<3, 0, 7>>
1. 该代码在函数内偏移为0x3
2. 操作码为0:表示Push非易失寄存器
3. 操作信息为7:寄存器编号,如下图所示
完整、规范的展开操作码、展开信息解释得参考MSDN手册:x64 异常处理
有了以上文档解释,那么虚拟展开(清理堆栈)的方式就很清晰了,编译器代码根据
UNWIND_INFO.CountOfCodes遍历UNWIND_CODE UnwindCode[1]这个变长数组的内容,来模拟还原栈帧到调用者即可这里有一个很巧妙的点,假设我们的函数很长,在函数中、后部出现了异常,此时可能有很多push操作,Rsp这个栈顶位置可以说是不可控的,所有我们使用Rbp这个当前栈帧的锚定点,那么我们现在来手动模拟清理栈帧:
// main函数序言
.text:0000000140011C80 40 55 push rbp
.text:0000000140011C82 57 push rdi
.text:0000000140011C83 48 81 EC E8 00 00 00 sub rsp, 0E8h
.text:0000000140011C8A 48 8D 6C 24 20 lea rbp, [rsp+20h]
1. Rbp是当前栈帧锚定点
2. 还原0000000140011C8A:Rsp = Rbp - 0x20
3. 还原0000000140011C83:Rsp += 0xE8
4. 还原0000000140011C82:pop rdi
5. 还原0000000140011C80:pop Rbp
6. 至此,栈帧清理完成
7. 此时[Rsp + 0] = 返回地址Ripx64 SEH派发流程
- 前置条件:不考虑内核调试、R3调试、VEH等前置异常处理阶段
- 有了前面几节的内容铺垫,到了这里我们就可以顺着解释
x64 SEH异常的派发流程了 - 这里从
ntdll!RtlDispatchException开始,伪代码如下所示:
BOOLEAN RtlDispatchException (
PEXCEPTION_RECORD pExceptionRecord,
PCONTEXT pContextRecord)
{
// 先调用VEH
if (RtlCallVectoredExceptionHandlers(pExceptionRecord, pContextRecord))
{
return TRUE;
}
// 根据异常Rip找到RUNTIME_FUNCTION对象
PRUNTIME_FUNCTION pRuntimeFunction = RtlLookupFunctionEntry(
pContextRecord->Rip, // 控制点
&ImageBase, // 输出:模块基址
NULL // 历史表
);
// 没找到RUNTIME_FUNCTION对象对象
if (pRuntimeFunction == NULL) {
// 没有函数表,通常是 JIT 代码,无法展开
return FALSE;
}
// 获取UNWIND_INFO
PUNWIND_INFO pUnwindInfo =
(PUNWIND_INFO)(ImageBase + pRuntimeFunction->UnwindInfoAddress);
// 检查Flags,检查当前函数内有try except或者try finally代码块
if (pUnwindInfo->Flags & (UNW_FLAG_EHANDLER | UNW_FLAG_UHANDLER)) {
// 取出C_SCOPE_TABLE对象
PVOID pHandlerData = RtlpGetHandlerData(pUnwindInfo);
// 这个函数是MSVC运行时提供的,负责解析C_SCOPE_TABLE
EXCEPTION_DISPOSITION disposition = __C_specific_handler(
pExceptionRecord,
(PVOID)pContextRecord->Rip,
pContextRecord,
pHandlerData
);
if (disposition == ExceptionExecuteHandler) {
return TRUE;
}
}
// 走到这里了,说当前函数体内没有try保护,尝试虚拟展开堆栈,寻找上一层有没有try保护
RtlUnwindEx(...);
return FALSE;
}__C_specific_handler伪代码如下所示:
EXCEPTION_DISPOSITION __C_specific_handler (
PEXCEPTION_RECORD pExceptionRecord,
PVOID EstablisherFrame, // 实际上是发生异常的函数栈帧指针
PCONTEXT pContextRecord,
PEXCEPTION_REGISTRATION_RECORD pDispatcherContext
)
{
// 从C_SCOPE_TABLE中提取Table
PSCOPE_RECORD pScopeRecords = (PSCOPE_RECORD)pDispatcherContext;
DWORD scopeCount = pScopeRecords->Count;
pScopeRecords++; // 指向第一个 SCOPE_RECORD
// 计算异常在函数内的偏移
DWORD_PTR ImageBase = ...;
PRUNTIME_FUNCTION pRF = ...;
DWORD offsetInFunction = (DWORD)(pContextRecord->Rip - ImageBase - pRF->BeginAddress);
// 遍历所有Table
for (DWORD i = 0; i < scopeCount; i++)
{
if (offsetInFunction >= pScopeRecords[i].BeginAddress &&
offsetInFunction < pScopeRecords[i].EndAddress)
{
// 找到范围匹配的try块,调用Filter
if (pScopeRecords[i].HandlerAddress)
{
int filterResult = ((FILTER_FUNC)pScopeRecords[i].HandlerAddress)();
if (filterResult == EXCEPTION_EXECUTE_HANDLER)
{
// 设置要跳转的目标地址
pDispatcherContext->TargetIp =
ImageBase + pRF->BeginAddress + pScopeRecords[i].JumpTarget;
return ExceptionExecuteHandler;
}
}
}
}
return ExceptionContinueSearch;
}手动映射注入修复异常
为什么不支持SEH?
- x64:本质是因为反射式注入DLL,不存在正常的模块链表中的,在派发SEH异常时
RtlDispatchException函数会调用RtlLookupFunctionEntry就返回NULL,导致这个异常我们的Shellcode DLL压根没有处理的机会 - x86:32位下的SEH异常,明明都是代码手动注册、卸载的,为什么也不支持?是因为
RtlDispatchException–>RtlIsValidHandler–>RtlLookupFunctionTable中校验失败了,其伪代码如下:
BOOLEAN
RtlIsValidHandler (
IN PEXCEPTION_ROUTINE Handler
)
{
PULONG FunctionTable;
ULONG FunctionTableLength;
PVOID Base;
FunctionTable = RtlLookupFunctionTable(Handler, &Base, &FunctionTableLength);
if (FunctionTable && FunctionTableLength) {
PEXCEPTION_ROUTINE FunctionEntry;
LONG High, Middle, Low;
if ((FunctionTable == LongToPtr(-1)) && (FunctionTableLength == (ULONG)-1)) {
// Address is in an image that shouldn't have any handlers (like a resource only dll).
RtlInvalidHandlerDetected((PVOID)((ULONG)Handler+(ULONG)Base), LongToPtr(-1), -1);
return FALSE;
}
// Bias the handler value down by the image base and see if the result
// is in the table
(ULONG)Handler -= (ULONG)Base;
Low = 0;
High = FunctionTableLength;
while (High >= Low) {
Middle = (Low + High) >> 1;
FunctionEntry = (PEXCEPTION_ROUTINE)FunctionTable[Middle];
if (Handler < FunctionEntry) {
High = Middle - 1;
} else if (Handler > FunctionEntry) {
Low = Middle + 1;
} else {
// found it
return TRUE;
}
}
// Didn't find it
RtlInvalidHandlerDetected((PVOID)((ULONG)Handler+(ULONG)Base), FunctionTable, FunctionTableLength);
return FALSE;
}
// Can't verify
return TRUE;
}x64修复手法
- 已知通过反射式注入的DLL,是不支持SEH异常的,此时由2种解决思路:
- 相当于写Shellcode:纯C语言写,手动导入用到的API,不使用任何C_Runtime函数、C++ STL库,但是这种方法很麻烦,一是手动导入的API内部可能使用了SEH,二是啥库都不能用,开发、调试过程会极其痛苦
- 动态注册函数:因为
RtlLookupFunctionEntry首先会搜索所有PE,搜索不到,再搜索RtlpDynamicFunctionTableTree这个表中的FuncEntry,将我们的函数动态注册到RtlpDynamicFunctionTableTree中即可解决问题
- 更多的时候是优先方法2,修复异常的代码如下(这份代码同时包含32、64):
static VOID
InsertExceptionTable(PMEMORYMODULE module)
{
#if defined(_WIN64)
PIMAGE_DATA_DIRECTORY pDataTable = \
&module->headers->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXCEPTION];
PIMAGE_RUNTIME_FUNCTION_ENTRY pFuncTable = \
(PIMAGE_RUNTIME_FUNCTION_ENTRY)((ULONG_PTR)module->codeBase + pDataTable->VirtualAddress);
if (pfnRtlAddFunctionTable64 != NULL)
{
pfnRtlAddFunctionTable64(pFuncTable, pDataTable->Size / sizeof(IMAGE_RUNTIME_FUNCTION_ENTRY), (DWORD64)module->codeBase);
}
#else
if (dwMajorVersion == 6 && dwMinorVersion == 1) //WIM7
{
if (pfnRtlInsertInvertedFunctionTable_Win7 != NULL);
{
pfnRtlInsertInvertedFunctionTable_Win7(LdrpInvertedFunctionTable, module->codeBase, module->headers->OptionalHeader.SizeOfImage);
}
}
else if (dwMajorVersion == 6 && dwMinorVersion == 3) //WIN8
{
if (pfnRtlInsertInvertedFunctionTable_Win8_Win10 != NULL);
{
pfnRtlInsertInvertedFunctionTable_Win8_Win10(module->codeBase, module->headers->OptionalHeader.SizeOfImage);
}
}
else if (dwMajorVersion == 10 && dwMinorVersion == 0) //WIN10
{
if (pfnRtlInsertInvertedFunctionTable_Win8_Win10 != NULL);
{
pfnRtlInsertInvertedFunctionTable_Win8_Win10(module->codeBase, module->headers->OptionalHeader.SizeOfImage);
}
}
else
{
// not support
}
#endif
}- 规范、文档化的动态注册方式,微软提供了2个函数,如下所示(这2个函数似乎都是x64下使用的):
typedef
_Function_class_(GET_RUNTIME_FUNCTION_CALLBACK)
PRUNTIME_FUNCTION
GET_RUNTIME_FUNCTION_CALLBACK (
_In_ DWORD64 ControlPc,
_In_opt_ PVOID Context
);
typedef GET_RUNTIME_FUNCTION_CALLBACK *PGET_RUNTIME_FUNCTION_CALLBACK;
// 火哥修x64 SEH演示了这个,105课30分钟处
// 这个函数就是向系统注册一个回调,传入起始地址、长度
// 当这块区域出现异常,就回调你注册的函数
NTSYSAPI BOOLEAN RtlInstallFunctionTableCallback(
[in] DWORD64 TableIdentifier,
[in] DWORD64 BaseAddress,
[in] DWORD Length,
[in] PGET_RUNTIME_FUNCTION_CALLBACK Callback,
[in] PVOID Context,
[in] PCWSTR OutOfProcessCallbackDll
);
// 动态注册
NTSYSAPI BOOLEAN RtlAddFunctionTable(
[in] PRUNTIME_FUNCTION FunctionTable,
[in] DWORD EntryCount,
[in] DWORD64 BaseAddress
);RtlInstallFunctionTableCallback用法简答讲解:将某一段区域注册一个异常回调,一般就是传入我们自己Shellcode DLL的区域,当这个区域内出现异常,系统回调我们的处理函数,我们在处理函数中,自己模拟查找过程,返回一个PRUNTIME_FUNCTION
x86修复手法
- 既然是
RtlIsValidHandler中会调用RtlLookupFunctionTable导致我们校验失败,那么就将我们的代码注册到这个表中,让其校验通过,32位的Win7~Win10都是调用RtlInsertInvertedFunctionTable来插入,但是参数列表不一样