异常处理
本篇内容还不够深入,后续将会重新梳理一遍
0.异常处理机制介绍
异常处理机制通常有三个层次,即:
- 交给调试器(进程必须被调试)
- 执行VEH
- 执行SEH
VEH和SEH有几点区别
**从应用范围:**SEH可以在用户态代码中,也可以用在内核态代码中,但是VEH只能用在用户态代码中。
**从优先角度:**对于同时注册了SEH与VEH的代码所触发的异常,VEH比SEH先得到处理权。
从作用域: VEH对整个进程都有效,具有全局性。SEH是动态建立在所在函数函数栈上的,它只能处理自己线程的异常,会随函数返回而销毁。
这里我们仅考虑SEH异常处理机制。
当进程正常运行时,操作系统委托进程处理。若代码中存在异常处理代码就使用这段代码处理异常,程序继续运行。如果进程内部没有具体的异常处理代码,异常将无法被处理,操作系统会使用默认的异常处理机制终止进程运行;当程序在调试状态下运行时,操作系统会优先把异常交给调试器管理,此时调试器暂停运行,此时经常以如下的顺序进行处理:
(1).修改异常:发生异常时调试器暂停,可以通过调试器修改异常
(2).被调试程序处理:如果被调试程序内存在异常处理的函数,则异常会被发给被调试程序,由程序处理异常。
(3).操作系统处理:如果以上两种方式都不能处理异常,则由操作系统终止被调试进程,同时结束调试。
1.SEH详解
SEH相关数据结构
当一个线程出现错误时,系统给用户抛过来一个事件,为了响应这个事件,我们就需要定义一个回调函数(回调函数是说,把一段可执行的代码像参数传递那样传给其他代码,而这段代码会在某个时刻被调用执行,这就叫做回调。如果代码立即被执行就称为同步回调,如果在之后晚点的某个时间再执行,则称之为异步回调。在回调中,主程序把回调函数像参数一样传入库函数。这样一来,只要我们改变传进库函数的参数,就可以实现不同的功能),而操作系统会调用用户定义的回调函数。贴心的windows已经帮我们写好了这个函数,函数会返回一些与异常有关的信息。其内容如下:
EXCEPTION_DISPOSITION __cdecl _except_handler( _In_ struct _EXCEPTION_RECORD* _ExceptionRecord, _In_ void* _EstablisherFrame, _Inout_ struct _CONTEXT* _ContextRecord, _Inout_ void* _DispatcherContext );
第一个参数为一个结构体
typedef struct _EXCEPTION_RECORD { DWORD ExceptionCode; DWORD ExceptionFlags; struct _EXCEPTION_RECORD *ExceptionRecord; PVOID ExceptionAddress; DWORD NumberParameters; DWORD ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS]; } EXCEPTION_RECORD;
这个结构体当中的ExcepitonCode给出了一个异常的列表,一个非法访问异常(STATUS_ACCESS_VIOLATION)的代码为0xC000000
_except_handler函数的第二个参数是一个指向establisher帧结构的指针。
_except_handler回调函数的第三个参数是一个指向CONTEXT结构的指针,它代表某个特定线程的寄存器值。当用于SEH时,CONTEXT结构表示异常发生时寄存器的值。另外,这个CONTEXT结构也是GetThreadContext和SetThreadContext这两个API中使用 的那个CONTEXT结构。CONTEXT结构体用来备份CPU寄存器的值,是因为多线程环境下需要这样做。每个线程内部都拥有1个CONTEXT结构体。CPU暂时离开当前线程去运行其他线程时,CPU寄存器的值就会保存到当前线程的CONTEXT结构体;CPU再次运行该线程时,会使用保存在CONTEXT结构体的值来覆盖CPU寄存器的值,然后从之前暂停的代码处继续执行。通过这种方式,OS可以在多线程环境下安全运行各线程。
前面我们提到,SEH是动态建立在所在函数函数栈上的,它只能处理自己线程的异常,会随函数返回而销毁。这里存在着一个相关的数据结构:TIB结构,它保存线程的基本信息。与异常处理相关的是EXCEPTION_REGISTRATION_RECORD,结构当中有关异常处理的指针位于TIB的偏移0处。在基于Intel处理器的Win32平台上,FS寄存器总是指向当前的TIB。因此在FS:[0]处可以找到一个指向EXCEPTION_REGISTRATION_RECORD结构的指针。上述结构的形式如下
ntdll!_EXCEPTION_REGISTRATION_RECORD +0x000 Next : Ptr32 _EXCEPTION_REGISTRATION_RECORD +0x004 Handler : Ptr32 _EXCEPTION_DISPOSITION
联系起上述内容:当异常发生时,系统查找出错线程的TIB,获取一个指向EXCEPTION_REGISTRATION_RECORD结构的指针。在这个结构中有一个指向_except_handler回调函数的指针。
在代码中加入SEH
由于 fs:[0] 总是指向当前异常处理程序的链表头,当程序中需要安装一个新的 SEH 异常处理程序时,也可以填写一个新的 EXCEPTION RECISTRATION_RECORD 结构并将其插人该链表的头部。进而我们可以使用汇编语言给代码加入SEH
PUSH MyHandler;
PUSH DWORD PTR FS:[0];
MOV DWORD PTR FS:[0],ESP;
2.编译器的SEH
在c中使用__try、__except、__finally可以向代码中添加SEH。形式如下
__try { // 这里是被保护的代码 } __except (过滤器表达式) { // 这里是异常处理程序代码 }
简单地说,在一个函数中,一个__try块中的所有代码就通过创建在这个函数的堆栈帧上的一个EXCEPTION_REGISTRATION结构来保护。在函数的入口处,这个新的EXCEPTION_REGISTRATION结构被放在异常处理程序链表的头部。在__try块结束后,相应的 EXCEPTION_REGISTRATION结构从这个链表的头部被移除。正如前面所说,异常处理程序链表的头部被保存在FS:[0]处。当出现如下指令时,或许正在进入/退出一个__try或__except块。
MOV DWORD PTR FS:[00000000],ESP
上述内容中,过滤表达式的值有三种情况
#define EXCEPTION_EXECUTE_HANDLER 1 //系统将控制权转移给异常处理程序,并在找到处理程序的堆栈帧中继续执行。 #define EXCEPTION_CONTINUE_SEARCH 0 //寻找下一个异常处理函数 #define EXCEPTION_CONTINUE_EXECUTION (-1) //系统停止搜索处理程序并将控制返回到异常发生点。如果异常不可继续,则会导致EXCEPTION_NONCONTINUABLE_EXCEPTION异常。
3.展开(unwind)
我们假设一系列使用 SEH 的函数调用流程:
func1->func2->func3,假定在func3执行的过程中触发了异常,由func1完成处理。回想一下我们在学习系统实现版本的 SEH 时总结的分发软件异常流程:
RtlRaiseException->RtlDispatchException->RtlpExecuteHandlerForException,由于R tlDispatchException会遍历异常链表,对每个EXCEPTION_REGISTRATION都调用RtlpExecuteHandlerForException,而RtlpExecuteHandlerForException会调用EXCEPTION_REGISTRATION:handler,后者在 MSC 中被实现为_exception_handler4。又,如上分析,该函数内部遍历EH4_EXCEPTION_REGISTRATION:scopetable,如果遇到有scopetable_entry:FilterFunc返回EXCEPTION_EXECUTE_HANDLER,那么scopetable_entry:HandlerFunc就会被调用,来处理该异常。因为
HandlerFunc不会返回到_except_handler4(_EH4_TransferToHandler没有ret/retn指令),于是执行完HandlerFunc后,就会从HandlerFunc之后的代码继续执行下去。也就是说,假设func3中触发了一个异常,该异常被func1中的__except处理块处理了,那__except处理块执行完毕后,就从其后的指令继续执行下去,即异常处理完毕后,接着执行的就是func1的代码,不会再回到func2或者func3。问题来了,func2和func3中占用的资源怎么办?这些资源(比如申请的内存)是不会自动释放的,那岂不是会有资源泄漏问题?
前面我们提到异常处理程序链表的头部被保存在FS:[0]。由于针对某个特定异常的处理程序可能不在这个链表的开头,因此就需要从链表中依次移除实际处理异常的那个异常处理程序之前的所有异常处理程序。
展开是由__global_unwind2这个运行时库(RTL)函数来完成的。这个函数只是对RtlUnwind这个API进行了非常简单的封装。(现在这个API已经被公开了,但给出的信息极其简单,详细信息可以参考最新的Platform SDK文档。)
void WINAPI RtlUnwind( _In_opt_ PVOID TargetFrame, //目标SEH节点地址,输入则为局部展开;NULL为退出展开 _In_opt_ PVOID TargetIp, //局部展开后EIP值 _In_opt_ PEXCEPTION_RECORD ExceptionRecord, //附加的ExceptionRecord记录,外部调用一般不需要 _In_ PVOID ReturnValue //返回值,留空即可 );
RtlUnwind是一个KERNEL32函数,但它只是转发到了NTDLL.DLL中的同名函数上。如下是别人分析出的伪代码
void _RtlUnwind( PEXCEPTION_REGISTRATION pRegistrationFrame, PVOID returnAddr, // 并未使用!(至少是在i386机器上) PEXCEPTION_RECORD pExcptRec, DWORD _eax_value) { DWORD stackUserBase; DWORD stackUserTop; PEXCEPTION_RECORD pExcptRec; EXCEPTION_RECORD exceptRec; CONTEXT context; // 从FS:[4]和FS:[8]处获取堆栈的界限 RtlpGetStackLimits( &stackUserBase, &stackUserTop ); if ( 0 == pExcptRec ) // 正常情况 { pExcptRec = &excptRec; pExcptRec->ExceptionFlags = 0; pExcptRec->ExceptionCode = STATUS_UNWIND; pExcptRec->ExceptionRecord = 0; pExcptRec->ExceptionAddress = [ebp+4]; // RtlpGetReturnAddress()—获取返回地址 pExcptRec->ExceptionInformation[0] = 0; } if ( pRegistrationFrame ) pExcptRec->ExceptionFlags |= EXCEPTION_UNWINDING; else // 这两个标志合起来被定义为EXCEPTION_UNWIND_CONTEXT pExcptRec->ExceptionFlags|=(EXCEPTION_UNWINDING|EXCEPTION_EXIT_UNWIND); context.ContextFlags =( CONTEXT_i486 | CONTEXT_CONTROL | CONTEXT_INTEGER | CONTEXT_SEGMENTS); RtlpCaptureContext( &context ); context.Esp += 0x10; context.Eax = _eax_value; PEXCEPTION_REGISTRATION pExcptRegHead; pExcptRegHead = RtlpGetRegistrationHead(); // 返回FS:[0]的值 // 开始遍历EXCEPTION_REGISTRATION结构链表 while ( -1 != pExcptRegHead ) { EXCEPTION_RECORD excptRec2; if ( pExcptRegHead == pRegistrationFrame ) { NtContinue( &context, 0 ); } else { // 如果存在某个异常帧在堆栈上的位置比异常链表的头部还低 // 说明一定出现了错误 if ( pRegistrationFrame && (pRegistrationFrame <= pExcptRegHead) ) { // 生成一个异常 excptRec2.ExceptionRecord = pExcptRec; excptRec2.NumberParameters = 0; excptRec2.ExceptionCode = STATUS_INVALID_UNWIND_TARGET; excptRec2.ExceptionFlags = EXCEPTION_NONCONTINUABLE; RtlRaiseException( &exceptRec2 ); } } PVOID pStack = pExcptRegHead + 8; // 8 = sizeof(EXCEPTION_REGISTRATION) // 确保pExcptRegHead在堆栈范围内,并且是4的倍数 if ( (stackUserBase <= pExcptRegHead ) && (stackUserTop >= pStack ) && (0 == (pExcptRegHead & 3)) ) { DWORD pNewRegistHead; DWORD retValue; retValue = RtlpExecutehandlerForUnwind(pExcptRec, pExcptRegHead, &context, &pNewRegistHead, pExceptRegHead->handler ); if ( retValue != DISPOSITION_CONTINUE_SEARCH ) { if ( retValue != DISPOSITION_COLLIDED_UNWIND ) { excptRec2.ExceptionRecord = pExcptRec; excptRec2.NumberParameters = 0; excptRec2.ExceptionCode = STATUS_INVALID_DISPOSITION; excptRec2.ExceptionFlags = EXCEPTION_NONCONTINUABLE; RtlRaiseException( &excptRec2 ); } else pExcptRegHead = pNewRegistHead; } PEXCEPTION_REGISTRATION pCurrExcptReg = pExcptRegHead; pExcptRegHead = pExcptRegHead->prev; RtlpUnlinkHandler( pCurrExcptReg ); } else // 堆栈已经被破坏!生成一个异常 { excptRec2.ExceptionRecord = pExcptRec; excptRec2.NumberParameters = 0; excptRec2.ExceptionCode = STATUS_BAD_STACK; excptRec2.ExceptionFlags = EXCEPTION_NONCONTINUABLE; RtlRaiseException( &excptRec2 ); } } // 如果执行到这里,说明已经到了EXCEPTION_REGISTRATION // 结构链表的末尾,正常情况下不应该发生这种情况。 //(因为正常情况下异常应该被处理,这样就不会到链表末尾) if ( -1 == pRegistrationFrame ) NtContinue( &context, 0 ); else NtRaiseException( pExcptRec, &context, 0 ); }
展开的工作流程如下:
RtlUnwind在堆栈上创建了一个空的EXCEPTION_RECORD结构并把STATUS_UNWIND赋给它的ExceptionCode域,同时把 EXCEPTION_UNWINDING标志赋给它的ExceptionFlags域。指向这个结构的指针作为其中一个参数被传递给每个异常回调函数。然后,这个函数调用RtlCaptureContext函数来创建一个空的CONTEXT结构,这个结构也变成了在展开阶段调用每个异常回调函数时传递给它们的一个参数。
RtlUnwind函数的其余部分遍历EXCEPTION_REGISTRATION结构链表。对于其中的每个帧,它都调用 RtlpExecuteHandlerForUnwind函数。这个函数带EXCEPTION_UNWINDING标志调用了异常处理回调函数。每次回调之后,它调用RtlpUnlinkHandler移除相应的异常帧。
在异常处理的过程中,每个异常会遍历异常链表两次:
在RtlDispatchException中,通过遍历寻找处理异常的_EXCEPTION_REGISTRATION_RECORD,遍历的同时会调用 RtlpExecuteHandlerForException 运行异常处理程序
在展开过程中,遍历是为了对每个遍历到的 EXCEPTION_REGISTRATION_RECORD 进行展开,遍历的同时会调用 RtlpExecuteHandlerForUnwind 运行异常处理程序。
moectf:unwind
int __cdecl main_0(int argc, const char **argv, const char **envp) { char v4; // [esp+0h] [ebp-100h] __CheckForDebuggerJustMyCode(&unk_41C063); sub_4113E3(); puts("Welcome to moectf2023!!! Now you find YunZh1Jun's revenge!!!"); puts("Do you know TEA(an encryption algorithm)? Do you know unwind in SEH? "); puts("I believe you can understand them! So let me check your flag~"); sub_4110CD("Input:", v4); sub_4113CA("%64s", (char)&byte_41A578); MEMORY[0] = 0; sub_4110FF(); puts("Right flag! Have fun in moectf2023~"); return 0; }
在主函数中可以发现程序故意触发了异常

在汇编内容中可以发现main函数实际就是一个__except_handler4,在随后的汇编中

text:0041590F 处触发了一个读写非法地址异常,往下紧接着就是 __except filter,它直接返回了 1,也就是 EXCEPTION_EXECUTE_HANDLER,filter的返回值为1,后续将会进行全局展开,也就是接着调用 RtlUnwind,RtlUnwind 又会起一个 EH4_EXCEPTION_REGISTRATION->Handler,也就是我们这里的 __except 块。except 块调用了 4 次 sub_41136B,这个函数是一个未经魔改的tea。
int __cdecl sub_415700(unsigned int *a1, _DWORD *a2) { int i; // [esp+D0h] [ebp-68h] unsigned int v4; // [esp+118h] [ebp-20h] unsigned int v5; // [esp+124h] [ebp-14h] int v6; // [esp+130h] [ebp-8h] __CheckForDebuggerJustMyCode(&unk_41C063); v6 = 0; v5 = *a1; v4 = a1[1]; for ( i = 0; i < 32; ++i ) { v6 -= 1640531527; v5 += (a2[1] + (v4 >> 5)) ^ (v6 + v4) ^ (*a2 + 16 * v4); v4 += (a2[3] + (v5 >> 5)) ^ (v6 + v5) ^ (a2[2] + 16 * v5); } *a1 = v5; a1[1] = v4; return ++dword_41A5BC; }
回到主函数,在第一个程序被处理后

这里调用了一个函数,跟进去看看

这里有一个int3断点,触发调试异常。在我们退回到上一层之前,回顾一下前面提到的注册异常处理的方法
PUSH MyHandler; PUSH DWORD PTR FS:[0]; MOV DWORD PTR FS:[0],ESP;
在int3断点前面
.text:00411842 89 45 FC mov [ebp+var_4], eax .text:00411845 C7 45 F4 FD 12 41 00 mov [ebp+var_C], offset loc_4112FD .text:0041184C FF 75 F4 push [ebp+var_C] .text:0041184F 64 FF 35 00 00 00 00 push large dword ptr fs:0 .text:00411856 64 89 25 00 00 00 00 mov large fs:0, esp .text:0041185D CC int 3 ; Trap to Debugger
这是一个熟悉的形式,出题人在此插入了一个异常链表的结点。这个节点里的 loc_4112FD 函数第一次被 RtlDispatchException 调用,紧接着在栈展开时又被调用一次。

但是这个新的 EXECEPTION_REGISTRATION 结构并不处理异常,loc_415995 处的 except 块,这个块的工作是逐个检查输入值是否正确。

通过分析,我们知道用于对比的flag存于byte_41A000中。将密文分为两份,前一半解密一次,后一半解密两次即可得到flag。
#include <stdio.h> #include <stdint.h> unsigned char key1[16] = { "DX3906" }; unsigned char key2[16] = { "doctor3" }; unsigned char key3[16] = { "FUX1AOYUN" }; unsigned char key4[16] = { "R3verier" }; void decrypt (uint32_t* v, uint32_t* k) { uint32_t v0 = v[0], v1 = v[1]; uint32_t delta = 0x9e3779b9; uint32_t sum = delta * 32; uint32_t k0 = k[0], k1 = k[1], k2 = k[2], k3 = k[3]; for (int i=0; i<32; i++) { v1 -= ((v0<<4) + k2) ^ (v0 + sum) ^ ((v0>>5) + k3); v0 -= ((v1<<4) + k0) ^ (v1 + sum) ^ ((v1>>5) + k1); sum -= delta; } v[0]=v0; v[1]=v1; } int main() { unsigned char enc[]={0x5a,0xe3,0x6b,0xe4,0x6,0x87,0x2,0x4f,0x43,0xdf,0xcd,0xc1,0x77,0x98,0x6b,0xdb,0x8f,0x38,0x43,0x99,0xe3,0x93,0x22,0xb5,0x23,0xfd,0xb0,0x1c,0xe5,0xe3,0xee,0xce,0x2f,0x1d,0xad,0x2b,0xa4,0x15,0x98,0xf9,0xd8,0xeb,0x25,0xfa,0x6b,0x21,0xb7,0x72,0xb9,0x3,0x33,0x2e,0xd9,0x4c,0xeb,0x7b,0xf5,0xa7,0x48,0xf9,0x90,0x9d,0x38,0xfc,0}; decrypt((uint32_t*)enc, (uint32_t*)key1); decrypt((uint32_t*)enc+2, (uint32_t*)key2); decrypt((uint32_t*)enc+4, (uint32_t*)key3); decrypt((uint32_t*)enc+6, (uint32_t*)key4); decrypt((uint32_t*)enc+8, (uint32_t*)key1); decrypt((uint32_t*)enc+10, (uint32_t*)key2); decrypt((uint32_t*)enc+12, (uint32_t*)key3); decrypt((uint32_t*)enc+14, (uint32_t*)key4); decrypt((uint32_t*)enc+8, (uint32_t*)key1); decrypt((uint32_t*)enc+10, (uint32_t*)key2); decrypt((uint32_t*)enc+12, (uint32_t*)key3); decrypt((uint32_t*)enc+14, (uint32_t*)key4); printf("%s",enc); return 0; }
参考内容:
Linkblog.littflower.top/posts/moectf2023-unwind-and-a-little-windows-seh