1. 程式人生 > >windbg核心除錯原理淺析

windbg核心除錯原理淺析

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工作原理的認識還處於混沌狀態時,幾句話點醒了我^^。因為水平有限難免有很多錯漏,還忘高手指出:)