首页
社区
课程
招聘
[转]---Windows内核调试器原理浅析---
发表于: 2004-12-30 22:13 7742

[转]---Windows内核调试器原理浅析---

2004-12-30 22:13
7742
原文出处:
http://www.xfocus.net/articles/200412/765.html

创建时间:2004-12-23
文章属性:原创
文章提交:SoBeIt (kinsephi_at_hotmail.com)

Windows内核调试器原理浅析

                                                                                       SoBeIt

    前段时间忽然对内核调试器实现原来发生了兴趣,于是简单分析了一下当前windows下主流内核调试器原理,并模仿原理自己也写了个极其简单的调试器:)

                WinDBG
   
    WinDBG和用户调试器一点很大不同是内核调试器在一台机器上启动,通过串口调试另一个相联系的以Debug方式启动的系统,这个系统可以是虚拟机上的系统,也可以是另一台机器上的系统(这只是微软推荐和实现的方法,其实象SoftICE这类内核调试器可以实现单机调试)。很多人认为主要功能都是在WinDBG里实现,事实上并不是那么一回事,windows已经把内核调试的机制集成进了内核,WinDBG、kd之类的内核调试器要做的仅仅是通过串行发送特定格式数据包来进行联系,比如中断系统、下断点、显示内存数据等等。然后把收到的数据包经过WinDBG处理显示出来。   

    在进一步介绍WinDBG之前,先介绍两个函数:KdpTrace、KdpStub,我在《windows异常处理流程》一文里简单提过这两个函数。现在再提一下,当异常发生于内核态下,会调用KiDebugRoutine两次,异常发生于用户态下,会调用KiDebugRoutine一次,而且第一次调用都是刚开始处理异常的时候。

    当WinDBG未被加载时KiDebugRoutine为KdpStub,处理也很简单,主要是对由int 0x2d引起的异常如DbgPrint、DbgPrompt、加载卸载SYMBOLS(关于int 0x2d引起的异常将在后面详细介绍)等,把Context.Eip加1,跳过int 0x2d后面跟着的int 0x3指令。

    真正实现了WinDBG功能的函数是KdpTrap,它负责处理所有STATUS_BREAKPOINT和STATUS_SINGLE_STEP(单步)异常。STATUS_BREAKPOINT的异常包括int 0x3、DbgPrint、DbgPrompt、加载卸载SYMBOLS。DbgPrint的处理最简单,KdpTrap直接向调试器发含有字符串的包。DbgPrompt因为是要输出并接收字符串,所以先将含有字符串的包发送出去,再陷入循环等待接收来自调试器的含有回复字符串的包。SYMBOLS的加载和卸载通过调用KdpReportSymbolsStateChange,int 0x3断点异常和int 0x1单步异常(这两个异常基本上是内核调试器处理得最多的异常)通过调用KdpReportExceptionStateChange,这两个函数很相似,都是通过调用KdpSendWaitContinue函数。KdpSendWaitContinue可以说是内核调试器功能的大管家,负责各个功能的分派。这个函数向内核调试器发送要发送的信息,比如当前所有寄存器状态,每次单步后我们都可以发现寄存器的信息被更新,就是内核调试器接受它发出的包含最新机器状态的包;还有SYMBOLS的状态,这样加载和卸载了SYMBOLS我们都能在内核调试器里看到相应的反应。然后KdpSendWaitContinue等待从内核调试器发来的包含命令的包,决定下一步该干什么。让我们来看看KdpSendWaitContinue都能干些什么:

        case DbgKdReadVirtualMemoryApi:
            KdpReadVirtualMemory(&ManipulateState,&MessageData,ContextRecord);
            break;

        case DbgKdReadVirtualMemory64Api:
            KdpReadVirtualMemory64(&ManipulateState,&MessageData,ContextRecord);
            break;

        case DbgKdWriteVirtualMemoryApi:
            KdpWriteVirtualMemory(&ManipulateState,&MessageData,ContextRecord);
            break;

        case DbgKdWriteVirtualMemory64Api:
            KdpWriteVirtualMemory64(&ManipulateState,&MessageData,ContextRecord);
            break;

        case DbgKdReadPhysicalMemoryApi:
            KdpReadPhysicalMemory(&ManipulateState,&MessageData,ContextRecord);
            break;

        case DbgKdWritePhysicalMemoryApi:
            KdpWritePhysicalMemory(&ManipulateState,&MessageData,ContextRecord);
            break;

        case DbgKdGetContextApi:
            KdpGetContext(&ManipulateState,&MessageData,ContextRecord);
            break;

        case DbgKdSetContextApi:
            KdpSetContext(&ManipulateState,&MessageData,ContextRecord);
            break;

        case DbgKdWriteBreakPointApi:
            KdpWriteBreakpoint(&ManipulateState,&MessageData,ContextRecord);
            break;

        case DbgKdRestoreBreakPointApi:
            KdpRestoreBreakpoin(&ManipulateState,&MessageData,ContextRecord);
            break;

        case DbgKdReadControlSpaceApi:
            KdpReadControlSpace(&ManipulateState,&MessageData,ContextRecord);
            break;

        case DbgKdWriteControlSpaceApi:
            KdpWriteControlSpace(&ManipulateState,&MessageData,ContextRecord);
            break;

        case DbgKdReadIoSpaceApi:
            KdpReadIoSpace(&ManipulateState,&MessageData,ContextRecord);
            break;

        case DbgKdWriteIoSpaceApi:
            KdpWriteIoSpace(&ManipulateState,&MessageData,ContextRecord);
            break;

        case DbgKdContinueApi:
            if (NT_SUCCESS(ManipulateState.u.Continue.ContinueStatus) != FALSE) {
                return ContinueSuccess;
            } else {
                return ContinueError;
            }
            break;

        case DbgKdContinueApi2:
            if (NT_SUCCESS(ManipulateState.u.Continue2.ContinueStatus) != FALSE) {
                KdpGetStateChange(&ManipulateState,ContextRecord);
                return ContinueSuccess;
            } else {
                return ContinueError;
            }
            break;

        case DbgKdRebootApi:
            KdpReboot();
            break;

        case DbgKdReadMachineSpecificRegister:
            KdpReadMachineSpecificRegister(&ManipulateState,&MessageData,ContextRecord);
            break;

        case DbgKdWriteMachineSpecificRegister:
            KdpWriteMachineSpecificRegister(&ManipulateState,&MessageData,ContextRecord);
            break;

        case DbgKdSetSpecialCallApi:
            KdSetSpecialCall(&ManipulateState,ContextRecord);
            break;

        case DbgKdClearSpecialCallsApi:
            KdClearSpecialCalls();
            break;

        case DbgKdSetInternalBreakPointApi:
            KdSetInternalBreakpoint(&ManipulateState);
            break;

        case DbgKdGetInternalBreakPointApi:
            KdGetInternalBreakpoint(&ManipulateState);
            break;

        case DbgKdGetVersionApi:
            KdpGetVersion(&ManipulateState);
            break;

        case DbgKdCauseBugCheckApi:
            KdpCauseBugCheck(&ManipulateState);
            break;

        case DbgKdPageInApi:
            KdpNotSupported(&ManipulateState);
            break;

        case DbgKdWriteBreakPointExApi:
            Status = KdpWriteBreakPointEx(&ManipulateState,
                                          &MessageData,
                                          ContextRecord);
            if (Status) {
                ManipulateState.ApiNumber = DbgKdContinueApi;
                ManipulateState.u.Continue.ContinueStatus = Status;
                return ContinueError;
            }
            break;

        case DbgKdRestoreBreakPointExApi:
            KdpRestoreBreakPointEx(&ManipulateState,&MessageData,ContextRecord);
            break;

        case DbgKdSwitchProcessor:
            KdPortRestore ();
            ContinueStatus = KeSwitchFrozenProcessor(ManipulateState.Processor);
            KdPortSave ();
            return ContinueStatus;

        case DbgKdSearchMemoryApi:
            KdpSearchMemory(&ManipulateState, &MessageData, ContextRecord);
            break;

    读写内存、搜索内存、设置/恢复断点、继续执行、重启等等,WinDBG里的功能是不是都能实现了?呵呵。

    每次内核调试器接管系统是通过调用在KiDispatchException里调用KiDebugRoutine(KdpTrace),但我们知道要让系统执行到KiDispatchException必须是系统发生了异常。而内核调试器与被调试系统之间只是通过串口联系,串口只会发生中断,并不会让系统引发异常。那么是怎么让系统产生一个异常呢?答案就在KeUpdateSystemTime里,每当发生时钟中断后在HalpClockInterrupt做了一些底层处理后就会跳转到这个函数来更新系统时间(因为是跳转而不是调用,所以在WinDBG断下来后回溯堆栈是不会发现HalpClockInterrupt的地址的),是系统中调用最频繁的几个函数之一。在KeUpdateSystemTime里会判断KdDebuggerEnable是否为TRUE,若为TRUE则调用KdPollBreakIn判断是否有来自内核调试器的包含中断信息的包,若有则调用DbgBreakPointWithStatus,执行一个int 0x3指令,在异常处理流程进入了KdpTrace后将根据处理不同向内核调试器发包并无限循环等待内核调试的回应。现在能理解为什么在WinDBG里中断系统后堆栈回溯可以依次发现KeUpdateSystemTime->RtlpBreakWithStatusInstruction,系统停在了int 0x3指令上(其实int 0x3已经执行过了,只不过Eip被减了1而已),实际已经进入KiDispatchException->KdpTrap,将控制权交给了内核调试器。

    系统与调试器交互的方法除了int 0x3外,还有DbgPrint、DbgPrompt、加载和卸载symbols,它们共同通过调用DebugService获得服务。

NTSTATUS DebugService(
        ULONG   ServiceClass,
        PVOID   Arg1,
        PVOID   Arg2
    )
{
    NTSTATUS    Status;

    __asm {
        mov     eax, ServiceClass
        mov     ecx, Arg1
        mov     edx, Arg2
        int     0x2d
        int     0x3   
        mov     Status, eax
    }
    return Status;
}

ServiceClass可以是BEAKPOINT_PRINT(0x1)、BREAKPOINT_PROMPT(0x2)、BREAKPOINT_LOAD_SYMBOLS(0x3)、BREAKPOINT_UNLOAD_SYMBOLS(0x4)。为什么后面要跟个int 0x3,M$的说法是为了和int 0x3共享代码(我没弄明白啥意思-_-),因为int 0x2d的陷阱处理程序是做些处理后跳到int 0x3的陷阱处理程序中继续处理。但事实上对这个int 0x3指令并没有任何处理,仅仅是把Eip加1跳过它。所以这个int 0x3可以换成任何字节。
   
    int 0x2d和int 0x3生成的异常记录结(EXCEPTION_RECORD)ExceptionRecord.ExceptionCode都是STATUS_BREAKPOINT(0x80000003),不同是int 0x2d产生的异常的ExceptionRecord.NumberParameters>0且ExceptionRecord.ExceptionInformation对应相应的ServiceClass比如BREAKPOINT_PRINT等。事实上,在内核调试器被挂接后,处理DbgPrint等发送字符给内核调试器不再是通过int 0x2d陷阱服务,而是直接发包。用M$的话说,这样更安全,因为不用调用KdEnterDebugger和KdExitDebugger。

    最后说一下被调试系统和内核调试器之间的通信。被调试系统和内核调试器之间通过串口发数据包进行通信,Com1的IO端口地址为0x3f8,Com2的IO端口地址为0x2f8。在被调试系统准备要向内核调试器发包之前先会调用KdEnterDebugger暂停其它处理器的运行并获取Com端口自旋锁(当然,这都是对多处理器而言的),并设置端口标志为保存状态。发包结束后调用KdExitDebugger恢复。每个包就象网络上的数据包一样,包含包头和具体内容。包头的格式如下:

        typedef struct _KD_PACKET {
            ULONG PacketLeader;
           USHORT PacketType;
            USHORT ByteCount;
            ULONG PacketId;
            ULONG Checksum;
        } KD_PACKET, *PKD_PACKET;
   
    PacketLeader是四个相同字节的标识符标识发来的包,一般的包是0x30303030,控制包是0x69696969,中断被调试系统的包是0x62626262。每次读一个字节,连续读4次来识别出包。中断系统的包很特殊,包里数据只有0x62626262。包标识符后是包的大小、类型、包ID、检测码等,包头后面就是跟具体的数据。这点和网络上传输的包很相似。还有一些相似的地方比如每发一个包给调试器都会收到一个ACK答复包,以确定调试器是否收到。若收到的是一个RESEND包或者很长时间没收到回应,则会再发一次。对于向调试器发送输出字符串、报告SYMBOL情况等的包都是一接收到ACK包就立刻返回,系统恢复执行,系统的表现就是会卡那么短短一下。只有报告状态的包才会等待内核调试器的每个控制包并完成对应功能,直到发来的包包含继续执行的命令为止。无论发包还是收包,都会在包的末尾加一个0xaa,表示结束。

    现在我们用几个例子来看看调试流程。

    记得我以前问过jiurl为什么WinDBG的单步那么慢(相对softICE),他居然说没觉得慢?*$&$^$^(&(&(我ft。。。现在可以理解为什么WinDBG的单步和从操作系统正常执行中断下来为什么那么慢了。单步慢是因为每单步一次除了必要的处理外,还得从串行收发包,怎么能不慢。中断系统慢是因为只有等到时钟中断发生执行到KeUpdateSystemTime后被调试系统才会接受来自WinDBG的中断包。现在我们研究一下为什么在KiDispatchException里不能下断点却可以用单步跟踪KiDispatchException的原因。如果在KiDispatchException中某处下了断点,执行到断点时系统发生异常又重新回到KiDispatchException处,再执行到int 0x3,如此往复造成了死循环,无法不能恢复原来被断点int 0x3所修改的代码。但对于int 0x1,因为它的引起是因为EFLAG寄存中TF位被置位,并且每次都自动被复位,所以系统可以被继续执行而不会死循环。现在我们知道了内部机制,我们就可以调用KdXXX函数实现一个类似WinDBG之类的内核调试器,甚至可以替换KiDebugRoutine(KdpTrap)为自己的函数来自己实现一个功能更强大的调试器,呵呵。

                SoftICE

    SoftICE的原理和WinDBG完全不一样。它通过替换正常系统中的中断处理程序来获得系统的控制权,也正因为这样它才能够实现单机调试。它的功能实现方法很底层,很少依赖与windows给的接口函数,大部分功能的实现都是靠IO端口读写等来完成的。

    SoftICE替换了IDT表中以下的中断(陷阱)处理程序:

    0x1:    单步陷阱处理程序
    0x2:    NMI不可屏蔽中断
    0x3:    调试陷阱处理程序
    0x6:    无效操作码陷阱处理程序
    0xb:    段不存在陷阱处理程序
    0xc:    堆栈错误陷阱处理程序
    0xd:    一般保护性错误陷阱处理程序
    0xe:    页面错误陷阱处理程序
    0x2d:    调试服务陷阱处理程序
    0x2e:    系统服务陷阱处理程序
    0x31:    8042键盘控制器中断处理程序
    0x33:    串口2(Com2)中断处理程序
    0x34:    串口1(Com1)中断处理程序
    0x37:    并口中断处理程序
    0x3c:    PS/2鼠标中断处理程序
    0x41:    未使用

    (这是在PIC系统上更换的中断。如果是APIC系统的话更换的中断号有不同,但同样是更换这些中断处理程序)

    其中关键是替换了0x3 调试陷阱处理程序和0x31 i8042键盘中断处理驱动程序(键盘是由i8042芯片控制的),SoftICE从这两个地方获取系统的控制权。
   
    启动softICE服务后SoftICE除了更换了IDT里的处理程序,还有几点重要的,一是HOOK了i8042prt.sys里的READ_PORT_UCHAR函数,因为在对0x60端口读后,会改变0x64端口对应控制寄存器的状态。所以在SoftICE的键盘中断控制程序读了0x60端口后并返回控制权给正常的键盘中断控制程序后,不要让它再读一次。还有就是把物理内存前1MB的地址空间通过调用MmMapIoSpace映射到虚拟的地址空间里,里面包括显存物理地址,以后重画屏幕就通过修改映射到虚拟地址空间的这段显存内容就行了。

    如果显示模式是彩色模式,那么显存起始地址是0xb8000,CRT索引寄存器端口0x3d4,CRT数据寄存器端口0x3d5。如果显示模式是单色模式,那么显存起始地址是0xb0000,CRT索引寄存器端口0x3b4,CRT数据寄存器端口0x3b5。首先写索引寄存器选择要进行设置的显示控制内部寄存器之一(r0-r17),然后将参数写到其数据寄存器端口。

    i8042键盘控制器中断控制驱动程序在每按下一个键和弹起一个键都会被触发。SoftICE在HOOK了正常的键盘中断控制程序获得系统控制权后,首先从0x60端口读出按下键的扫描码然后向0x20端口发送通用EOI(0x20)表示中断已结束,如果没有按下激活热键(ctrl+d),则返回正常键盘中断处理程序。如果是按下热键则会判断控制台(就是那个等待输入命令的显示代码的黑色屏幕)是否被激活,未被激活的话则先激活。然后设置IRQ1键盘中断的优先级为最高,同时设置两个8259A中断控制器里的中断屏蔽寄存器(向0x21和0xa1发中断掩码,要屏蔽哪个中断就把哪一位设为1),只允许IRQ1(键盘中断)、IRQ2(中断控制器2级联中断,因为PS/2鼠标中断是归8259A-2中断控制器管的,只有开放IRQ2才能响应来自8259A-2管理的中断)、IRQ12(PS/2鼠标中断,如果有的话),使系统这时只响应这3个中断。新的键盘和鼠标中断处理程序会建立一个缓冲区,保存一定数量的输入扫描信息。当前面的工作都完成后会进入一段循环代码,负责处理键盘和鼠标输入的扫描码缓冲区,同时不断地更新显存的映射地址缓冲区重画屏幕(这段循环代码和WinDBG里循环等待从串口发来的包的原理是一样的,都是在后台循环等待用户的命令)。这段循环代码是在激活控制台的例程里调用的,也就是说当控制台已被激活的话正常流程不会再次进入这段循环代码的(废话,再进入系统不就死循环了)。当有一个新的键按下时,都会重新调用一遍键盘中断处理程序,因为控制台已激活,所以它只是简单地更新键盘输入缓冲区内容然后iret返回。它并不会返回正常的键盘中断处理程序,因为那样会交出控制权(想证明这点也很简单,在SoftICE里断正常的键盘中断处理程序,然后g,1秒后在这里断下,这是我们可以F10,如果SoftICE会把控制权交给正常的键盘中断处理程序的话,在这里早就发生死循环了)。鼠标中断驱动也是一样。这个时候实际iret返回到的还是那段循环代码里面,所以被调试的代码并不会被执行,除非按下了F10之类的键,它会指示退出循环返回最开始时的中断处理程序,然后再iret返回最开始中断的地方。当然,因为设置了EFLAG里的TF位,执行了一个指令又会通过单步的处理程序进入那段循环的代码。

    而处理int 0x3也差不多,若没有激活控制台则先激活并屏蔽除了键盘、鼠标及8259A-2中断控制器外的所有中断,然后进入那段循环代码。

    作为对比同样来看一下在SoftICE里处理int 0x3和单步的过程。当执行到int 0x3时,激活控制台并屏蔽中断,然后将int 0x3指令前后范围的指令反汇编并写入显存映射地址空间,并把最新的寄存器值也写进去,最后在后台循环等待键盘输入命令。当命令是F10时,设置好EFLAG的TF位,清除8259A中断控制器里的中断屏蔽寄存器,开放所有中断,将控制台清除,从循环代码中返回新键盘(或int 0x3)中断处理程序,然后再返回到正常键盘(或int 0x3)中断处理程序,由这里iret到被中断代码处执行。执行了一个指令后因为发生单步异常又进入后台循环代码。

    SoftICE里的单步比WinDBG要快得多的原因很简单,SoftICE只需要把反汇编出来的代码和数据经过简单处理再写入显存映射地址缓冲区里刷新屏幕就可以继续执行了,省略了串行的发包收包,怎么会不快。而中断系统更快,按下键中断就会发生,根本不用象WinDBG等时钟中断才能把系统断下来。
   
   
后记:
   
    好象说得很简单,其实一个内核调试器实现起来极其复杂,没说得再详细,一是因为题目就叫“浅析”,就是类似于科普的东西;二是水平和时间有限(主要原因^^);三是真要详细写起来就不是这几千字能说得明白的东西了。还有,反汇编ntice.sys真是一项艰巨的任务,比分析漏洞要复杂N倍,刚开始没着门道时真看得我头昏眼花。在此特别感谢Syser的作者,牛人就是牛人,在我对SoftICE工作原理的认识还处于混沌状态时,几句话点醒了我^^。因为水平有限难免有很多错漏,还忘高手指出:)

QQ:27324838
Email:27324838

[招生]科锐逆向工程师培训(2024年11月15日实地,远程教学同时开班, 第51期)

收藏
免费 1
支持
分享
最新回复 (6)
雪    币: 339
活跃值: (1510)
能力值: ( LV13,RANK:970 )
在线值:
发帖
回帖
粉丝
2
顶!
2004-12-30 22:19
0
雪    币: 97697
活跃值: (200839)
能力值: (RANK:10 )
在线值:
发帖
回帖
粉丝
3
创建时间:2004-12-10
文章属性:原创
文章提交:SoBeIt (kinsephi_at_hotmail.com)

Windows异常处理流程

                                                                                       SoBeIt

    先来说说异常和中断的区别。中断可在任何时候发生,与CPU正在执行什么指令无关,中断主要由I/O设备、处理器时钟或定时器等硬件引发,可以被允许或取消。而异常是由于CPU执行了某些指令引起的,可以包括存储器存取违规、除0或者特定调试指令等,内核也将系统服务视为异常。中断和异常更底层的区别是当广义上的中断(包括异常和硬件中断)发生时如果没有设置在服务寄存器(用命令号0xb向8259-1中断控制器0x20端口读出在服务寄存器1,用0xb向8259-2中断控制器的0xa0端口读出在服务寄存器2)相关的在服务位(每个在服务寄存器有8位,共对应IRQ 0-15)则为CPU的异常,否则为硬件中断。

    下面是WINDOWS2000根据INTEL x86处理器的定义,将IDT中的前几项注册为对应的异常处理程序(不同的操作系统对此的实现标准是不一样的,这里给出的和其它一些资料不一样是因为这是windows的具体实现):

    中断号    名字        原因        
    0x0    除法错误        1、DIV和IDIV指令除0
                2、除法结果溢出
    0x1    调试陷阱        1、EFLAG的TF位置位
                2、执行到调试寄存器(DR0-DR4)设置的断点
                3、执行INT 1指令
    0x2    NMI中断        将CPU的NMI输入引脚置位(该异常为硬件发生非屏蔽中断而保留)
    0x3    断点        执行INT 3指令
    0x4    整数溢出        执行INTO指令且OF位置位
    0x5    BOUND边界检查错误    BOUND指令比较的值在给定范围外   
    0x6    无效操作码    指令无法识别
    0x7    协处理器不可用    1、CR0的EM位置位时执行任何协处理器指令        
                2、协处理器工作时执行了环境切换   
    0x8    双重异常        处理异常时发生另一个异常
    0x9    协处理器段超限    浮点指令引用内存超过段尾
    0xA    无效任务段    任务段包含的描述符无效(windows不   
使用TSS进行环境切换,所以发生该异常说明有其它问题)
    0xB    段不存在        被引用的段被换出内存
    0xC    堆栈错误        1、被引用内存超出堆栈段限制
                2、加载入SS寄存器的描述符的present位置0
    0xD    一般保护性错误    所有其它异常处理例程无法处理的异常
    0xE    页面错误        1、访问的地址未被换入内存
                2、访问操作违反页保护规则
    0x10    协处理器出错    CR0的EM位置位时执行WAIT或ESCape指令
    0x11    对齐检查错误    对齐检查开启时(EFLAG对齐位置位)访问未对齐数据
   
    其它异常还包括获取系统启动时间服务int 0x2a、用户回调int 0x2b、系统服务int 0x2e、调试服务int 0x2d等系统用来实现自己功能的部分,都是通过异常的机制,触发方式就是执行相应的int指令。

    这里给出几个异常处理中重要的结构:

    陷阱帧TrapFrame结构(后面提到的异常帧ExceptionFrame结构其实也是一个KTRAP_FRAME结构):

    typedef struct _KTRAP_FRAME {
            ULONG   DbgEbp;         
            ULONG   DbgEip;      
            ULONG   DbgArgMark;   
            ULONG   DbgArgPointer;
            ULONG   TempSegCs;
            ULONG   TempEsp;
            ULONG   Dr0;
            ULONG   Dr1;
            ULONG   Dr2;
            ULONG   Dr3;
            ULONG   Dr6;
            ULONG   Dr7;
            ULONG   SegGs;
            ULONG   SegEs;
            ULONG   SegDs;
            ULONG   Edx;
            ULONG   Ecx;
            ULONG   Eax;
            ULONG   PreviousPreviousMode;
            PEXCEPTION_REGISTRATION_RECORD ExceptionList;
            ULONG   SegFs;
            ULONG   Edi;
            ULONG   Esi;
            ULONG   Ebx;
            ULONG   Ebp;
            ULONG   ErrCode;
            ULONG   Eip;
            ULONG   SegCs;
            ULONG   EFlags;
            ULONG   HardwareEsp;   
            ULONG   HardwareSegSs;
            ULONG   V86Es;         
            ULONG   V86Ds;  
            ULONG   V86Fs;
            ULONG   V86Gs;   
    } KTRAP_FRAME;

    环境Context结构:

    typedef struct _CONTEXT {
            ULONG ContextFlags;
            ULONG   Dr0;
            ULONG   Dr1;
            ULONG   Dr2;
            ULONG   Dr3;
            ULONG   Dr6;
            ULONG   Dr7;
            FLOATING_SAVE_AREA FloatSave;
            ULONG   SegGs;
            ULONG   SegFs;
            ULONG   SegEs;
            ULONG   SegDs;
            ULONG   Edi;
            ULONG   Esi;
            ULONG   Ebx;
            ULONG   Edx;
            ULONG   Ecx;
            ULONG   Eax;
            ULONG   Ebp;
            ULONG   Eip;
            ULONG   SegCs;   
            ULONG   EFlags;
            ULONG   Esp;
            ULONG   SegSs;
            UCHAR   ExtendedRegisters[MAXIMUM_SUPPORTED_EXTENSION];
    } CONTEXT;

    异常记录ExceptionRecord结构:

    typedef struct _EXCEPTION_RECORD {
            NTSTATUS ExceptionCode;
            ULONG ExceptionFlags;
            struct _EXCEPTION_RECORD *ExceptionRecord;
            PVOID ExceptionAddress;
            ULONG NumberParameters;
            ULONG_PTR ExceptionInformatio[EXCEPTION_MAXIMUM_PARAMETERS];
        } EXCEPTION_RECORD;

    当发生异常后,CPU记录当前各寄存器状态并在内核堆栈中建立陷阱帧TrapFrame,然后将控制交给对应异常的陷阱处理程序。当陷阱处理程序能处理异常时,比如缺页时通过调页程序MmAccessFault将页换入物理内存后通过iret返回发生异常的地方。但大多数无法处理异常,这时先是调用CommonDispatchException在内核堆栈中建立异常记录ExceptionRecord和异常帧ExceptionFrame。ExceptionRecord很重要,它记录了异常代码、异常地址以及一些其它附加的参数。然后调用KiDispatchException进行异常的分派。这个函数是WINDOWS下异常处理的核心函数,负责异常的分派处理。

    KiDispatchException的处理流程(每当异常被某个例程处理时处理的例程将返回TRUE到上一个例程,未处理则返回FALSE。当任何一个例程处理了异常返回TRUE时,则KiDispatchException正常返回):

    在进行用户态内核态的异常的分派前,先判断异常是否来自用户模式,是的话将Context.ContextFlags(这时候Context结构还刚初始化完,还未赋初值) or上CONEXT_FLOATING_POINT,意味着对来自用户模式的异常总是尝试分派浮点状态,这样可以允许异常处理程序或调试器检查和修改协处理器的状态。然后从陷阱帧中取出寄存器值填入Context结构,并判断是否是断点异常(int 0x3和int 0x2d),如果是的话先将Context.Eip减一使它指向int 0x3指令(无论是由int 0x3还是由int 0x2d引起的异常,因为前面的陷阱处理程序里已经改变过TrapFrame里面的Eip了)。然后判断异常是发生于内核模式还是用户模式,根据不同模式而采取不同处理过程。

    如果异常发生于内核模式,会给予内核调试器第一次机会和第二次机会处理异常。当异常被处理后就将设置好陷阱帧并返回到陷阱处理程序,在那里iret返回发生异常的地方继续执行。

    内核模式异常处理流程为:

    (第一次机会)判断KiDebugRoutine是否为空,不为空就将Context、陷阱帧、异常记录、异常帧、发生异常的模式等压入栈并将控制交给KiDebugRoutine。
        |
        |
        |
    若KiDebugRoutine为空(正常的系统这里不为空。正常启动的系统KiDebugRoutine为KdpStub,在Boot.ini里加上/DEBUG启动的系统的KiDebugRoutine为KdpTrap。如果这里为空的话会因为处理不了DbgPrint这类int 0x2d产生的异常而导致系统崩溃)或者KiDebugRoutine未处理异常,则将Context结构和异常记录ExceptionRecord压栈并调用内核模式的RtlDispatchException在内核堆栈中查找基于帧的异常处理例程。
        |
        |
        |        
    RtlDispatchException调用RtlpGetRegistrationHead从fs:[0](0xffdff000)处获取当前线程异常处理链表指针,并调用RtlpGetStackLimits从0xffdff004和0xffdff008取出当前线程堆栈底和顶。然后开始由异常处理链表指针遍历链表查找异常处理例程(若在XP和2003下先处理VEH再处理SEH),其实这就是SEH,只是和用户态有一点不同是既没有顶层异常处理例程(TOP LEVEL SEH)也没有默认异常处理例程。然后对每个当前异常处理链表指针检查判断堆栈是否有效(是否超出了堆栈范围或者未对齐)及堆栈是否是DPC堆栈。若0xffdff80c处DpcRoutineActive为TRUE且堆栈顶和底在0xffdff81c处取出的DpcStack到DpcStack-0x3000(一个内核堆栈大小),若是则更新堆栈顶和底为DpcStack和DpcStack-0x3000并继续处理,否则将异常记录结构里的异常标志ExceptionRecord.ExceptionFlags设置EXCEPTION_STACK_INVALID表示为无效堆栈并返回FALSE。
        |
        |
        |
        |
    调用异常处理链表上的异常处理例程之前会在异常处理例程链表上插入一个新的节点,对应的异常处理例程是用来处理嵌套异常,也就是在处理异常时发生另一个异常。处理后
RtlDispatchException判断异常处理例程的返回值:
    若为ExceptionContinueExecution,若异常标志ExceptionRecord.ExceptionFlags未设置EXCEPTION_NONCONTINUABLE不可恢复执行,则返回TRUE到上一层,否则在做了一些工作后调用RtlRaiseException进入到KiDispatchException的第二次机会处理部分。
    若为ExceptionContinueSearch,则继续查找异常处理例程。
    若为ExceptionNestedException,嵌套异常。保留当前异常处理链表指针为内层异常处理链表并继续查找异常处理例程。当发现当前异常处理链表地址大于保留的内层异常处理链表时,表示当前的异常处理链表比保留的更内层(因为堆栈是由高向低扩展的,地址越高则入栈越早,表示更内层),则将其值赋予内层异常处理链表指针,除了第一次赋初值外发生修改保留的内层异常处理链表指针这种情况表示嵌套了不止一次的异常。当搜索到新的异常处理链表指针和保留的内层异常处理链表指针相同,则清除ExceptionRecord.ExceptionFlags的嵌套异常位(&(~EXCEPTION_NESTED_CALL)),表示嵌套的异常已经处理完。
    其它的返回值都视为无效,调用RtlRaiseException回到KiDispatchException的第二次机会处理部分。
    当异常处理链表指针为0xffffffff时,异常处理例程链表已到头。
        |
        |
        |
    若RtlDispatchException无法处理异常,(第二次机会)判断KiDebugRoutine是否为空,不为空则交给KiDebugRoutine处理。
        |
        |
        |
    当所有例程都无法处理异常时,调用KeBugCheckEx蓝屏,错误代码为KMODE_EXCEPTION_NOT_HANDLED,表示谁也没有处理异常,系统视这个异常为无法恢复的致命异常。至此内核模式下异常处理完毕。

    若异常发生在用户模式,同样给予调试器第一次机会和第二次机会调试。只不过因为调试器是用户态调试器,所以通过LPC发送消息给会话管理器smss.exe,再由会话管理器将消息转发给调试器。

    用户模式异常处理流程:

    若KiDebugRoutine不为空,则不为空就将Context、陷阱帧、异常记录、异常帧、发生异常的模式等压入栈并将控制交给KiDebugRoutine。当处理完毕用Context设置陷阱帧并返回到上一级例程。(第一次机会)否则把异常记录压栈并调用DbgkForwardException,在DbgkForwardException里判断当前线程ETHREAD结构的HideFromDebugger成员如果为FALSE(为TRUE表示该异常对用户调试器不可见)则向当前进程的调试端口(DebugPort)发送LPC消息。
        |
        |
        |
    当上一步无法处理异常时将Context结构拷贝到用户堆栈,在堆栈中设置一个陷阱帧,陷阱帧的Eip为Ke(i)UserExceptionDisptcher((i)表示这个函数的Ke和Ki打头的符号其实是一回事),接着返回陷阱处理程序,由陷阱处理程序iret返回用户态执行Ke(i)UserExceptionDispatcher(这个函数虽然是Ke(Ki)打头,却不是内核里的函数。同样性质特殊的还有Ke(i)RaiseUserExceptionDispatcher、Ke(i)UserCallbackDispatcher、Ke(i)UserApcDispatcher。它们的共同特点就是不是被调用的,而是由内核例程设置了陷阱帧TrapFrame.Eip为该函数后iret执行到这里的)。Ke(i)UserExceptionDisptcher调用RtlDispatchException(用户态下的)寻找堆栈中基于帧的异常处理例程(若在XP和2003下先处理VEH再处理SEH),这个流程大家应该很熟了,就是搜索SEH链表,若都不处理就调用顶层异常处理(TOP LEVEL SEH)例程。当再无法处理时就调用默认异常处理例程终止进程(有VC时这里就换成了VC)。有点不同的是用户态下的RtlDispatchException只判断返回值是ExceptionContinueExecution还是ExceptionContinueSearch。若RtlDispatchException找到异常处理例程能够处理异常,则调用ZwContinue按照设置好的Context结构继续执行,否则调用ZwRaiseException,并且把第三个布尔参数设为FALSE,表示进入第二次机会处理。
        |
        |
        |
    ZwRaiseException经过一系列调用最后直接调用KiDispatchException,由于把布尔值FirstChance设置为FALSE,在KiDispatchException里直接进入第二次机会处理。
        |
        |
        |
    (第二次机会)向进程的DebugPort发消息,若无法处理,则改向进程的ExceptionPort发消息(这里同样如果该异常对用户调试器不可见,则只会发送到ExceptionPort)。DebugPort和ExceptionPort的区别在于,若向ExceptionPort发消息,先停止目标进程所有线程的执行,直到收到回应消息后线程才恢复执行,而向DebugPort发消息则不需要停止线程运行。还有DebugPort是向会话管理器发消息,而ExceptionPort是向Win32子系统发消息,当向ExceptionPort发消息时,已经不给用户态调试器任何机会了:)。
        |
        |
        |
    当异常还是无法处理,则终止当前线程,并调用KeBugCheckEx蓝屏,错误代码为KMODE_EXCEPTION_NOT_HANDLED。至此用户模式下异常处理完毕。

    有一点要说明的是,这里只是列出了操作系统的流程。如果现在去看比方说驱动或者一个应用程序里,加入了__try/__except代码,却没发现SEH上的节点对应的异常处理例程和自己写的有啥关系。其中的原因就是因为M$的编译器(比方说VC++、DDK)在系统内部封装了SEH的机制。在异常处理例程链表节点上的异常处理例程实际上是_except_handler3,这个函数自己在内部又实现了一套类似SEH的机制,就是通过对每个函数建立一个表,包括了该函数中所有__try块对应的过滤异常例程指针(__try()括号里的对应函数,如果是括号里是EXCEPTION_EXECUTE_HANDLER之类的则该过滤异常例程很简单地把eax赋为相应的1、0或-1然后返回,对应了EXCEPTION_EXECUTE_HANDLER、EXCEPTION_CONTINUE_SEARCH、EXCEPTION_CONTINUE_EXECUTE)和处理例程指针(__except{}和__finally{}里面代码的地址)。所以对应每个被调用到的函数都在SEH链表上注册一个节点,并建立一张表。象诸如EXCEPTION_EXECUTE_HANDLER、EXCEPTION_CONTINUE_SEARCH、EXCEPTION_CONTINUE_EXECUTE实际上是返回给_except_handler3的返回值,而不是返回给RtlDispatchException的。

    看得有点晕吧,呵呵。总结一下流程(指异常未被处理的完整流程,若异常在任一个环节被处理都从该环节上退出继续原来正常程序代码的执行):

    内核模式下:
    KiDispatchException->(第一次机会)KiDebugRoutine->RtlDispatchException在内核堆栈寻找SEH(VEH)->(第二次机会)KiDebugRoutine->KeBugCheckEx

    用户模式下:
    KiDispatchException->KiDebugRoutine->(第一次机会)发送消息到进程调试端口->RtlDispatchException在用户堆栈寻找SEH(VEH)->ZwRaiseException回到KiDispatchException->(第二次机会)发送消息到进程调试或异常端口->KeBugCheckEx。

    就这么简单,呵呵。举个例子来说明异常的处理。
   
    我们程序中通过加入int 0x3来对程序进行调试,那么int 0x3产生的异常是怎么处理的呢?

    若int 0x3发生在内核模式下(驱动程序里),又分为内核调试器是否加载。内核调试器未加载时,KiDebugRoutine(KdpStub)不处理int 0x3异常,则在堆栈中搜索由驱动程序编写者注册的SEH,若也没有对int 0x3异常的处理,则只好调用KeBugCheckEx蓝屏,错误代码是KMODE_EXCEPTION_NOT_HANDLED(谁都不干活,系统只好悲愤地自我了结了)。若有内核调试器加载,则KiDebugRoutine(KdpTrap)可以处理int 0x3异常,异常处理到这里被正常返回。处理方法是将当前的处理器状态(比如各寄存器等重要信息)发送给通过串口相连的主机上的内核调试器,并一直在等待内核调试器的回应,系统这时在KdpTrap里调用一个Kd函数KdpSendWaitContinue循环等待来自串口的数据(内核调试器和被调试系统通过串口联系),直到内核调试器下达继续执行的命令,系统可以正常从int 0x3后面一条指令执行。

    若int 0x3发生在用户模式下,也分为用户调试器是否加载。用户调试器未加载时,KiDebugRoutine不处理int 0x3异常,且用户进程无DebugPort,将记录异常的相关结构拷入用户堆栈交由用户模式的RtlDispatchException搜索用户堆栈中的SEH。若也没有对int 0x3异常的处理,则调用默认异常处理例程,默认情况下是将发生异常的进程终止。若有用户调试器加载,则向进程的DebugPort或ExceptionPort发送有关异常的LPC消息并判断发送函数的返回状态,若用户调试器继续执行,则返回STATUS_SUCCESS,内核视为异常已解决继续执行。

    这里也说明了一点,就是相同的错误,发生在?核态下比发生在用户态下致命得多。其它异常的处理流程基本也一样。少数不一样的异常象DebugPrint、DebugPrompt、加载和卸载symbol等是通过调用DebugService的(这实际上是通过产生一个异常int 0x2d)。可以在KdpStub中就被处理(处理很简单,只是把Context结构的Eip加一略过当前int 0x2d后那条int 0x3指令),若在KdpTrap中被处理则是和内核调试器更进一步的交互。关于KdpStub、KdpTrap和DebugService更详细的介绍参见我的另一篇文章《windows内核调试器原理浅析》。

后记:
    前几天翻硬盘时找出了这篇半年前写了大半的文章。当时因为还有些不明白的东西,所以没写完,结果一放就放了半年。现在翻出来写完后弄明白了不少东西,也把心得拿出来供大家参考,就当是抛砖引玉吧:)

QQ:27324838
EMAIL:kinvis@hotmail.com
2004-12-30 22:29
0
雪    币: 206
活跃值: (16)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
4
太高深看不懂
2004-12-30 22:55
0
雪    币: 213
活跃值: (16)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
5
逆向的成果,
前日已收藏过。
2004-12-30 23:01
0
雪    币: 200
活跃值: (10)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
6
谢谢,~~~~
2004-12-31 11:15
0
雪    币: 390
活跃值: (707)
能力值: ( LV12,RANK:650 )
在线值:
发帖
回帖
粉丝
7
经常去xfocus
2004-12-31 12:16
0
游客
登录 | 注册 方可回帖
返回
//