前言
本文参考 张银奎.《软件调试》一书,融入了自己的理解,以及将逻辑用伪代码形式表达
1. 中断和异常管理
前言:系统调用所提供的服务是在内核里。一般是在系统空间实现,而应用软件则都在用户空间运行。它们之间有着明确的间隔。实质是CPU运行模式的不同。
通过一条int 0x2e的指令可以让CPU陷入内核。
1.1 IDT、IDTR、PCR、PCRB等背景知识
IDT
: 中断描述符表寄存器
IDTR
:中断描述符表寄存器 ----- 寄存器的LIDT\SIDT操作必须是ring0下
PCR
:处理器控制区
PCRB
:处理器控制块IDT表的初始化过程。IDT的最初建立和初始化工作是windows加载程序(NTLDR\WinLoad)在实模式下完成的:
- 在准备好内存后,加载程序先执行CLI指令关闭中断处理,然后执行LIDT指令将IDT的位置和长度加载到CPU中。
- 加载程序将CPU从实模式切换到保护模式,并将执行权移交给NT内核入口函数
KiSystemStartup.
- 接下来,内核中的处理器初始化函数会通过SIDT指令取得IDT表的信息,对其进行必要的调整。
- 以参数形式传递给KiInitializePcr函数,后者将其记录到描述处理器的基本数据区PCR和PCRB
以上的过程发生在0号处理器中,也就是多为的BootstrpProcessor – BSP,即使是多CPU系统,在NTLDR或WinLoad即执行权移交给内核阶段都只有BSP运行,BSP会执行KestartupAllProcessors函数来初始化其他CPU。简称AP,对于每个Cpu KeStartupProcessors函数会为其建立单独的处理器状态去包括IDT表,然后调用KiInitProcessor函数,后者会根据BSP的IDT初始化AP的IDT并作相应的更改
1.2 门描述符背景知识
IDT 表每个表项都有一个所谓的门描述符(Gate Descriptor)结构。这样称呼的原因是引领CPU从一个空间到另一个空间的大门(Gate),CPU在此之前会做必要的安全检查和准备工作。
IDT表中可以包含以下三种门描述符(每个描述符8个字节):
任务门(task-gate):用于任务切换,里面包含用于选择任务状态段(TSS)的段选择子。可以使用 JMP 或 CALL 指令来通过任务们来切换任务,当CPU因为中断或异常要转移任务的时候,也会切换到指定的任务。
中断门(interrupt-gate):用于描述中断处理例程的入口。
陷阱门(trap-gate):用于描述异常处理例程的入口。
大多数中断和异常都是利用中断们或陷阱们来处理的:
- 首先,CPU会根据门描述符的段选择子定位到段描述符,
- 然后进行一系列检查,如果检查通过后,CPU就判断是否进行切换栈。如果目标代码的特权级比较高(数值小),那么CPU就需要切换栈,其方法就是从当前任务状态段TSS中读取新堆栈的段选择子
SS
和堆栈指针ESP
,并将其加载到SS和ESP中,- 然后,CPU会把中断过程的旧的SS指针和ESP指针压入新的堆栈 。
然后把EFLAGS、CS、EIP等压入栈
如果发生异常,那么把错误码也压入栈。
如果特权级别一样,就不需要进行堆栈的切换,但是任然需要前面中间的两步
- TR寄存器存放的是指向当前任务TSS段的段选择子,使用WinDBG可以观察TSS的内容
1.3 异常的描述和登记
为了更好的管理异常,windows系统定义了专门的数据结构来描述异常,并定义了一系列代码来标识典型的异常。
》 除了CPU产生异常,还能通过软件方式产生异常,比如:RaiseException而产生的异常和使用编程语言的throw关键字抛出的异常。
即: 异常包括了 — CPU 异常 和 软件异常
EXCEPTION_RECORD结构
windows 使用EXCEPTION_RECORD来描述异常:
typedef struct_ EXCEPTION_RECORD ( DWORD ExceptionCode;//异常代码 DWORD ExceptionFlags;//异常标志 struct_ EXCEPTION RECORD* ExceptionRecord;//相关的另一个异常 PVOID ExceptionAddress ;//异常发生地址,比如int 3 ;那就是 int 3的地址,而不是下一条; //对于硬件而言,根据异常类型不同可能是导致异常的那条指令的地址,获知 是导致异常指令的下一条地址 DWORD NumberParameters;//参數数组中的元素个数,即ExceptionInfomation数组中包含的有效草书个数,注: 最多允许 15个附加参数。 ULONG_ PTR Except ionInformation [EXCEPTION MAXIMUM PARAMETERS]; //参数数组 } EXCEPTION RECORD, * PEXCEPTION RPCORD;
- 其中ExceptionCode为异常代码,是一个32位的整数,其格式是Windows系统的状态代码格式,在在NtStatus.h中包含了已经定义的所有状态代码,WinBase.h中可以看到异常代码只是状态代码的别名,比如:
#define EXCBPTION BREAKPOINT STATUS_BREAKPOINT #define EXCEPTION_SINGLE STEP STATUS_SINGLE STEP
1.3.1 登记CPU异常
对于CPU异常,KitTrapXX例程在完成对本异常动作后,通常会调用**CommonDispatchException**函数。并通过如下途径将消息传递给这个函数:
- 将唯一标识该异常的一个异常代码(/异常状态码)放入EAX
- 将导致异常指令地址放入EBX寄存器
- 将其他信息作为附带参数(最多三个)分别放入EDX,ESI, EDI 寄存器,并将参数个数传入ECX寄存器中
注:
》 在commonDispatchException被调用后,它会在栈中分配一个EXCEPTION_RECORD结构,并把以上异常信息存储到该结构中。在准备好这个结构后,会调用KiDispatchException来分发异常。
1.3.2 登记软件异常
简单来说,软件异常是通过直接调用或间接调用内核服务 NtRaiseException而登记的。服务接口原型如下:
NTSTATUS NtRaiseException (IN PEXCEPTION RECORD Except ionRecord, IN PCONTEXT ContextRecord, IN BOOLEAN FirstChance )
用户模式下登记异常
- 用户模式下可以在程序中调用RaiseException()API,(MFC,throw 也是调用RaiseException,并且将ExceptionCode参数固定为0xe06d7373),来调用这个内核服务。RaiseException是KERNEL32.DLL导出的API,供程序产生“自定义”异常。API接口原型如下:
void RaiseException( DWORD dwExceptionCode,//异常代码,可以是已经定义的代码,也可以自定 DWORD dwExceptionFlags,// DWORD nNumberOfArguments,// const DWORD* lpArguments) ;//后面着两个参数是用来定义异常的常数,相当于:EXCEPTION RECORD结构中的ExceptionInformation和NumberParameters。
- 实际上RaiseException的实现非常简单:
内部只是将自身的几个参数放入一个EXCEPTION_RECORD后,
调用rtlRaiseException(),这个函数会将当前的执行上下文(通用寄存器)放入CONTEXT结构中,
然后通过NTDLL中的系统服务调用内核中的NtRaiseException
NtRaiseException 内部会调用另一个内核函数KiRaiseException:
NTSTATUS KiRaiseException (IN PEXCEPTION RECORD ExceptionRecord,//指向异常记录 IN PCONTEXT ContextRecord, //指向线程上下文CONTEXT IN PKEXCEPTION_FRAME ExceptionFrame,//x86总是NULL IN PKTRAP_FRAME TrapFrame,//栈帧基地址 IN BOOLEAN FirstChance )//表示是第一轮还是第二轮处理
内核模式下登记异常
- 内核中的代码可以通过RtlRaiseException来调用NtRaiseException 然后KiRaiseException。
总结:
不论是从用户模式调用RaiseException,还是从内核模式调用相应的函数,最后都会转到KiRaiseException。
用户/内核 --间接/直接调用 – KiRaiseException内部的具体细节:
- 调用KeContextToKframes 例程把ContextRecord结构中的信息复制到当前线程的内核栈中,
- 然后把ExceptionRecord的异常代码最高位清0,便于区分CPU异常和程序异常。
- 接下来 会调用 KiDispatchException开始分发该异常
1.4 异常分发过程
根据前面介绍:
当有异常发生时,CPU会通过IDT表找到异常处理函数,即内核中的KiTrapXX系列函数,然后转去执行。但是,KiTrapXX函数通常只是对异常作简单的表征和描述,为了支持调试和软件自己定义的异常处理函数,系统需要将异常分发给调试器或应用程序的处理函数。对于软件异常,Windows系统采用的策略是和CPU异常同意的方式分发和处理的,接下来是分发异常的核心函数KiDispatchException和它的工作过程。
1.4.1 KiDispatchException
windows 内核的KiDispatchException 函数是分发各种Windows异常的枢纽。其函数原型如下:
VOID KiDispatchException ( IN PEXCEPTION_RECORD ExceptionRecord,
IN PKEXCEPTION_FRAME ExceptionFrame,
IN PKTRAP_FRAME TrapFrame,//出发异常时候保存下来的
IN KPROCESSOR_MODE PreviousMode,
IN BOOLEAN FirstChance )
//注解:
//1. ExceptionRecord 是前面KeContextToKframes转换到本地那个ExceptionRecord,描述分发的异常
//2. ExceptionFrame 对于x86系统总是NULL。
//3. TrapFrame 指向的是 KTRAP_FRAME 结构,用来描述处理器状态包括上下文。
//4. PreviousMode 是枚举typedef enum MODE {KernelMode, UserMode, MaximumMode } MODE;也就是说,PreviousMode 等于0表示前一个模式(通常是出发异常代码的模式)是内核模式,1 表示用户模式。
//5. FirstChance 参数表示是否是第一轮(True 是第一次,False是第二次)分发这个异常。对于一个异常系统最多分发两轮(系统定义的)
KiDispatchException的基本过程
从图中可以看出,KiDispatchException 做的操作:
- 会先调用KeContextFromKframes函数从TrapFrame(比如kiTrap03中ENTRY_TRAP中开辟的空间填充的_KTRAP_FRAME)中提取数据建立CONTEXT。用来供调试器和异常处理函数报告异常使用
- 根据PreviousMode(段寄存器最低(两位)位就是表示的是是内核态(模式)还是用户态(模式)),KiDispatchException根据不同的当前模式,会有不同的流程
内核态的分发过程(过程都在KiDispatchException内部调用实现)
也就是PreviousMode是 KernelMode(0)的时候
if( FirstChance)//判断是第一轮,还是第二轮? FirstChance--True or False?
{
//第一次处理
if (KiDebugRoutine())//通知内核调试引擎交互的接口函数,KiDebugRoutine 指向的是内核调试迎请的 KdpTrap,这个函数会进一步把异常信息封装为数据包发送给内核调试器。//如果内核调试器引擎没有启动时,那么KiDebugRoutine指向的是KdpStup,这个实现很简单,做一些简单的失利后就返回FALSE
{
return handled; //内核引擎处理了的话,就停止分发,准备返回,handled代表处理了
}
else
{
//如果没有处理的话,就调用 RtlDispatchException();
RtlDispatchException();
//RtlDispatchException()会试图寻找已经注册的结构化异常处理器(内核SEH,和我们的SEH有区别吗?);
/*
1. 首先RtlDispatchException会调用RtGetRegistrationHead获得异常注册链表的首节点
2. RtlDispatchException会遍历异常登记链表,并记录每个异常处理器,如果有一个处理器返回了ExceptionContinueExecution,那么RtlDispatchException便会返回true,表示已经处理了该函数。
3. 如果RtlDispatchException返回False,也就是没有找到处理该异常的异常处理器。那么KiDispatchException会尝试给内核调试器第二轮处理机会。
*/
}
}
else
{
//第二次处理机会
if (KiDebugRoutine())
{
return handled;
}
else
{
//两次都没有处理,那么系统会认为这是一个严重错误,会调用 KeBugCheckEx引发蓝屏(BSOD),报告错误并终止系统运行,并且KeBugCheckEx的第一个参数被置为KMODE_EXCEPTION_NOT_HANDLED -- (0X1E) -- 代表未处理的内核异常。异常代码和一场地址会作为参数传给KeBugCheckEx,并显示在蓝屏界
}
}
用户态异常的分发过程()
即PreviousMode 为 UserMode(1),那么流程如下:
if(isNeedSendKerNel())//这里根据一些条件,比如是否是内核调试器出发的异常等来判断是否需要发送给内核调试器引擎,但内核调试器通常不处理用户态异常。直接返回不处理,所以大多数
{
if(KiDebugRoutine())
{
return handled//代表内核调试引擎处理了
}
}
else //这里是大多数情况执行的步骤
{
//如果时第一次处理
if(FirstChance)
{
//1. 尝试先将异常分发给用户态的调试器DbgkForwardException()
//Firstchance指明是第一次处理TRUE
if(DbgkForwardException(Exception_Record,Dg/异常port,FirstChance))
/*DbgkForwardException 内部:
//先判断DebugPort字段是否为空,如果不为空就调用,即:
if(DebugPort!=NULL)
{
//调用这个API和用户态体哦啊是其进行交互
if(DbgkpSendApiMessage()== STATUS_SUCCESS)
{
//如果返回的是成功 STATUS_SUCCESS,表示调试器处理了该异常
return DBG_CONTINUE;TRUE;
}
else
{
//返回不成功
return DBG_EXCEPTION_NOT_HANDLED;FALSE;
}
}
*/
{
return handled
}
//2. 试图寻找异常处理块
else
{
//因为异常在用户态代码中,异常处理快也应该咋用户态函数
//KiDispatchException会准备转回到用户态执行
//内核变量KeUserExceptionDispatcher 记录了用户态中的异常分发函数,在目前的windows中指向的是NTDLL中的 KiUserExceptionDispatcher ;具体怎么转回用户态的准备工作,如下步骤:
//.1 KiDispatchException会先确认用户态栈有足够的空间能容纳CONTEXT和EXCEPTION_RECORD结构
// 然后将这两个结构体复制到用户态栈中。
//.2 将TrapFrame指向的KTRAP_FRAME中的状态信息调整为在用户态执行需要的合适值?
// 比如 段寄存器,栈指针
//.3 KiDipatchException将 KeUserExceptionDispatcher(异常分发函数)的值赋值给EIP,这样线程返回用户态就从异常分发函数开始执行
//**********以上准备工作做好后KiDisPatchException就直接返回了******
//具体返回的地址会有如下:
//根据是 --CPU异常 还是 --软件异常 的区别,来确定返回的方式
if(IsCpuErro)//判断是CPU异常
{
//KiDispatcher 会返回到 CommonDispatchException
//然后执行KiExceptionExit并根据TrapFrame回复CPU状态
//然后执行异常返回执行 IRETD,因为TrapFrame被修改过了,所以异常返回指令执行后,当前线程便转到用户态的KiUserExceptionDispatcher
}
else if(IsUserErro)//如果是软件异常
{
//KiDispatcher 会返回到 KiRaiseException,再返回到NtRaiseException
//然后通过系统服务返回流程到用户态。
//由于已经设置过TrapFrame、EIP等,会从KiUserExceptionDispatcher开始执行
}
//------------------------------
//返回 总结:无论是CPU异常还是软件异常,返回后都执行EIP指向的KiUserExceptionDispatcher 函数
//接下来就是已经回到用户态后
//.1 KiUserExceptionDispatcher 会通过调用RtlDispatchException来寻找异常处理
//具体细节在 软件调试-- 24章
if(RtlDispatchException()) //如果返回True,代表有异常处理 处理了这个异常
{
//调用ZwContinue这个系统服务来接续执行原来发生异常的代码。
if(ZwContinue())
{
//*********出口 1-----------------------
//*******************************************
//如果调用成功,这里不会再返回到 KiUserExceptionDispathcer
//直接返回到 异常产生的代码处,继续执行代码流程
//********************************************
}
}
else
{
//RtlDispatchException()处理失败
//Status = NtRaiseException(ExceptionRecord, Context, FALSE);
RtlRaiseException()//把当前的异常第二次抛出,实际调用的还是NtRaiseException()
}
//走到这里的话,说明前面的ZwContinue() 继续失败了,才会走到这里会抛出一个新的异常
RtlRaiseException() //EXCEPTION_NONCONTINUABLE
}
}
else//第二次处理
{
//第二次处理 直接发送 DbgkForwardException(,TRUE,FALSE);
//首先先尝试发送调试端口 DebugPort
if(DbgkForwardException(,TRUE,FALSE))
{
return handled;
}
else
{
//如果调试端口没有处理,再尝试发送异常端口 ExceptionPort
//
if(rwardException(,FALSE,FALSE))
{
return handled;
}
}
//走到这儿的话,说明前面的调试端口和异常端口都没有处理
//如果还是没有处理的话,就蓝屏异常
KeBugCheckEx();
}
}
2. 查看一个函数多个SEH的嵌套
多个SEH嵌套,也只有一个,_except_handler4()函数 — 加载在SEH链中的默认两个函数的后面,也就是SEH的第三个,
2.1 SEH前置知识
- 软件调试(老版本上):
过滤表达式既可以是常量、函数调用,也可以是条件表达式或其他表达式,只要 表达式的结果为0, 1, -1这三个值之- ~, 它们的含义如下。 EXCEPTION_ CONTINUE_ SEARCH(0): 本保护块不处理该异常,让系统继续寻 找其他异常保护块。 EXCEPTION_ CONTINUE_ EXECUTION(1): 已经处理异常,让程序回到异常发 生点继续执行,如果导致异常的情况没被消除,那么很可能还会发生异常。 EXCEPTION_ EXECUTE_ HANDLER(-1): 这是本保护块预计到的异常,让系统 执行本块中的异常处理代码,执行完后会继续执行本异常处理块下面的代码,即except 块之后的第- -条指令。
- 而我们现在VS中
EXCEPTION_CONTINUE_EXECUTION(-1):
EXCEPTION_ EXECUTE_ HANDLER(1):
这两个相反语法结构:
__try { //被保护体(guarded body), 也就是要保护的代码块。 } __except (过滤表达式) { //异常处理块( exception-handling block ) } //常见的过滤表达式 1. 直接使用常量,比如 __except(EXCEPTION_BXECUTE_HANDLER)//即__except(1)//就目前VS而言是1而不是-1; 2. 使用条件运算符,比如__except(GetExceptionCode() == EXCEPTION_ACCESS_VIOLATION ? EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_ SRARCH)//其含义是,如果发生的异常是非法访问异常,那么就执行异常处理块,否则就继续搜索其他异常保护块。 3. 使用逗号表达式实现-系列操作,比如在2.2节我们给出的例子中,便在过滤表达式中执行了打印变量、判断等多个操作,_ except (pr intf ("In_ _ exceptblock:"},VAR WATCH(),...}。 4. 调用其他函数,通常将GetExcept ionCode()得到的异常代码或GetExceptionInformation()得到异常信息作为参数传给该函数。例如__ except (ExcptFilter(GetExcept ionInformation())).