PE文件格式與API HOOK

對於windows低層編程來說,進行API攔截始終是一件讓人激動的事,用自己的代碼來改變其它程序的行爲,還有比這個更有趣嗎?而且,在實現API攔截的過程中我們還有機會去熟悉許多在RAD編程環境中很少接觸的東西,如DLL遠程注入、內存管理,PE文件格式等知識。許多商業軟件,如金山詞霸等詞典軟件,各種即時漢化軟件、甚至一些網絡遊戲的外掛中都用到了這種技術,各種調試工具中多多少少也要用到這種技術。
實現API攔截的一種方法是修改PE文件中的輸入地址表。在32windows中,無論是.EXE文件,還是.DLL文件都是採用PE文件格式,PE文件格式將程序所有調用的API函數的地址信息存放在輸入地址表中,而在程序碼中,對API的調用使用的地址不是API函數的地址,而是輸入地址表中該API函數對應的地址。我們只要修改輸入地址表中函數地址就可以攔截API了。首先我們來熟悉一下PE文件格式,由於PE文件格式本身比較複雜,涉及到的數據類型較多,所以在這裏只介紹一部分內容。我已經畫了一幅示意圖,大致描繪出PE文件格式,其中有的結構中的數據是一個RVA,凡是這樣數據在圖中都已註明。
PE文件是由一個DOS文件頭開始的,緊接在它後面的是一個DOS stub,它們合在一起實際上是一個完整的DOS程序,在PE文件中提供它們最主要的目的是由於兼容性,如果我們在DOS中去執行一個win32程序,這個DOS程序就會顯示出“This program can not run in dos mode”之類的語句。在它們的後面纔是真正的PE文件頭,所以這兩個部分並不重要,但是由於每一個DOS stub的大小並不一樣,所以我們必須要用DOS文件頭中一個成員e_lfanew來定位PE文件頭,DOS文件頭被定義成IMAGE_DOS_HEADER結構。它的成員e_lfanew中含有PE文件頭的“相對虛擬地址”(RVA)。
在這裏我們要解釋一下RVA(相對虛擬地址),在PE文件中經常見到這個名詞,所謂RVA指的是相對於模塊起始地址的偏移量,所以RVA必須要加上模塊的起始地址才能得到真正的地址。之所以稱它爲“虛擬”的是因爲在一個PE格式文件沒有被裝入內存之前,RVA是沒有意義的,只有PE格式文件被裝入內存後,RVA纔是有意義的。
舉例說明:如上圖所示:
假設某個PE文件的裝入虛擬地址(VA)爲400000h,而這個PE文件中的DOS頭中的成員e_lfanew的值爲40hRVA)的話,那麼它所指的PE文件頭的虛擬地址(VA)就是400040h
DOS stub後面纔是我們感興趣的PE文件頭,它被定義成IMAGE_NT_HEADERS結構,這個結構中含有整個PE文件的信息,它的定義如下:( 這裏用彙編語言定義,在winnt.h中有基於C語言的定義)
IMAGE_NT_HEADERS STRUCT
 Signature dd ?
 FileHeader IMAGE_FILE_HEADER <>
 OptionalHeader IMAGE_OPTIONAL_HEADER32<>
IMAGE_NT_HEADERS ENDS
而這個結構中,與我們API攔截有關的是最後一項OptionalHeader,它被定義成IMAGE_OPTIONAL_HEADER32結構,這個結構共有31個域,定義如下:(省略了一部分與API攔截無關的)
IMAGE_OPTIONAL_HEADER32 STRUCT
 NumberOfRvaSizes dd ?
 DataDirectory IMAGE_DATA_DIRECTORY 16 dup(<>)
IMAGE_OPTIONAL_HEADER32 STRUCT
其中我們需要的是最後的DataDirectory域,這個域被稱爲“數據目錄”,它是由16IMAGE_DATA_DIRECTORY結構組成的數組,每個數組中存放了PE文件的一個重要的數據結構的信息,其中第二個元素稱爲“引入表”,在“引入表”中存放了PE文件所調用的DLL及外部函數的信息,包括引入函數所在DLL名,引入函數名,引入函數地址等。我們實現API攔截的方法就是要將“引入表”中的引入函數地址改成我們自已的函數地址。IMAGE_DATA_DIRECTORY定義如下:
IMAGE_DATA_DIRECTORY STRUCT
 VirtualAddress dd ?
 isize dd ?
IMAGE_DATA_DIRECTORY ENDS
其中VirtualAddress 是數據結構的相對虛擬地址,isize含有VirtualAddress所指向的數據結構的大小。舉例來說,一個關於“引入表”的IMAGE_DATA_DIRECTORY結構中,VirtualAddress包含了“引入表”的RVA。利用這個RVA我們就可以找到“引入表”。
“引入表”本身是一個由IMAGE_IMPORT_DESCRIPTOR結構組成的數組,數組中的每個IMAGE_IMPORT_DESCRIPTOR元素包含一個PE文件引用的DLL的信息,所以數組中元素個數與PE文件引用的DLL個數有關。這個數組以一個全0IMAGE_IMPORT_DESCRIPTOR結構結束。下面看一下IMAGE_IMPORT_DESCRIPTOR結構的定義:
IMAGE_IMPORT_DESCRIPTOR STRUCT
 union
     Characteristics dd ?
     OriginalFirstThunk dd ?
 ends
 TimeDataStamp dd ?
 ForarderChain dd ?
 Name1 dd ?
FirstThunk dd ?
IMAGE_IMPORT_DESCRIPTOR ENDS
這個結構中的成員並不是每一個都和我們討論的API攔截有關,但是它實在是太有趣了,所以在這裏介紹一下它的部分成員。
第一個成員是一個union子結構,這個子結構其實只是給OriginalFirstThunk加了個別名而已,該成員含有指向一個IMAGE_THUNK_DATA結構數組的RVA
那麼什麼是IMAGE_THUNK_DATA呢?它的定義如下:
IMAGE_THUNK_DATA STRUCT
    union u1
        ForwarderString dd ?
        Function dd         ?
        Ordinal dd          ?
        AddressOfData dd    ?
    ends
IMAGE_THUNK_DATA ENDS
雖然看起來很複雜,其實它不過是一個DWORD型的變量,一般我們將它看作是一個指向IMAGE_IMPORY_BY_NAME結構的RVA。至於IMAGE_IMPORY_BY_NAME結構它存放了一個引入函數的信息。定義如下:
IMAGE_IMPORT_BY_NAME STRUCT
 Hint dw ?
 Name1 db ?
IMAGE_IMPORT_BY_NAME ENDS
其中Hint指示本函數在DLL的“引出表”中的索引號,而Name1含有函數名。(這個成員本來的定義應該是Name,但是Name是彙編語言的僞指令,所以用Name1代替,注意Name1本身就含有函數名,它不是一個RVA。)
真正和我們討論的主題API攔截有關的是FirstThunk。它也是指向一個IMAGE_THUNK_DATA結構數組的RVA,這個IMAGE_THUNK_DATA 和前面所說的OriginalFirstThunk所指向的IMAGE_THUNK_DATA並不是同一個數組,不過它們是有聯繫的,在PE文件未被裝入內存之前,這兩個數組的內容完全相同,但是在PE文件被裝入內存後,OrigianalFirstThunk所指向的IMAGE_THUNK_DATA結構數組的內容保持不變,還是指向IMAGE_IMPORT_BY_NAME結構,而FirstThunk所指向的IMAGE_THUNK_DATA結構數組的內容就改成了引入函數的真實地址了,這時我們稱這個結構數組爲輸入地址表IATImport Address Table)。我們實現API的關鍵就是修改IAT中的數據,將它改成我們自己的函數的地址。
看了上面的介紹你是否已經知道我們API攔截的實現方法了,對,我們先取得模塊的起始地址,然後利用IMAGE_DOS_HEADER結構中的e_lfanew域來定位到IMAGE_NT_HEADER結構,獲取OptionalHeader結構中的數據目錄地址,取數據目錄的第二個成員,提取其VirtualAddress的值,這樣,我們得到了IMAGE_IMPORT_DESCRIPTOR結構數組,也就是“引入表”。關鍵代碼如下:
mov eax,hMoudle ;hMoudle爲模塊起始地址
 mov esi,eax
     assume esi :ptr IMAGE_DOS_HEADER ;假設esi指向一個IMAGE_DOS_HEADER結構
 add esi,[esi].e_lfanew          ;此時esi指向PE header
     assume esi :ptr IMAGE_NT_HEADERS ;假設esi指向一個IMAGE_NT_HEADERS結構
mov ebx,[esi].OptionalHeader.DataDirectory[sizeof IMAGE_DATA_DIRECTORY].VirtualAddress ;取引入表的RVA
add eax,ebx ;RVA加上模塊起始地址得到引入表的實際地址.
 mov esi,eax
     assume esi :ptr IMAGE_IMPORT_DESCRIPTOR;假設esi是指向一個IMAGE_IMPORT_DESCRIPTOR結構
我們遍歷這個數組中的每一個IMAGE_IMPORT_DESCRIPTOR結構,檢查其中由FirstThunk所指向的IAT表,如果其中有函數地址和我們要攔截的API函數地址相同,就修改它。
invoke GetModuleHandle,addr DllName ;取得要攔截API所在的DLL名稱
 invoke GetProcAddress,eax,addr ApiName 
 mov ProcAddr,eax ;取得我們要攔截的API的地址並存放在ProcAddr中。
    .while!([esi].OriginalFirstThunk==0 && [esi].TimeDateStamp==0 && [esi].ForwarderChain==0 && [esi].Name1==0 && [esi].FirstThunk==0) ;引入表由一個全0IMAGE_IMPORT_DESCRIPTOR作爲結束
         mov edi,hMoudle
         add edi,[esi].FirstThunk ;獲得IAT表的起始地址
              assume edi :ptr IMAGE_THUNK_DATA ;假設edi是指向IMAGE_THUNK_DATA
         .while [edi]!=0 ;檢查IAT表中的每一項,如果等於我們要攔截的API地址,則修改
         mov ebx,[edi] ;由於IMAGE_THUNK_DATA數組存放了引入函數的地址所以此時ebx中是函數地址
         .if ebx==ProcAddr ;如果和我們要攔截的API地址相同
          invoke GetCurrentProcess
          mov ProcHandle,eax ;得到當前進程的句柄並放在ProcHandle
          invoke VirtualProtectEx,eax,edi,sizeof DWORD,PAGE_READWRITE,addr Old ;修改內存屬性
          mov eax,offset NewExitProcess ;NewExitProcess是我們自己的API實現函數
          mov NewAddr,eax
          invoke WriteProcessMemory,ProcHandle,edi,addr NewAddr,sizeof DWORD,NULL ;進行改寫
         .endif
         add edi, sizeof IMAGE_THUNK_DATA
         .endw
        add esi,sizeof IMAGE_IMPORT_DESCRIPTOR
     .endw     
由模塊起始地址查找IAT表地址的示意圖如下:
IMAGE_IMPORT_DESCRIPTOR結構中的Name1含有指向DLL名字的RVA,利用它你可以列舉一個PE文件引用了哪些DLL
好,現在我們已經知道實現API攔截的關鍵了,但是還有一些問題沒有解決。
先來說說第一個問題,因爲Windows是不允許一個進程去訪問另一個進程的內存空間的,所以我們不能用一個進程去修改另一個進程的IAT表,要想修改進程的IAT表,只能由這個進程自已來做,一個已經寫好的程序當然不會好好地去修改它自身的IAT表,不過我們可以將我們自己的DLL注入到它的進程空間裏去,一旦DLL注入到一個進程的內存空間中以後,這個DLL就成了這個進程的一部分,它就能夠訪問這個進程的所有的內存空間,當然也就能修改它的IAT表了。將一個DLL注入到一個目標進程中去的方法有很多,但是考慮到兼容性,用windows提供給我們的系統範圍的windows鉤子來完成DLL注入是最好的。我們可以用SetWindowsHookEx 來安裝一個系統鉤子,這個API的用法如下:HHOOK SetWindowsHookEx(
 int idHook,        // 鉤子類型,本例中指定爲WH_GETMESSAGE鉤子,其它的類型參見MSDN
 HOOKPROC lpfn,     //鉤子的回調消息函數。
 HINSTANCE hMod,    //指定回調消息函數所在的DLL句柄。
 DWORD dwThreadId   // 鉤子監視的線程句柄,本例中因爲要的是系統範圍鉤子,故設爲0
);
我們安裝一個系統鉤子的主要目的是用它來將我們的DLL注入到其它進程中去,所以鉤子的回調消息函數並不重要,只要調用一下CallNextHookEx來向後傳遞鉤子就可以了。你可以調用UnhookWindowsHookEx來卸載一個系統鉤子,它只要一個參數:鉤子句柄。
第二個問題是DLL被注入到目標進程的內存空間中以後,它在什麼時候進行修改呢?這要用到DLL的入口點函數,每一個DLL都有一個入口點函數,當DLL被裝入內存時,或是它從內存中卸載時這個入口點函數都會自動地被執行,本來入口點函數主要是做一些初始化工作或是做一些收尾工作的,我們的API攔截代碼放在這裏是最恰當的。因爲一個單個進程空間是由一個可執行模塊和若干個DLL模塊組成的,而一個程序在運行時,加載程序將可執行模塊加載進內存空間後會接着加載這個進程的所有的DLL模塊,在加載我們注入的DLL模塊時,入口點函數自動被執行,進行IAT表的修改工作。此時,進程的主線程還沒有開始運行。在進程所有的DLL被全部裝入內存後,主線程纔開始執行,應用程序也纔開始運行,這時我們已經將它的IAT表修改了,在它調用被我們修改了地址的API時,它的調用就會轉到我們自己的函數中去,這樣就實現了API攔截。DLL的入口點函數般寫法如下:
DllEntry proc hInstDll:HINSTANCE,reason:DWORD,reserved1:DWORD ;DLL的入口點函數
.if reason==DLL_PROCESS_ATTACH ;DLL第一次被裝入時調用
 push hInstDll 
 pop DllhInst    ;保存DLL的句柄在變量DllhInst
………
.if reason== DLL_PROCESS_DETACH ;當DLL從進程空間卸出時調用
    ………
DllEntry endp
不過,一般windows是不允許我們動態修改代碼段的,因爲代碼段一般只具有執行屬性而不具有讀寫屬性,如果我們去寫一個不具備寫屬性的內存空間時,windows會出現一個保護性錯誤,所以我們在修改之前必須要使我們想要修改的內存地址具有讀寫屬性,這個工作可以用VirtualProtectEx來完成。它的具體參數在MSDN中有詳細說明。有一種說法認爲直接用WriteProcessMemory就能夠修改內存,這個說法其實不一定正確,如果事先不用VirtualProtectEx來修改內存屬性的話,WriteProcessMemory並不總是能成功地完成修改。代碼如下:
mov ProcHandle,eax ;得到當前進程的句柄並放在ProcHandle中
          invoke VirtualProtectEx,eax,edi,sizeof DWORD,PAGE_READWRITE,addr Old ;修改內存屬性
          mov eax,offset NewExitProcess ;NewExitProcess是我們自己的API實現函數
          mov NewAddr,eax
          invoke WriteProcessMemory,ProcHandle,edi,addr NewAddr,sizeof DWORD,NULL ;進行改寫
另外,如果我們的DLL由於某種原因從內存中卸出,這時目標進程的IAT中的地址就會變成一個無效的值,進程如果這時調用被攔截API的話就一定會崩潰掉,所以在DLL被卸出進程的內存空間時,我們一定要將IAT表中數據恢復。這個恢復工作當然也是放在DLL的入口點函數,因爲在DLL被卸出時它也被自動執行。
還有一個問題是如何取得模塊的起始地址。在PE文件中所用的都是RVA,只有將RVA加上模塊的起始地址才能得到真正的內存地址,而正如我們上面所說,一個進程的地址空間是由一個可執行模塊和若干個DLL模塊組成的,DLL模塊同樣有自己的引入表,我們要攔截的API有可能在可執行模塊中被調用,也有可能在DLL模塊中被調用,所以爲了正確的攔截,我們必須列舉出進程空間中所有的模塊,修改它們的IAT表。這裏介紹幾個需要的API:CreateToolhelp32Snapshot,作用是創建一個進程快照,它有兩個參數,指定第一個參數爲TH32CS_SNAPMODULE,第二個參數爲0,此時這個API返回一個快照句柄,再利用Module32FirstModule32Next這兩個API就可以列出這個進程中的所有模塊地址。這裏要注意的是:我們進行修改工作的DLL本身也是進程中的一個模塊,而且這個模塊的IAT表中一定會有被攔截的API,對這個模塊是不能進行修改的,所以在對進程中的模塊進行修改之前先要判斷一個這個模塊是不是這個DLL自身,我們可以用VirtualQuery來得到進行修改工作的DLL的起始地址,利用這個起始地址來判斷當前獲取的模塊是不是其自身。代碼如下:
invoke VirtualQuery,offset Modify,addr MemBaseinform,sizeof MemBaseinform ;獲取DLL本身所在模塊信息
 invoke CreateToolhelp32Snapshot,TH32CS_SNAPMODULE,NULL ;創建一個進程快照,返回一個快照句柄
 mov snapshot,eax
 mov module.dwSize,sizeof MODULEENTRY32 ;在調用Module32First之前先設置module的大小,否則調用會失敗
 invoke Module32First,snapshot,addr module ;獲取進程中第一個模塊的信息
 .while eax==TRUE ;檢查進程空間中每一個模塊
      mov ebx,MemBaseinform.AllocationBase;ebx中存放我們自己的DLL本身的起始地址
     .if module.hModule!=ebx
      invoke Modify,module.hModule ;進行修改,module.hModule指定了被修改模塊的起始地址
      .endif
     invoke Module32Next,snapshot,addr module ;取下一個模塊
  .endw
整個源程序代碼是用宏彙編來寫的,因爲彙編語言相對於其它的語言來說,是最直接的一種編程語言,利用它能夠將問題說得更清楚一點。在我的例子中我攔截的API是ExitProcess,當然我不會自己去寫一個ExitProcess,我只是在ExitProcess的前面加了一段音樂,這樣進程在調用ExitProcess退出時會先放一段音樂。爲了簡單起見,代碼中有一部分內容沒有實現,比如鉤子的卸載,DLL被卸出時對IAT表的恢復,這些內容你可以自己加上去。
DLL部分: apidll.asm
(略)
DLL文件的DEF文件: apidll.def
LIBRARY apidll
EXPORTS MouseProc
EXPORTS InstallHook
彙編命令:ml /c /coff apidll.asm  
連接命令:link /subsystem:windows /section:.bss,RWS /dll /def:apidll.def apidll.obj
以上是DLL部分,我們必須還需要一個程序進行系統鉤子的安裝工作。下面的代碼就是系統鉤子的安裝部分:
安裝程序: me.asm
(略)
彙編命令:ml /c /coff me.asm  
連接命令:link /subsystem:windows me.obj
好,彙編、連接好這個程序之後,就可以運行了,這個安裝程序只提供了安裝鉤子功能,沒有提供卸載鉤子功能,你可以自己補上,運行這個程序,按一下命令按鈕,系統鉤子被裝入系統,這時,API攔截工作已經開始,因爲我們安裝的是系統範圍的鉤子,所以此時系統內所有的進程都會受到影響。你可以找一個程序試一下,因爲這篇文章是用word 2000輸入的,就試試word 2000吧,運行word 2000,好像沒有什麼反應,這是因爲我們攔截的是ExitProcess,關閉word 2000,怎麼樣,聽見那段音樂了嗎?
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章