目錄?
3.1實時監控概論
3.2病毒實時監控實現技術概論
3.3WIN9X下的病毒實時監控
? 3.3.1實現技術詳解
? 3.3.2程序結構與流程
? 3.3.3HOOKSYS.VXD逆向工程代碼剖析
??? 3.3.3.1鉤子函數入口代碼
??? 3.3.3.2取得當前進程名稱代碼
??? 3.3.3.3通信部分代碼
3.4 WINNT/2000下的病毒實時監控
? 3.4.1實現技術詳解
? 3.4.2程序結構與流程
? 3.4.3HOOKSYS.SYS逆向工程代碼剖析
??? 3.4.3.1取得當前進程名稱代碼
??? 3.4.3.2啟動鉤子函數工作代碼
??? 3.4.3.3映射系統內存至用戶空間代碼
3.病毒實時監控
3.1實時監控概論
實時監控技術其實并非什么新技術,早在DOS編程時代就有之。只不過那時人們沒有給這項技術冠以這樣專業的名字而已。早期在各大專院校機房中普遍使用的硬盤寫保護軟件正是利用了實時監控技術。硬盤寫保護軟件一般會將自身寫入硬盤零磁頭開始的幾個扇區(由0磁頭0柱面1扇最開始的64個扇區是保留的,DOS訪問不到)并修改原來的主引導記錄以使啟動時硬盤寫保護程序可以取得控制權。引導時取得控制權的硬盤寫保護程序會修改INT13H的中斷向量指向自身已駐留于內存中的鉤子代碼以便隨時攔截所有對磁盤的操作。鉤子代碼的作用當然是很明顯的,它主要負責由判斷中斷入口參數,包括功能號,磁盤目標地址等來決定該類型操作是否被允許,這樣就可以實現對某一特定區域的寫操作保護。后來又誕生了在此基礎之上進行改進了的磁盤恢復卡之類的產品,其利用將寫操作重定向至目標區域外的臨時分區并保存磁盤先前狀態等技術來實現允許寫入并可隨時恢復之功能。不管怎么改進,這類產品的核心技術還是對磁盤操作的實時監控。對此有興趣的朋友可參看高云慶著《硬盤保護技術手冊》。DOS下還有許多通過駐留并截獲一些有用的中斷來實現某種特定目的的程序,我們通常稱之為TSR(終止并等待駐留terminate-and-stay-resident,此種程序不容易編好,需要大量的關于硬件和Dos中斷的知識,還要解決Dos重入,tsr程序重入等問題,搞不好就會當機)。在WINDOWS下要實現實時監控決非易事,普通用戶態程序是不可能監控系統的活動的,這也是出于系統安全的考慮。HPS病毒能在用戶態下直接監控系統中的文件操作其實是由于WIN9X在設計上存在漏洞。而我們下面要討論的兩個病毒實時監控(For WIN9X&WINNT/2000)都使用了驅動編程技術,讓工作于系統核心態的驅動程序去攔截所有的文件訪問。當然由于工作系統的不同,這兩個驅動程序無論從結構還是工作原理都不盡相同的,當然程序寫法和編譯環境更是千差萬別了,所以我們決定將其各自分成獨立的一節來詳細地加以討論。上面提到的病毒實時監控其實就是對文件的監控,說成是文件監控應該更為合理一些。除了文件監控外,還有各種各樣的實時監控工具,它們也都具有各自不同的特點和功用。這里向大家推薦一個關于WINDOWS系統內核編程的站點:www.sysinternals.com。在其上可以找到很多實時監控小工具,比如能夠監視注冊表訪問的Regmon(通過修改系統調用表中注冊表相關服務入口),可以實時地觀察TCP和UDP活動的Tdimon(通過hook系統協議驅動Tcpip.sys中的dispatch函數來截獲tdi clinet向其發送的請求),這些工具對于了解系統內部運作細節是很有裨益的。介紹完有關的背景情況后,我們來看看關于病毒 實時監控的具體實現技術的情況。
3.2病毒實時監控實現技術概論正如上面提到的病毒實時監控其實就是一個文件監視器,它會在文件打開,關閉,清除,寫入等操作時檢查文件是否是病毒攜帶者,如果是則根據用戶的決定選擇不同的處理方案,如清除病毒,禁止訪問該文件,刪除該文件或簡單地忽略。這樣就可以有效地避免病毒在本地機器上的感染傳播,因為可執行文件裝入器在裝入一個文件執行時首先會要求打開該文件,而這個請求又一定會被實時監控在第一時間截獲到,它確保了每次執行的都是干凈的不帶毒的文件從而不給病毒以任何執行和發作的機會。以上說的僅是病毒實時監控一個粗略的工作過程,詳細的說明將留到后面相應的章節中。病毒實時監控的設計主要存在以下幾個難點:
其一是驅動程序的編寫不同于普通用戶態程序的寫作,其難度很大。寫用戶態程序時你需要的僅僅就是調用一些熟知的API函數來完成特定的目的,比如打開文件你只需調用CreateFile就可以了;但在驅動程序中你將無法使用熟悉的CreateFile。在NT/2000下你可以使用ZwCreateFile或NtCreateFile(native API),但這些函數通常會要求運行在某個IRQL(中斷請求級)上,如果你對如中斷請求級,延遲/異步過程調用,非分頁/分頁內存等概念不是特別清楚,那么你寫的驅動將很容易導致藍屏死機(BSOD),Ring0下的異常將往往導致系統崩潰,因為它對于系統總是被信任的,所以沒有相應處理代碼去捕獲這個異常。在NT下對KeBugCheckEx的調用將導致藍屏的出現,接著系統將進行轉儲并隨后重啟。另外驅動程序的調試不如用戶態程序那樣方便,用象VC++那樣的調試器是不行的,你必須使用系統級調試器,如softice,kd,trw等。
其二是驅動程序與ring3下客戶程序的通信問題。這個問題的提出是很自然的,試想當驅動程序截獲到某個文件打開請求時,它必須通知位于ring3下的查毒模塊檢查被打開的文件,隨后查毒模塊還需將查毒的結果通過某種方式傳給ring0下的監控程序,最后驅動程序根據返回的結果決定請求是否被允許。這里面顯然存在一個雙向的通信過程。寫過驅動程序的人都知道一個可以用來向驅動程序發送設備I/O控制信息的API調用DeviceIoControl,它的接口在MSDN中可以找到,但它是單向的,即ring3下客戶程序可以通過調用DeviceIoControl將某些信息傳給ring0下的監控程序但反過來不行。既然無法找到一個現成的函數實現從ring0下的監控程序到ring3下客戶程序的通信,則我們必須采用迂回的辦法來間接做到這一點。為此我們必須引入異步過程調用(APC)和事件對象的概念,它們就是實現特權級間喚醒的關鍵所在?,F在先簡單介紹一下這兩個概念,具體的用法請參看后面的每子章中的技術實現細節。異步過程調用是一種系統用來當條件合適時在某個特定線程的上下文中執行一個過程的機制。當向一個線程的APC隊列排隊一個APC時,系統將發出一個軟件中斷,當下一次線程被調度時,APC函數將得以運行。APC分成兩種:系統創建的APC稱為內核模式APC,由應用程序創建的APC稱為用戶模式APC。另外只有當線程處于可報警(alertable)狀態時才能運行一個APC。比如調用一個異步模式的ReadFileEx時可以指定一個用戶自定義的回調函數FileIOCompletionRoutine,當異步的I/O操作完成或被取消并且線程處于可報警狀態時函數被調用,這就是APC的典型用法。Kernel32.dll中導出的QueueUserAPC函數可以向指定線程的隊列中增加一個APC對象,因為我們寫的是驅動程序,這并不是我們要的那個函數。很幸運的是在Vwin32.vxd中導出了一個同名函數QueueUserAPC,監控程序攔截到一個文件打開請求后,它馬上調用這個服務排隊一個ring3下客戶程序中需要被喚醒的函數的APC,這個函數將在不久客戶程序被調度時被調用。這種APC喚醒法適用于WIN9X,在WINNT/2000下我們將使用全局共享的事件和信號量對象來解決互相喚醒問題。有關WINNT/2000下的對象組織結構我將在3.4.2節中詳細說明。NT/2000版監控程序中我們將利用KeReleaseSemaphore來喚醒一個在ring3下客戶程序中等待的線程。目前不少反病毒軟件已將驅動使用的查毒模塊移到ring0,即如其所宣傳的“主動與操作系統無縫連接”,這樣做省卻了通信的消耗,但把查毒模塊寫成驅動形式也同時會帶來一些麻煩,如不能調用大量熟知的API,不能與用戶實時交互,所以我們還是選擇剖析傳統的反病毒軟件的監控程序。
其三是驅動程序所占用資源問題。如果由于監控程序頻繁地攔截文件操作而使系統性能下降過多,則這樣的程序是沒有其存在的價值的。本論文將對一個成功的反病毒軟件的監控程序做徹底的剖析,其中就包含有分析其用以提高自身性能的技巧的部分,如設置歷史記錄,內置文件類型過濾,設置等待超時等。
3.3WIN9X下的病毒實時監控3.3.1實現技術詳解WIN9X下病毒實時監控的實現主要依賴于虛擬設備驅動(VXD)編程,可安裝文件系統鉤掛(IFSHook),VXD與ring3下客戶程序的通信(APC/EVENT)三項技術。
我們曾經提到過只有工作于系統核心態的驅動程序才具有有效地完成攔截系統范圍文件操作的能力,VXD就是適用于WIN9X下的虛擬設備驅動程序,所以正可當此重任。當然,VXD的功能遠不止由IFSMGR.vxd提供的攔截文件操作這一項,系統的VXDs幾乎提供了所有的底層操作的接口--可以把VXD看成ring0下的DLL。虛擬機管理器本身就是一個VXD,它導出的底層操作接口一般稱為VMM服務,而其他VXD的調用接口則稱為VXD服務。
二者ring0調用方法均相同,即在INT20(CD 20)后面緊跟著一個服務識別碼,VMM會利用服務識別碼的前半部分設備標識--Device Id找到對應的VXD,然后再利用服務識別碼的后半部分在VXD的服務表(Service Table)中定位服務函數的指針并調用之:
CD 20 INT 20H
01 00 0D 00 DD VKD_Define_HotKey |
這條指令第一次執行后,VMM將以一個同樣6字節間接調用指令替換之(并不都是修正為CALL指令,有時會利用JMP指令),從而省卻了查詢服務表的工作:
FF 15 XX XX XX XX CALL [$VKD_Define_HotKey] |
必須注意,上述調用方法只適用于ring0,即只是一個從VXD中調用VXD/VMM服務的ring0接口。VXD還提供了V86(虛擬8086模式),Win16保護模式,Win32保護模式調用接口。其中V86和Win16保護模式的調用接口比較奇怪:
XOR DI DI
MOV ES,DI
MOV AX,1684 ;INT 2FH,AX = 1684H-->取得設備入口
MOV BX,002A ;002AH = VWIN32.VXD的設備標識
INT 2F
MOV AX,ES ;現在ES:DI中應該包含著入口
OR AX,AX
JE failure
MOV AH,00 ;VWIN32 服務 0 = VWIN32_Get_Version
PUSH DS
MOV DS,WORD PTR CS:[0002]
MOV WORD PTR [lpfnVMIN32],DI
MOV WORD PTR [lpfnVMIN32+2],ES ;保存ES和DI
CALL FAR [lpfnVMIN32] ;call gate(調用門)
ES:DI指向了3B段的一個保護模式回調:
003B:000003D0 INT 30 ;#0028:C025DB52 VWIN32(04)+0742 |
INT30強迫CPU從ring3提升到ring0,然后WIN95的INT30處理函數先檢查調用是否發自3B段,如是則利用引發回調的CS:IP索引一個保護模式回調表以求得一個ring0地址。本例中是0028:C025DB52 ,即所需服務VWIN32_Get_Version的入口地址。
VXD的Win32保護模式調用接口我們在前面已經提到過。一個是DeviceIoControl,我們的ring3客戶程序利用它來和監控驅動進行單向通信;另一個是VxdCall,它是Kernel32.dll的一個未公開的調用,被系統頻繁使用,對我們則沒有多大用處。
你可以參看WIN95DDK的幫助,其中對每個系統VXD提供的調用接口均有詳細說明,可按照需要選擇相應的服務。
可安裝文件系統鉤掛(IFSHook)就源自IFSMGR.VXD提供的一個服務IFSMgr_InstallFileSystemApiHook,利用這個服務驅動程序可以向系統注冊一個鉤子函數。以后系統中所有文件操作都會經過這個鉤子的過濾,WIN9X下文件讀寫具體流程如下:
在讀寫操作進行時,首先通過未公開函數EnterMustComplete來增加MUSTCOMPLETECOUNT變量的記數,告訴操作系統本操作必須完成。該函數設置了KERNEL32模塊里的內部變量來顯示現在有個關鍵操作正在進行。有句題外話,在VMM里同樣有個函數,函數名也是EnterMustComplete。那個函數同樣告訴VMM,有個關鍵操作正在進行。防止線程被殺掉或者被掛起。
接下來,WIN9X進行了一個_MapHandleWithContext(又是一個未公開函數)操作。該操作本身的具體意義尚不清楚,但是其操作卻是得到HANDLE所指對象的指針,并且增加了引用計數。
隨后,進行的乃是根本性的操作:KERNEL32發出了一個調用VWIN32_Int21Dispatch的VxdCall。陷入VWIN32后,其 檢查調用是否是讀寫操作。若是,則根據文件句柄切換成一個FSD能識別的句柄,并調用IFSMgr_Ring0_FileIO。接下來任務就轉到了IFS MANAGER。
IFS MANAGER生成一個IOREQ,并跳轉到Ring0ReadWrite內部例程。Ring0ReadWrite檢查句柄有效性,并且獲取FSD在創建文件句柄時返回的CONTEXT,一起傳入到CallIoFunc內部例程。CallIoFunc檢查IFSHOOK的存在,如果不存在,IFS MANAGER生成一個缺省的IFS HOOK,并且調用相應的VFatReadFile/VFatWriteFile例程(因為目前 MS本身僅提供了VFAT驅動);如果IFSHOOK存在,則IFSHOOK函數得到控制權,而IFS MANAGER本身就脫離了文件讀寫處理。然后,調用被層層返回。KERNEL32調用未公開函數LeaveMustComplete,減少MUSTCOMPLETECOUNT計數,最終回到調用者。
由此可見通過IFSHook攔截本地文件操作是萬無一失的,而通過ApiHook或VxdCall攔截文件則多有遺漏。著名的CIH病毒正是利用了這一技術,實現其駐留感染的,其中的代碼片段如下:
lea eax, FileSystemApiHook-@6[edi] ;取得欲安裝的鉤子函數的地址
push eax
int 20h ;調用IFSMgr_InstallFileSystemApiHook
IFSMgr_InstallFileSystemApiHook = $
dd 00400067h
mov dr0, eax ;保存前一個鉤子的地址
pop eax |
正如我們看到的,系統中安裝的所有鉤子函數呈鏈狀排列。最后安裝的鉤子,最先被系統調用。我們在安裝鉤子的同時必須將調用返回的前一個鉤子的地址暫存以便在完成處理后向下傳遞該請求:
mov eax, dr0 ;取得前一個鉤子的地址
jmp [eax] ; 跳到那里繼續執行 |
對于病毒實時監控來說,我們在安裝鉤子時同樣需要保存前一個鉤子的地址。如果文件操作的對象攜帶了病毒,則我們可以通過不調用前一個鉤子來簡單的取消該文件請求;反之,我們則需及時向下傳遞該請求,若在鉤子中滯留的時間過長--用于等待ring3級查毒模塊的處理反饋--則會使用戶明顯感覺系統變慢。
至于鉤子函數入口參數結構和怎樣從參數中取得操作類型(如IFSFN_OPEN)和文件名(以UNICODE形式存儲)請參看相應的代碼剖析部分。
我們所需的另一項技術--APC/EVENT也是源自一個VXD導出的服務,這便是著名的VWIN32.vxd。這個奇怪的VXD導出了許多與WIN32 API對應的服務:如_VWIN32_QueueUserApc,_VWIN32_WaitSingleObject,_VWIN32_ResetWin32Event,_VWIN32_Get_Thread_Context,_VWIN32_Set_Thread_Context 等。這個VXD叫虛擬WIN32,大概名稱即是由此而來的。雖然服務的名稱與WIN32 API一樣,但調用規則卻大相徑庭,千萬不可用錯。_VWIN32_QueueUserApc用來注冊一個用戶態的APC,這里的APC函數當然是指我們在ring3下以可告警狀態睡眠的待查毒線程。ring3客戶程序首先通過IOCTL把待查毒線程的地址傳給驅動程序,然后當鉤子函數攔截到待查文件時調用此服務排隊一個APC,當ring3客戶程序下一次被調度時,APC例程得以執行。_VWIN32_WaitSingleObject則用來在某個對象上等待,從而使當前ring0線程暫時掛起。我們的ring3客戶程序先調用WIN32 API--CreateEvent創建一組事件對象,然后通過一個未公開的API--OpenVxdHandle將事件句柄轉化為VXD可辯識的句柄(其實應是指向對象的指針)并用IOCTL發給ring0端VXD,鉤子函數在排隊APC后調用_VWIN32_WaitSingleObject在事件的VXD句柄上等待查毒的完成,最后由ring3客戶程序在查毒完畢后調用WIN32 API--SetEvent來解除鉤子函數的等待。
當然,這里面存在著一個很可怕的問題:如果你按照的我說的那樣去做,你會發現它會在一端時間內工作正常,但時間一長,系統就被掛起了。就連驅動編程大師Walter Oney在其著作《System Programming For Windows 95》的配套源碼的說明中也稱其APC例程在某些時候工作會不正常。而微軟的工程師聲稱文件操作請求是不能被中斷掉的,你不能在驅動中阻斷文件操作并依賴于ring3的反饋來做出響應。網上關于這個問題也有一些討論,意見不一:有人認為當系統DLL--KERNEL32在其調用ring0處理文件請求時擁有一個互斥量(MUTEX),而在某些情況下為了處理APC要擁有同樣的互斥量,所以死鎖發生了;還有人認為盡管在WIN9X下32位線程是搶先多任務的,但Win16子系統是以協作多任務來運行的。為了能平滑的運行老的16位程序,它引入了一個全局的互斥量--Win16Mutex。任何一個16位線程在其整個生命周期中都擁有Win16Mutex而32位線程當它轉化成16位代碼也要攫取此互斥量,因為WIN9X內核是16位的,如Knrl386.exe,gdi.exe。如果來自于擁有Win16Mutex的線程的文件請求被阻塞,系統將陷入死鎖狀態。這個問題的正確答案似乎在沒有得到WIN9X源碼的之前永遠不可能被證實,但這是我們實時監控的關鍵,所以必須解決。
我通過跟蹤WIN95文件操作的流程,并反復做實驗驗證,終于找到了一個比較好的解決辦法:在攔截到文件請求還沒有排隊APC之前我們通過Get_Cur_Thread_Handle取得當前線程的ring0tcb,從中找到TDBX,再在TDBX中取得ring3tcb根據其結構,我們從偏移44H處得到Flags域值,我發現如果它等于10H和20H時容易導致死鎖,這只是一個實驗結果,理由我也說不清楚,大概是這樣的文件請求多來自于擁有Win16Mutex的線程,所以不能阻塞;另外一個根本的解決方法是在調用_VWIN32_WaitSingleObject時指定超時,如果在指定時間里沒有收到ring3的喚醒信號,則自動解除等待以防止死鎖的發生。
以上對WIN9X下的實時監控的主要技術都做了詳細的闡述。當然,還有一部分關于VXD的結構,編寫和編譯的方法由于篇幅的關系不可能在此一一說明。需要了解更詳細內容的,請參看Walter Oney的著作《System Programming For Windows 95》,此書尚有臺灣候俊杰翻譯版《Windows 95系統程式設計》。
3.3.2程序結構與流程以下的程序結構與流程分析來自一著名反病毒軟件的WIN9X實時監控虛擬設備驅動程序Hooksys.vxd:
1.當VXD收到來自VMM的ON_SYS_DYNAMIC_DEVICE_INIT消息--需要注意這是個動態VXD,它不會收到系統虛擬機初始化時發送的Sys_Critical_Init, Device_Init和Init_Complete控制消息--時,它開始初始化一些全局變量和數據結構,包括在堆上分配內存(HeapAllocate),創建備用,歷史記錄,打開文件,等待操作,關閉文件5個雙向循環鏈表及用于鏈表操作互斥的5個信號量(調用Create_Semaphore),同時將全局變量_gNumOfFilters即文件名過濾項個數設置為0。
2.當VXD收到來自VMM的ON_W32_DEVICEIOCONTROL消息時,它會從入口參數中取得用戶程序利用DeviceIoControl傳送進來的IO控制代碼(IOCtlCode),以此判斷用戶程序的意圖。和Hooksys.vxd協同工作的ring3級客戶程序guidll.dll會依次向Hooksys.vxd發送IO控制請求來完成一系列工作,具體次序和代碼含義如下:
83003C2B:將guidll取得的操作系統版本傳給驅動(保存在iOSversion變量中),根據此變量值的不同,從ring0tcb結構中提取某些域時將采用不同的偏移,因為操作系統版本不同會影響內核數據結構。
83003C1B:初始化后備鏈表,將guidll傳入的用OpenVxdHandle轉換過的一組事件指針保存在每個鏈表元素中。
83003C2F:將guidll取得的驅動器類型值傳給驅動(保存在DriverType變量中),根據此變量值的不同,調用VWIN32_WaitSingleObject設置不同的等待超時值,因為非固定驅動器的讀寫時間可能會稍長些。
83003C0F:保存guidll傳送的用戶指定的攔截文件的類型,其實這個類型過濾器在查毒模塊中已存在,這里再設置顯然是為了提高處理效率:它確保不會將非指定類型文件送到ring3級查毒模塊,節省了通信的開銷。經過解析的各文件類型過濾塊指針將保存在_gaFileNameFilterArra數組中,同時更新過濾項個數_gNumOfFilters 變量的值。
83003C23:保存guidll中等待查殺打開文件的APC函數地址和當前線程KTHREAD指針。
83003C13:安裝系統文件鉤子,啟動攔截文件操作的鉤子函數FilemonHookProc的工作。
83003C27:保存guidll中等待查殺關閉文件的APC函數地址和當前線程KTHREAD指針。
83003C17:卸載系統文件鉤子,停止攔截文件操作的鉤子函數FilemonHookProc的工作。
以上列出的IO控制代碼的發出是固定,而當鉤子函數啟動后,還會發出一些隨機的控制代碼:
83003C07:驅動將打開文件鏈表的頭元素即最先的請求打開的文件刪除并插入到等待鏈表尾部,同時將元素的用戶空間地址傳送至ring3級等待查殺打開文件的APC函數中處理。
83003C0B:驅動將關閉文件鏈表的頭元素即最先的請求關閉的文件刪除并插入到備用鏈表尾部,同時將元素中的文件名串傳送至ring3級等待查殺關閉文件的APC函數中處理
83003C1F:當查得關閉文件是病毒時,更新歷史記錄鏈表。
下面介紹鉤子函數和guidll中等待查殺打開文件的APC函數協同工作流程,寫文件和關閉文件的處理與之類似:
當文件請求進入鉤子函數FilemonHookProc后,它先從入口參數中取得被執行的函數的代號并判斷其是否為打開操作(IFSFN_OPEN 24H),若非則馬上將這個IRQ向下傳遞,即構造入口參數并調用保存在PrevIFSHookProc中前一個鉤子函數;若是則程序流程轉向打開文件請求的處理分支。分支入口處首先要判斷當前進程是否是我們自己,若是則必須放過去,因為查毒模塊中要頻繁的進行文件操作,所以攔截來自自身的文件請求將導致嚴重的系統死鎖。接下來是從堆棧參數中取得完整的文件路徑名并通過保存的文件類型過濾陣列檢查其是否在攔截類型之列,如通過則進一步檢查文件是否是以下幾個須放過的文件之一:SYSTEM.DAT,USER.DAT,\PIPE\。然后查找歷史記錄鏈表以確定該文件是否最近曾被檢查并記錄過,若在歷史記錄鏈表中找到關于該文件的記錄并且記錄未失效即其時間戳和當前系統時間之差不得大于1F4h,則可直接從記錄中讀取查毒結果。至此才進入真正的檢查打開文件函數_RAVCheckOpenFile,此函數入口處先從備用,等待或關閉鏈表頭部摘得一空閑元素(_GetFreeEntry)并填充之(文件路徑名域等)。接著通過一內核未公開的數據結構中的值(ring3tcb->Flags)判斷可否對該文件請求排隊APC。如可則將空閑元素加入打開文件鏈表尾部并排隊一個ring3級檢查打開文件函數的APC。然后調用_VWIN32_WaitSingleObject在空閑元素中保存的一個事件對象上等待ring3查毒的完成。當鉤子函數掛起不久后,ring3的APC函數得到執行:它會向驅動發出一IO控制碼為83003C07的請求以取得打開文件鏈表頭元素即保存最先提交而未決的文件請求,驅動可以將內核空間中元素的虛擬地址直接傳給它而不必考慮將之重新映射。實際上由于WIN9X內核空間沒有頁保護因而ring3級程序可以直接讀寫之。接著它調用RsEngine.dll中的fnScanOneFile函數進行查毒并在元素中設置查毒結果位,完畢后再對元素中保存的事件對象調用SetEvent喚醒在此事件上等待的鉤子函數。被喚醒的鉤子函數檢查被ring3查毒代碼設置的結果位以此決定該文件請求是被采納即繼續向下傳遞還是被取消即在EAX中放入-1后直接返回,同時增加歷史記錄。
以上只是鉤子函數與APC函數流程的一個簡單介紹,其中省略了諸如判斷固定驅動器,超時等內容,具體細節請參看guidll.dll和hooksys.vxd的反匯編代碼注釋。
3.當VXD收到來自VMM的ON_SYS_DYNAMIC_DEVICE_EXIT消息時,它釋放初始化時分配的堆內存(HeapFree),并清除5個用于互斥的信號量(Destroy_Semaphore)。
3.3.3HOOKSYS.VXD逆向工程代碼剖析在剖析代碼之前有必要介紹一下逆向工程的概念。逆向工程(Reverse Engineering)是指在沒有源代碼的情況下對可執行文件進行反匯編試圖理解機器碼本身的含義。逆向工程的用途很多,如摘掉軟件保護,窺視其設計和編寫技術,發掘操作系統內部奧秘等。本文中我們用到的不少未公開數據結構和服務就是利用逆向的方法得到的。逆向工程的難度可想而知:一個1K大小的exe文件反匯編后就有1000行左右,而我們要逆向的3個文件加起來有80多K,總代碼量是8萬多行。所以必須掌握一定的逆向技巧,否則工作起來將是非常困難的。
首先要完成逆向工作,必須選擇優秀的反匯編及調試跟蹤工具。IDA(The Interactive Disassembler)是一款功能強大的反匯編工具:它以交互能力強而著稱,允許使用者增加標簽,注釋及定義變量,函數名稱;另外不少反匯編工具對于特殊處理的反逆向文件,如導入節損壞等顯得無能為力,但IDA仍可勝任之。當文件被加過殼或插入了干擾指令時 就需要使用調試工具進行動態跟蹤。Numega公司的Softice是調試工具中的佼佼者:它支持所有類型的可執行文件,包括vxd和sys驅動程序,能夠用熱鍵實時呼出,可對代碼執行,內存和端口訪問設置斷點,總之功能非常之強大以至于連微軟總裁比爾蓋茨對此都驚嘆不已。
其次需要對編譯器常用的編譯結構有一定了解,這樣有助于我們理解代碼的含義。
如下代碼是MS編譯器常用的一種編譯高級語言函數的形式:
0001224A push ebp ;保存基址寄存器
0001224B mov ebp, esp
0001224D sub esp, 5Ch ;在堆棧留出局部變量空間
00012250 push ebx
00012251 push esi
00012252 push edi
......
0001225B lea edi, [ebp-34h] ;引用局部變量
......
0001238D mov esi, [ebp+08h] ;引用參數
......
00012424 pop edi
00012425 pop esi
00012426 pop ebx
00012427 leave
00012428 retn 8 ;函數返回
如下代碼是MS編譯器常用的一種編譯高級語言取串長度的形式:
0001170D lea edi, [eax+1Ch] ;串首地址指針
00011710 or ecx, 0FFFFFFFFh ;將ecx置為-1
00011713 xor eax, eax ;掃描串結束符號(NULL)
00011715 push offset 00012C04h ;編譯器優化
0001171A repne scasb ;掃描串結束符號位置
0001171C not ecx ;取反后得到串長度
0001171E sub edi, ecx ;恢復串首地址指針 |
最后一點是必須要有堅忍的毅力和清晰的頭腦。逆向工程本身是件痛苦的工作:高級語言源代碼中使用的變量和函數名字在這里僅是一個地址,需要反復調試琢磨才能確定其含義;另外編譯器優化更為我們理解代碼增加了不少障礙,如上例中那句壓棧指令是將后面函數調用時參數入棧提前放置。所以毅力和頭腦二者缺一不可。
以下進入hooksys.vxd代碼剖析,由于代碼過于龐大,我只選擇有代表性且精彩的部分進行介紹。代碼中的變量和函數及標簽名是我分析后自己添加的,可能會與原作者的意圖有些出入。
3.3.3.1鉤子函數入口代碼C00012E0 push ebp
C00012E1 mov ebp, esp
C00012E3 sub esp, 11Ch
C00012E9 push ebx
C00012EA push esi
C00012EB push edi
C00012EC mov eax, [ebp+arg_4] ; 被執行的函數的代號
C00012EF mov [ebp+var_11C], eax
C00012F5 cmp [ebp+var_11C], 1 ; IFSFN_WRITE
C00012FC jz writefile
C0001302 cmp [ebp+var_11C], 0Bh ; IFSFN_CLOSE
C0001309 jz closefile
C000130F cmp [ebp+var_11C], 24h ; IFSFN_OPEN
C0001316 jz short openfile
C0001318 jmp irqpassdown
鉤子函數入口處,堆棧參數分布如下:
ebp+00h -> 保存的EBP值.
ebp+04h -> 返回地址.
ebp+08h -> 提供這個API要調用的FSD函數的的地址
ebp+0Ch -> 提供被執行的函數的代號
ebp+10h -> 提供了操作在其上執行的以1為基準的驅動器代號(如果UNC為-1)
ebp+14h -> 提供了操作在其上執行的資源的種類。
ebp+18h -> 提供了用戶串傳遞其上的代碼頁
ebp+1Ch -> 提供IOREQ結構的指針。 |
鉤子函數利用[ebp+0Ch]中保存的被執行的函數的代號來判斷該請求的類型。同時它利用[ebp+0Ch]中保存的IOREQ結構的指針從該結構中偏移0ch處path_t ir_ppath域取得完整的文件路徑名稱。
3.3.3.2取得當前進程名稱代碼C0000870 push ebx
C0000871 push esi
C0000872 push edi
C0000873 call VWIN32_GetCurrentProcessHandle ;在eax中返回ring0 PDB(進程數據庫)
C0000878 mov eax, [eax+38h] ;HTASK W16TDB
;偏移38h處是Win16任務數據庫選擇子
C000087B push 0 ;DWORD Flags
C000087D or al,
C000087F push eax ;DWORD Selector
C0000880 call Get_Sys_VM_Handle@0
C0000885 push eax ;取得系統VM的句柄 VMHANDLE hVM
C0000886 call _SelectorMapFlat ;將選擇子基址映射為平坦模式的線形地址
C000088B add esp, 0Ch
C000088E cmp eax, 0FFFFFFFFh ;映射錯誤
C0000891 jnz short loc_C0000899
......
C0000899 lea edi, [eax+0F2h] ;從偏移0F2h取得模塊名稱
;char TDB_ModName[8] |
3.3.3.3通信部分代碼hooksys.vxd中代碼:
C00011BC push ecx ;客戶程序的ring0線程句柄
C00011BD push ebx ;傳入APC的參數
C00011BE push edx ;ring3級APC函數的平坦模式地址
C00011BF call _VWIN32_QueueUserApc ;排隊APC
C00011C4 mov eax, [ebp+0Ch] ;事件對象的ring0句柄
C00011C7 push eax
C00011C8 call _VWIN32_ResetWin32Event;設置事件對象為無信號態
......
C00011E7 mov eax, [ebp+0Ch]
C00011EA push 3E8h ;超時設置
C00011EF push eax ;事件對象的ring0句柄
C00011F0 call _VWIN32_WaitSingleObject ;等待ring3查毒的完成
guidll.dll中代碼:
APC函數入口:
10001AD1 mov eax, hDevice ;取得設備句柄
10001AD6 lea ecx, [esp+4]
10001ADA push 0
10001ADC push ecx ;返回字節數
10001ADD lea edx, [esp+8]
10001AE1 push 4 ;輸出緩沖區大小
10001AE3 push edx ;輸出緩沖區指針
10001AE4 push 0 ;輸入緩沖區大小
10001AE6 push 0 ;輸入緩沖區指針
10001AE8 push 83003C07h ;IO控制代碼
10001AED push eax ;設備句柄
10001AEE call ds:DeviceIoControl
10001AF4 test eax, eax
10001AF6 jz short loc_10001B05
10001AF8 mov ecx, [esp+0] ;得到打開文件鏈表頭元素
10001AFC push ecx
10001AFD call ScanOpenFile ;調用查毒函數
ScanOpenFile函數中:
1000185D call ds:fnScanOneFile ;調用真正查毒庫導出函數
10001863 mov edx, hMutex
10001869 add esp, 8
1000186C mov esi, eax ;查毒結果
1000186E push edx
1000186F call ds:ReleaseMutex
10001875 test esi, esi ;檢查結果
10001877 jnz short OpenFileIsVirus ;如發現病毒則跳到OpenFileIsViru進一步處理
10001879 mov eax, [ebp+10h] ;事件對象的ring3句柄
1000187C mov byte ptr [ebp+16h], 0 ;設置元素中的結果位為無病毒
10001880 push eax
10001881 call ds:SetEvent ;設置事件對象為有信號態喚醒鉤子函數 |
3.4 WINNT/2000下的病毒實時監控3.4.1實現技術詳解WINNT/2000下病毒實時監控的實現主要依賴于NT內核模式驅動編程,攔截IRP,驅動與ring3下客戶程序的通信(命名的事件與信號量對象)三項技術。程序的設計思路和大體流程與前面介紹的WIN9X下病毒實時監控非常相似,只是在實現技術由于運行環境的不同將呈現很大的區別。
WINNT/2000下不再支持VXD,我將在后面剖析的hooksys.sys其實是一種稱為NT內核模式設備驅動的驅動程序。這種驅動程序無論從其結構還是工作方式都與VXD有很大不同。比較而言,NT內核模式設備驅動的編寫比VXD難度更大:因為它要求編程者熟悉WINNT/2000的整體架構和運行機制,NT/2000是純32位微內核操作系統,與WIN9X有很大區別;能靈活使用內核數據結構,如驅動程序對象,設備對象,文件對象,IO請求包,執行體進程/線程塊,系統服務調度表等。另外編程者在編程時還需注意許多重要事項,如當前系統運行的IO請求級,分頁/非分頁內存等。
這里首先介紹幾個重要的內核數據結構,它們在NT內核模式設備驅動的編程中經常被用到,包括文件對象,驅動程序對象,設備對象,IO請求包(IRP),IO堆棧單元(IO_STACK_LOCATION):
文件明顯符合NT中的對象標準:它們是兩個或兩個以上用戶態進程的線程可以共享的系統資源;它們可以有名稱;它們被基于對象的安全性所保護;并且它們支持同步。對于用戶態受保護的子系統,文件對象通常代表一個文件,設備目錄,或卷的打開實例;而對于設備和中間型驅動,文件對象通常代表一個設備。文件對象結構中的域大部分是透明的驅動可以訪問的域包括:
PDEVICE_OBJECT DeviceObject:指向文件于其上被打開的設備對象的指針。
UNICODE_STRING FileName:在設備上被打開的文件的名字,如果當由DeviceObject代表的設備被打開時此串長度(FileName.Length)為0。
驅動程序對象代表可裝載的內核模式驅動的映象,當驅動被加載至系統中時,有I/O管理器負責創建。指向驅動程序對象的指針將作為一個輸入參數傳送到驅動的初始化例程(DriverEntry),再初始化例程(Reinitialize routines)和卸載例程(Unload routine)。驅動程序對象結構中的域大部分是透明的,驅動可以訪問的域包括:
PDEVICE_OBJECT DeviceObject:指向驅動創建的設備對象的指針。當在初始化例程中成功調用IoCreateDevice后這個域將被自動更新。當驅動卸載時,它的卸載例程將使用此域和設備對象中NextDevice域調用IoDeleteDevice來清除驅動創建的每個設備對象。
PDRIVER_INITIALIZE DriverInit:由I/O管理器設置的初始化例程(DriverEntry)入口地址。該例程負責創建驅動程序操作的每個設備的設備對象,需要的話還可以在設備名稱和設備對用戶態可見名稱間創建符號鏈接。同時它還把驅動程序各例程入口點填入驅動程序對象相應的域中。
PDRIVER_UNLOAD DriverUnload:驅動程序的卸載例程入口地址。
PDRIVER_DISPATCH MajorFunction[IRP_MJ_MAXIMUM_FUNCTION+1]:一個或多個驅動程序調度例程入口地址數組。每個驅動必須在此數組中為驅動處理的IRP_MJ_XXX請求集設置至少一個調度入口,這樣所有的IRP_MJ_XXX請求都會 被I/O管理器導入同一個調度例程。當然,驅動程序也可以為每個IRP_MJ_XXX請求設置獨立的調度入口。
當然,驅動程序中可能包含的例程將遠不止以上列出的。比如啟動I/O例程,中斷服務例程(ISR),中斷服務DPC例程,一個或多個完成例程,取消I/O例程,系統關閉通知例程,錯誤記錄例程。只不過我們將要剖析的hooksys.sys中只用到例程中很少一部分,故其余的不予詳細介紹。
設備對象代表已裝載的驅動程序為之處理I/O請求的一個邏輯,虛擬或物理設備。每個NT內核模式驅動程序必須在它的初始化例程中一次或多次調用IoCreateDevice來創建它支持的設備對象。例如tcpip.sys在其DriverEntry中就創建了3個共用此驅動的設備對象:Tcp,Udp,Ip。目前有一種比較流行的稱為WDM(Windows Driver Model)的驅動程序,在大多數情況下,其二進制映像可以兼容WIN98和WIN2000(32位版本)。WDM與NT內核模式驅動程序的主要區別在于如何創建設備:在WDM驅動程序中,即插即用(PnP)管理器告知何時向系統中添加一個設備,或者從系統中刪除設備。WDM驅動程序有一個特殊的AddDevice例程,PnP管理器為共用該驅動的每個設備實例調用該函數;而NT內核模式驅動程序需要做大量額外的工作,它們必須探測自己的硬件,為硬件創建設備對象(通常在DriverEntry中),配置并初始化硬件使其正常工作。設備程序對象結中的域大部分是透明的,驅動可以訪問的域包括:
PDRIVER_OBJECT DriverObject:指向代表驅動程序裝載映象的驅動程序對象的指針。
所有I/O都是通過I/O請求包(IRP)驅動的。所謂IRP驅動,是指I/O管理器負責在系統的非分頁內存中分配一定的空間,當接受用戶發出的命令或由事件引發后,將工作指令按一定的數據結構置于其中并傳遞到驅動程序的服務例程。換言之,IRP中包含了驅動程序的服務例程所需的信息指令。IRP有兩部分組成:固定部分(稱為標題)和一個或多個堆棧單元。固定部分信息包括:請求的類型和大小,是同步請求還是異步請求,用于緩沖I/O的指向緩沖區的指針和由于請求的進展而變化的狀態信息。
PMDL MdlAddress:指向一個內存描述符表(MDL),該表描述了一個與該請求關聯的用戶模式緩沖區。如果頂級設備對象的Flags域為DO_DIRECT_IO,則I/O管理器為IRP_MJ_READ或IRP_MJ_WRITE請求創建這個MDL。如果一個IRP_MJ_DEVICE_CONTROL請求的控制代碼指定METHOD_IN_DIRECT或METHOD_OUT_DIRECT操作方式,則I/O管理器為該請求使用的輸出緩沖區創建一個MDL。MDL本身用于描述用戶模式虛擬緩沖區,但它同時也含有該緩沖區鎖定內存頁的物理地址。
PVOID AssociatedIrp.SystemBuffer:SystemBuffer指針指向一個數據緩沖區,該緩沖區位于內核模式的非分頁內存中于IRP_MJ_READ和IRP_MJ_WRITE操作,如果頂級設備指定DO_BUFFERED_IO標志I/O管理器就創建這個數據緩沖區。對于IRP_MJ_DEVICE_CONTROL操作,如果I/O控制功能代碼指出需要緩沖區,則I/O管理器就創建這個數據緩沖區。I/O管理器把用戶模式程序發送給驅動程序的數據復制到這個緩沖區,這也是創建IRP過程的一部分。這些數據可以是與WriteFile調用有關的數據,或者是DeviceIoControl調用中所謂的輸入數據。對于讀請求,設備驅動程序把讀出的數據填到這個緩沖區,然后I/O管理器再把緩沖區的內容復制到用戶模式緩沖區。對于指定了METHOD_BUFFERED的I/O控制操作,驅動程序把所謂的輸出數據放到這個緩沖區, 然后I/O管理器再把數據復制到用戶模式的輸出緩沖區。
IO_STATUS_BLOCK IoStatus:IoStatus(IO_STATUS_BLOCK)是一個僅包含兩個域的結構,驅動程序在最終完成請求時設置這個結構。IoStatus.Status域將收到一個NTSTATUS代碼。
PVOID UserBuffer:對于METHOD_NEITHER方式的IRP_MJ_DEVICE_CONTROL請求,該域包含輸出緩沖區的用戶模式虛擬地址。該域還用于保存讀寫請求緩沖區的用戶模式虛擬地址,但指定了DO_BUFFERED_IO或DO_DIRECT_IO標志的驅動程序,其讀寫例程通常不需要訪問這個域。當處理一個METHOD_NEITHER控制操作時,驅動程序能用這個地址創建自己的MDL。
任何內核模式程序在創建一個IRP時,同時還創建了一個與之關聯的IO_STACK_LOCATION結構數組:數組中的每個堆棧單元都對應一個將處理該IRP的驅動程序,另外還有一個堆棧單元供IRP的創建者使用。堆棧單元中包含該IRP的類型代碼和參數信息以及完成函數的地址。
UCHAR MajorFunction:該IRP的主功能碼。這個代碼應該為類似IRP_MJ_READ一樣的值,并與驅動程序對象中MajorFunction表的某個派遣函數指針相對應。
UCHAR MinorFunction:該IRP的副功能碼。它進一步指出該IRP屬于哪個主功能類。
PDEVICE_OBJECT DeviceObject:與該堆棧單元對應的設備對象的地址。該域由IoCallDriver函數負責填寫。
PFILE_OBJECT FileObject:內核文件對象的地址,IRP的目標就是這個文件對象。
下面簡要介紹一下WINNT/2000下I/O請求處理流程。先看對單層驅動程序的同步的I/O請求:I/O請求經過子系統DLL子系統DLL調用I/O管理器中相應的服務。I/O管理器以IRP的形式給設備驅動程序發送請求。驅動程序啟動I/O操作。在設備完成了操作并且中斷CPU時,設備驅動程序服務于中斷。最后I/O管理器完成I/O請求。以上六步只是一個非常粗略的描述,其中的中斷處理和I/O完成階段比較復雜。
當設備完成了I/O操作后,它將發出中斷請求服務。設備中斷發生時,處理器將控制權交給內核陷阱處理程序,內核陷阱處理程序將在它的中斷調度表(IDT)中定位用于設備的ISR。驅動程序的ISR例程獲得控制權后,它通常只在設備IRQL上停留獲得設備狀態所必需的一段時間,然后停止設備中斷,接著它排隊一個DPC并清除中斷退出操作。IRQL降低至Dispatch/DPC級之前,所有中間優先級中斷因而可以得到服務。當DPC例程得到控制時,它將啟動設備隊列中下一個I/O請求,然后完成中斷服務。
當驅動的DPC例程執行完后,在I/O請求可以考慮結束之前還有一些工作要做。如某些情況下,I/O系統必須將存儲在系統內存中的數據復制到調用者的虛擬地址空間中,如將操作結果記錄在調用者提供的I/O狀態塊中或執行緩沖I/O的服務將數據返回給調用線程。這樣當DPC例程調用I/O管理器完成原始I/O請求后,I/O管理器會為調用線程調用線程排隊一個核心態APC。當線程被調度執行時,掛起的APC被交付。它將把數據和返回狀態復制到調用者的地址空間,釋放代表I/O操作的IRP,并將調用者的文件句柄或調用者提供的事件或I/O完成端口設置為有信號狀態。如果調用者用異步I/O函數ReadFileEx和WriteFileEx指定了用戶態APC,則此時還需要將用戶態APC排隊。最后可以考慮完成I/O。在文件或其它對象句柄上等待的線程將被釋放。
基于文件系統設備的I/O請求處理過程與此是基本相同的,主要區別在于增加一個或多個附加的處理層。例如讀文件操作,用戶應用程序調用子系統庫Kernel32.dll中的API函數ReadFile,ReadFile接著調用系統庫Ntdll.dll中的NtReadFile,NtReadFile通過一個陷入指令(INT2E)將處理器模式提升至ring0。然后Ntoskrnl.exe中的系統服務調度程序KiSystemService將在系統服務調度表中定位Ntoskrnl.exe中的NtWReadFile并調用之,同時解除中斷。此服務例程是I/O管理器的一部分。它首先檢查傳遞給它們的參數以保護系統安全或防止用戶模式程序非法存取數據,然后創建一個主功能代碼為IRP_MJ_READ的IRP,并將之送到文件系統驅動程序的入口點。以下的工作會由文件系統驅動程序與磁盤驅動程序分層來完成。文件系統驅動程序可以重用一個IRP或是針對單一的I/O請求創建一組并行工作的關聯(associated)IRP。執行IRP的磁盤驅動程序最后可能會訪問硬件。對于PIO方式的設備,一個IRP_MJ_READ操作將導致直接讀取設備的端口或者是設備實現的內存寄存器。盡管運行在內核模式中的驅動程序可以直接與其硬件會話,但它們通常都使用硬件抽象層(HAL)訪問硬件:讀操作最終會調用Hal.dll中的READ_PORT_UCHAR例程來從某個I/O口讀取單字節數據。
WINNT/2000下設備和驅動程序的有著明顯堆棧式層次結構:處于堆棧最底層的設備對象稱為物理設備對象,或簡稱為PDO,與其對應的驅動程序稱為總線驅動程序。在設備對象堆棧的中間某處有一個對象稱為功能設備對象,或簡稱FDO,其對應的驅動程序稱為功能驅動程序。在FDO的上面和下面還會有一些過濾器設備對象。位于FDO上面的過濾器設備對象稱為上層過濾器,其對應的驅動程序稱為上層過濾器驅動程序;位于FDO下面(但仍在PDO之上)的過濾器設備對象稱為下層過濾器,其對應的驅動程序稱為下層過濾器驅動程序。這種棧式結構可以使I/O請求過程更加明了。每個影響到設備的操作都使用IRP。通常IRP先被送到設備堆棧的最上層驅動程序,然后逐漸過濾到下面的驅動程序。每一層驅動程序都可以決定如何處理IRP。有時,驅動程序不做任何事,僅僅是向下層傳遞該IRP。有時,驅動程序直接處理完該IRP,不再向下傳遞。還有時,驅動程序既處理了IRP,又把IRP傳遞下去。這取決于設備以及IRP所攜帶的內容。
通過上面的介紹可得知:如果我們想攔截系統的文件操作,就必須攔截I/O管理器發向文件系統驅動程序的IRP。而攔截IRP最簡單的方法莫過于創建一個上層過濾器設備對象并將之加入文件系統設備所在的設備堆棧中。具體方法如下:首先通過IoCreateDevice創建自己的設備對象,然后調用IoGetDeviceObjectPointer來得到文件系統設備(Ntfs,Fastfat,Rdr或Mrxsmb,Cdfs)對象的指針,最后通過IoAttachDeviceToDeviceStack將自己的設備放到設備堆棧上成為一個過濾器。
這是攔截IRP最常用也是最保險的方法,Art Baker的《Windows NT設備驅動程序設計指南》中有詳細介紹,但用它實現病毒實時監控卻存在兩個問題:其一這種方法是將過濾器放到堆棧的最上層,當存在其它上層過濾器時就不能保證過濾器正好在文件系統設備之上;其二由于過濾器設備需要表現的和文件系統設備一樣,這樣其所有特性都需從文件系統設備中復制。另外文件系統驅動對象中調度例程過濾器驅動必須都支持,這就意味著我們無法使過濾器驅動中的調度例程供自己的ring3級客戶程序所專用,因為原本發往文件系統驅動調度例程的IRP現在都會先從過濾器驅動的調度例程中經過。
所以Hooksys.sys沒有使用上述方法。它的方法更簡單且更為直接:它先通過ObReferenceObjectByName得到文件系統驅動對象的指針。然后將驅動對象中MajorFunction數組中的打開,關閉,清除,設置文件信息,和寫入調度例程入口地址改為Hooksys.sys中相應鉤子函數的入口地址來達到攔截IRP的目的。具體操作細節請參看代碼剖析一節。
下面介紹驅動與ring3下客戶程序的通信技術。與WIN9X下驅動與ring3下客戶程序通信技術相同,NT/2000仍然支持使用DeviceIoControl實現從ring3到ring0的單向通信,但從ring0通過排隊APC來喚醒ring3線程的方法卻無法使用了。原因是我沒有找到一個公開的函數來實現(Walter Oney的書中說存在一個未公開的函數實現從ring0排隊APC)。其實不通過APC我們也可以通過命名的事件/信號量對象來實現雙向喚醒,而且這可能比APC更為可靠些。
對象管理器在Windows NT/2000內核中占了極其重要的位置,其一個最主要職能是組織管理系統內核對象。在Windows NT/2000中,內核對象管理器大量引入了C++面向對象的思想,即所有內核對象都封裝在對象管理器內部,除對象管理器自己以外,對其他所有想引用內核對象結構成員的子系統都是不透明的,也即都需通過對象管理器訪問這些結構。Microsoft極力推薦內核驅動代碼遵循這一原則(用戶態代碼根本不能直接訪問這些數據),它提供了一系列以Ob開頭的例程供我們使用。
內核已命名對象存于系統全局命名內核區,與傳統的DOS目錄和文件組織方式相似,對象管理器也采用樹狀結構管理這些對象,這樣可以快速檢索內核對象。當然使用這種樹狀結構組織內核已命名對象,還有另一個優點,那就是使所有已命名對象組織的十分有條理,如設備對象處于\Device下,而對象類型名稱處于\ObjectTypes下等等。再者這樣也能達到使用戶態進程僅能訪問\??與\BaseNamedObjects下的對象,而內核態代碼則沒有任何限制的目的。至于系統內部如何組織管理這些已命名對象,其實Windows NT/2000內部由內核變量ObpRootDirectoryObject指向的Directory對象代表根目錄,使用哈希表(HashTable)來組織管理這些命名內核對象。
Hooksys.sys中使用命名的信號量來喚醒ring3級線程。具體做法如下:首先在guidll.dll中調用CreateSemaphore創建一個命名信號量Hookopen并設為無信號狀態,同時調用CreateThread創建一個線程。線程代碼的入口處通過調用WaitForSingleObject在此信號量上等待被ring0鉤子函數喚醒查毒。驅動程序這邊則在初始化過程中通過未公開的例程ObReferenceObjectByName(\BaseNamedObjects\Hookopen)得到命名信號量對象Hookopen的指針,當它攔截到文件打開請求時調用KeReleaseSemaphore將Hookopen置為有信號狀態喚醒ring3級等待檢查打開文件的線程。其實guidll.dll共創建了兩個命名信號量,還有一個Hookclose用于喚醒ring3級等待檢查關閉文件的線程。
guidll.dll中使用命名的事件來喚醒暫時掛起等待查毒完畢的ring0鉤子函數。具體做法如下:Hooksys.sys在其初始化過程中通過ZwCreateEvent函數創建一組命名事件對象(此處必須合理設置安全描述符,否則ring3線程將無法使用事件句柄)并得到其句柄,同時通過ObReferenceObjectByHandle得到句柄引用的事件對象的指針。然后Hooksys.sys將這一組事件句柄和指針對以及事件名保存在備用鏈表的每個元素中:ring3使用句柄,ring0使用指針。當鉤子函數攔截到文件請求時它首先喚醒ring3查毒線程,然后馬上調用KeWaitForSingleObject在一個事件\BaseNamedObjects\Hookxxxx上等待查毒的完成。而被喚醒的ring3查毒線程通過OpenEventA函數由事件名字得到其句柄,在結束查毒后發出一個SetEvent調用將事件置為有信號狀態從而喚醒ring0掛起的鉤子函數。當然,以上討論僅限于打開文件操作,鉤子函數在攔截到其它文件請求時并不調用KeWaitForSingleObject等待查毒的完成,而是喚醒ring3查毒線程后直接返回;相應的ring3查毒線程也就不必在查毒完成后調用SetEvent進行遠程喚醒。
另外在編寫NT內核模式驅動程序時還必須注意一些事項。首先是中斷請求級(IRQL),這是在進行NT驅動編程時特別值得注意的問題。每個內核例程都要求在一定的IRQL上運行,如果在調用時不能確定當前IRQL在哪個級別,則可調用KeGetCurrentIrql獲取當前的IRQL值并進行判斷。例如欲獲得指向當前進程Eprocess的指針可以考慮先判斷當前的IRQL,如大于等于DISPATCH_LEVEL時可調用IoGetCurrentProcess;而當IRQL小于調度/延遲過程調用級別時(DISPATCH_LEVEL/DPC)則可使用PsGetCurrentProcessId和PsLookupProcessByProcessId。其次要注意的問題是分頁/非分頁內存。由于執行在提升的IRQL級上時系統將不能處理頁故障,因為系統在APC級處理頁故障,因而這里總的原則是:執行在高于或等于DISPATCH_LEVEL級上的代碼絕對不能造成頁故障。這也意味著執行在高于或等于DISPATCH_LEVEL級上的代碼必須存在于非分頁內存中。此外,所有這些代碼要訪問的數據也必須存在于非分頁內存中。最后是同步互斥問題,這對于如病毒實時監控等系統范圍共享的驅動程序尤顯重要。雖然在Hooksys中沒有創建多線程(PsCreateSystemThread),但由于它掛接了系統文件鉤子,系統中所有線程的文件請求都會從Hooksys中經過。當一個線程的文件請求被處理過程中Hooksys會去訪問一些全局共享的數據,如過濾器,歷史記錄等,有可能在訪問進行到一半時該線程由于某種原因被搶占了,結果是其它線程的文件請求經過時Hooksys訪問的共享數據將是錯誤的。為此驅動程序必須合理使用自旋鎖,互斥量,資源等內核同步對象對共享全局數據的所有線程進行同步。
3.4.2程序結構與流程以下的程序結構與流程分析來自一著名反病毒軟件的WINNT/2000實時監控NT內核模式設備驅動程序Hooksys.sys:
1.初始化例程(DriverEntry):調用_GetProcessNameOffset取得進程名在Eprocess中的偏移。初始化備用,打開文件等待操作,關閉文件,歷史記錄5個雙向循環鏈表及用于鏈表操作互斥的4把自旋鎖和1個快速互斥量。將全局變量_IrqCount(IRP記數)設置為0。創建卸載保護用事件對象。為文件名過濾數組初始化同步用資源變量。在系統全局命名內核區中檢索Hookopen和Hookclose兩個命名信號量( _CreateSemaphore)。為備用(_AllocateBuffer)和歷史記錄(_AllocatHistoryBuf)鏈表在系統非分頁池中分配空間,同時創建一組命名事件對象Hookxxxx并保存至備用鏈表的每個元素中(_CreateOneEvent)。創建設備,設置驅動例程入口,為設備建立符號連接。創建磁盤驅動器設備對象指針(_QuerySymbolicLink)和文件系統驅動程序對象指針(_HookSys)列表。
2.打開例程(IRP_MJ_CREATE):將備用鏈表用系統非分頁內存(首地址保存在_SysBufAddr中)映射到用戶空間中(保存在_UserBufAddr)以便從用戶態可以直接訪問這段內存(_MapMemory)。
3.設備控制例程(IRP_MJ_DEVICE_CONTROL):它會從入口IRP當前堆棧單元中取得用戶程序利用DeviceIoControl傳送進來的IO控制代碼(IoControlCode),以此判斷用戶程序的意圖。和Hooksys.sys協同工作的ring3級客戶程序guidll.dll會依次向Hooksys.sys發送IO控制請求來完成一系列工作,具體次序和代碼含義如下:
83003C2F:將guidll取得的驅動器類型值傳給驅動(保存在DriverType變量中),根據此變量值的不同,設置不同的等待(KeWaitForSingleObject)超時值,因為非固定驅動器的讀寫時間會稍長些。
83003C0F:保存guidll傳送的用戶指定的攔截文件的類型,其實這個類型過濾器在查毒模塊中已存在,這里再設置顯然是為了提高處理效率:它確保不會將非指定類型文件送到ring3級查毒模塊,節省了通信的開銷。經過解析的各文件類型過濾塊指針將保存在_gaFileNameFilterArra數組中,同時更新過濾項個數_gNumOfFilters變量的值。
83003C13:修改文件系統驅動程序對象調度例程入口,啟動攔截文件操作的鉤子函數的工作。
83003C17:恢復文件系統驅動程序原調度例程入口,停止攔截文件操作的鉤子函數工作。
以上列出的IO控制代碼的發出是固定,而當鉤子函數啟動后,還會發出一些隨機的控制代碼:
83003C07:驅動將打開文件鏈表的頭元素即最先的請求打開的文件刪除并插入到等待鏈表尾部,同時將元素的用戶空間地址傳送至ring3級等待查殺打開文件的線程中處理。
83003C0B:驅動將關閉文件鏈表的頭元素即最先的請求關閉的文件刪除并插入到備用鏈表尾部,同時將元素中的文件名串傳送至ring3級等待查殺關閉文件的線程中處理
83003C1F:當查得關閉文件是病毒時,更新歷史記錄鏈表。
下面介紹鉤子函數_HookCreateDispatch和guidll中等待查殺打開文件的線程協同工作流程,而關閉,清除,設置文件信息,和寫入操作的處理與此大同小異:
當文件請求進入鉤子函數_HookCreateDispatch后,它首先從入口IRP中定位當前的堆棧單元并從中取得代表此次請求的文件對象。然后判斷當前進程是否為我們自己,若是則必須放過去,因為查毒模塊中要頻繁的進行文件操作,所以攔截來自ravmon的文件請求將導致嚴重的系統死鎖。接下來利用堆棧單元中的文件對象取得完整的文件路徑名并確保文件不是:\PIPE\,\IPC。之后查找歷史記錄鏈表以確定該文件是否最近曾被檢查并記錄過,若在歷史記錄鏈表中找到關于該文件的記錄并且記錄未失效即其時間戳和當前系統時間之差不得大于1F4h,則可直接從記錄中讀取查毒結果。如歷史鏈表中沒有該文件的記錄則利用保存的文件類型過濾陣列檢查文件是否在被攔截的文件類型之列。至此才進入真正的檢查打開文件函數_RAVCheckOpenFile,此函數入口處先從備用,等待或關閉鏈表頭部摘得一空閑元素(_GetFreeEntry)并填充之,如文件路徑名域等。接著將空閑元素加入打開文件鏈表尾部并釋放Hookopen信號量喚醒ring3下等待檢查打開文件的線程。然后調用KeWaitForSingleObject在空閑元素中保存的一個事件對象上等待ring3查毒的完成。當鉤子函數掛起后,ring3查毒線程得到執行:它會向驅動發出一IO控制碼為83003C07的請求以取得打開文件鏈表頭元素即保存最先提交而未決的文件請求,驅動會將元素映射到用戶空間中的偏移地址直接傳給它。接著它調用RsEngine.dll中的fnScanOneFile函數進行查毒并在元素中設置查毒結果位,完畢后再對元素中保存的事件對象調用SetEvent喚醒在此事件上等待的鉤子函數。被喚醒的鉤子函數檢查被ring3查毒代碼設置的結果位以此決定該文件請求是被采納即調用保存的原調度例程還是被取消即調用IofCompleteRequest直接返回,同時增加歷史記錄。
以上只是鉤子函數與ring3線程流程的一個簡單介紹,其中省略了諸如判斷固定驅動器,超時等內容,具體細節請參看guidll.dll和hooksys.sys的反匯編代碼注釋。
4.關閉例程(IRP_MJ_CLOSE):停止鉤子函數工作,恢復文件系統驅動程序原調度入口(_StopFilter)。解除到用戶空間的內存映射。
5.卸載例程(DriverUnload):停止鉤子函數工作,恢復文件系統驅動程序原調度入口。刪除設備和符號連接。刪除初始化時創建的一組命名事件對象Hookxxxx,包括解除指針引用,關閉打開的句柄。釋放為MDL(_pMdl),備用鏈表(_SysBufAddr),歷史記錄鏈表(_HistoryBuf)和過濾器分配的內存空間。刪除為文件名過濾數組訪問同步設置的資源變量(_FilterResource)。解除對系統全局命名內核區中Hookopen和Hookclose兩個命名信號量的指針引用。
3.4.3HOOKSYS.SYS逆向工程代碼剖析3.4.3.1取得當前進程名稱代碼初始化例程中取得進程名在Eprocess中偏移
00011889 call ds:__imp__IoGetCurrentProcess@0 ;
得到當前進程System的Eprocess指針
0001188F mov edi, eax ;Eprocess基地址
00011891 xor esi, esi ;初始化偏移為0
00011893 lea eax, [esi+edi] ;掃描指針
00011896 push 6 ;進程名長度
00011898 push eax ;掃描指針
00011899 push offset $SG8452 ; "System" ;進程名串
0001189E call ds:__imp__strncmp ;比較掃描指針處是否為進程名
000118A4 add esp, 0Ch ;恢復堆棧
000118A7 test eax, eax ;測試比較結果
000118A9 jz short loc_118B9 ;找到則跳出循環
000118AB inc esi ;增加偏移量
000118AC cmp esi, 3000h ;在12K范圍中掃描
000118B2 jb short loc_11893 ;在范圍之內則繼續比較
鉤子函數開始處取得當前進程名
00010D1E call ds:__imp__IoGetCurrentProcess@0 ;得到當前進程System的Eprocess指針
00010D24 mov ecx, _ProcessNameOffset ;取得保存的進程名偏移量
00010D2A add eax, ecx ;得到指向進程名的指針 |
3.4.3.2啟動鉤子函數工作代碼000114F4 push 4 ;預先將文件系統驅動對象個數壓棧
000114F6 mov esi, offset FsDriverObjectPtrList ;
取得文件系統驅動對象指針列表偏移地址
000114FB pop edi ;用EDI做記數器,初始值為4
000114FC mov eax, [esi] ;取得第一個驅動對象的指針
000114FE test eax, eax ;測試是否合法
00011500 jz short loc_11548 ;不合法則繼續下一個修改驅動對象
00011502 mov edx, offset _HookCreateDispatch@8 ;
取得自己的鉤子函數的偏移地址
00011507 lea ecx, [eax+38h] ;取得對象中打開調度例程(IRP_MJ_CREATE)偏移
0001150A call @InterlockedExchange@8 ;
原子操作,替換驅動對象中打開調度例程的入口為鉤子函數的偏移地址
0001150F mov [esi-10h], eax ;保存原打開調度例程的入口 |
3.4.3.3映射系統內存至用戶空間代碼0001068E push esi ;系統內存大小
0001068F push _SysBufAddr ;系統內存基地址
00010695 call ds:__imp__MmSizeOfMdl@8 ;計算描述系統內存所需內存描述符表(MDL)大小
0001069B push 206B6444h ;調試用標簽
000106A0 push eax ;MDL大小
000106A1 push 0 ;在系統非分頁內存池中分配
000106A3 call ds:__imp__ExAllocatePoolWithTag@12 ;為MDL分配內存
000106A9 push esi ;系統內存大小
000106AA mov _pMdl, eax ;保存MDL指針
000106AF push _SysBufAddr ;系統內存基地址
000106B5 push eax ;MDL指針
000106B6 call ds:__imp__MmCreateMdl@12 ;初始化MDL
000106BC push eax ;MDL指針
000106BD mov _pMdl, eax ;保存MDL指針
000106C2 call ds:__imp__MmBuildMdlForNonPagedPool@4
;填寫MDL后物理頁面數組
000106C8 push 1 ;訪問模式
000106CA push _pMdl ;MDL指針
000106D0 call ds:__imp__MmMapLockedPages@8 ;映射MDL描述的物理內存頁面
......
000106DB mov _UserBufAddr, eax ;保存映射后的用戶空間地址
_UserBufAddr 和_SysBufAddr映射到相同的物理地址。 |
主要參考文獻David A. Solomon, Mark Russinovich 《Inside Microsoft Windows 2000》September 2000
David A. Solomon 《Inside Windows NT》 May 1998
Prasad Dabak,Sandeep Phadke,Milind Borate 《Undocumented Windows NT》October 1999
Matt Pietrek 《Windows 95 System Programming Secrets》 March 1996
Walter Oney 《System Programming for Windows 95》 March 1996
Walter Oney 《Programming the Windows Driver Model》 1999
陸麟 《WINDOWS9X文件讀寫Internal》2001