天天看點

HOOK API DLL 注入

 一、

序言對大多數的Windows開發者來說,如何在Win32系統中對API函數的調用進行攔截一直是項極富挑戰性的課題,因為這将是對你所掌握的計算機知識較為全面的考驗,尤其是一些在如今使用RAD進行軟體開發時并不常用的知識,這包括了作業系統原理、彙編語言甚至是關于機器指令代碼的(聽上去真是有點恐怖,不過這是事實)。

目前廣泛使用的Windows作業系統中,像Win 9x和Win NT/2K,都提供了一種比較穩健的機制來使得各個程序的記憶體位址空間之間是互相獨立,也就是說一個程序中的某個有效的記憶體位址對另一個程序來說是無意義的,這種記憶體保護措施大大增加了系統的穩定性。不過,這也使得進行系統級的API攔截的工作的難度也大大加大了。

當然,我這裡所指的是比較文雅的攔截方式,通過修改可執行檔案在記憶體中的映像中有關代碼,實作對API調用的動态攔截;而不是采用比較暴力的方式,直接對可執行檔案的磁盤存儲中機器代碼進行改寫。

二、

API鈎子系統一般架構通常,我們把攔截API的調用的這個過程稱為是安裝一個API鈎子(API Hook)。一個API鈎子至少有兩個子產品組成:一個是鈎子伺服器(Hook Server)子產品,一般為EXE的形式;一個是鈎子驅動器(Hook Driver)子產品,一般為DLL的形式。

伺服器主要負責向目标程序注入驅動器,使得驅動器工作在目标程序的位址空間中,這是關鍵的第一步。驅動器則負責實際的API攔截工作,以便在我們所關心的API函數調用的前後能做一些我們需要的工作。

一個大家比較常見的API鈎子的例子就是一些實時翻譯軟體(像金山詞霸)中必備的的功能:螢幕抓詞,它主要是對一些GDI 函數進行了攔截,擷取它們的輸入參數中的字元串,然後在自己的視窗中顯示出來。針對上述的兩個部分,有以下兩點需要我們重點考慮的: 選用何種DLL注入技術 采用何種API攔截機制

 三、

注入技術的選用由于在Win32系統中各個程序的位址是互相獨立的,是以我們無法在一個程序中對另一個程序的代碼進行有效的修改。而你要完成API鈎子的工作就必須進行這種操作。是以,我們必須采取某種獨特的手段,使得API鈎子(準确的說是鈎子驅動器)能夠成為目标程序中的一部分,才有較大的可能來對目标程序資料和代碼進行有控制的修改。

通常有以下幾種注入方式:

1.利用系統資料庫如果我們準備攔截的程序連接配接了User32.dll,也就是使用了User32中的API(一般圖形界面的應用程式都符合這個條件),那麼就可以簡單把你的鈎子驅動器DLL的名字作為值添加在下面系統資料庫的鍵下: HKEY_LOCAL_MACHINE/Software/Microsoft/WindowsNT/CurrentVersion/Windows/AppInit_DLLs 值的形式可以為單個DLL的檔案名,或者是一組DLL的檔案名,相鄰的名稱之間用逗号或空格間隔。所有由該值辨別的DLL将在符合條件的應用程式啟動的時候裝載。這是一個作業系統内建的機制,相對其他方式來說危險性較小,但它有一些比較明顯的缺點: 該方法僅适用于NT/2K作業系統。看看鍵的名稱你就應該明白 為了激活或停止鈎子的注入,必須重新啟動Windows。這個就似乎太不友善了 不能用此方法向沒有使用User32的應用程式注入DLL,例如控制台應用程式 不管需要與否,鈎子DLL将注入每一個GUI應用程式,這将導緻整個系統性能的下降

2.

建立系統範圍的Windows鈎子要向某個程序注入DLL,一個十分普遍也是比較簡單的方法就是建立在标準的Windows鈎子的基礎上。Windows鈎子一般是在DLL中實作的,這是一個全局性的Windows鈎子的基本要求,這也符合我們的需要。當我們成功地調用SetWindowsHookEx函數之後,便在系統中安裝了某種類型的消息鈎子,這個鈎子可以是針對某個程序,也可以是針對系統中的所有程序。一旦某個程序中産生了該類型的消息,作業系統會自動把該鈎子所在的DLL映像到該程序的位址空間中,進而使得消息回調函數(在SetWindowsHookEx的參數中指定)能夠對此消息進行适當的處理,在這裡,我們所感興趣的當然不是對消息進行什麼處理,是以在消息回調函數中隻需把消息鈎子向後傳遞就可以了,但是我們所需的DLL已經成功地注入了目标程序的位址空間,進而可以完成後續工作。

我們知道,不同程序中使用的DLL之間是不能直接共享資料的,因為它們活動在不同的位址空間中。但在Windows鈎子DLL中,有一些資料,例如Windows鈎子句柄HHook,這是由SetWindowsHookEx函數傳回值得到的,并且作為參數将在CallNextHookEx函數和UnhookWindoesHookEx函數中使用,顯然使用SetWindowsHookEx函數的程序和使用CallNextHookEx函數的程序一般不會是同一個程序,是以我們必須能夠使句柄在所有的位址空間中都是有效的有意義的,也就是說,它的值必須必須在這些鈎子DLL所挂鈎的程序之間是共享的。為了達到這個目的,我們就應該把它存儲在一個共享的資料區域中。

在VC++中我們可以采用預編譯指令#pragma data_seg在DLL檔案中建立一個新的段,并且在DEF檔案中把該段的屬性設定為“shared”,這樣就建立了一個共享資料段。對于使用Delphi的人來說就沒有這麼幸運了:沒有類似的比較簡單的方法(或許是有的,但我沒有找到)。不過我們還是可以利用記憶體映像技術來申請使用一塊各程序可以共享的記憶體區域,主要是利用了CreateFileMapping和MapViewOfFile這兩個函數。這倒是一個通用的方法,适合所有的開發語言,隻要它能使用Windows的API。

在Borland的BCB中有一個指令#pragma codeseg與VC++中的#pragma data_seg指令有點類似,應該也能起到一樣的作用,但我試了一下,沒有沒有效果,而BCB的聯機幫助中對此也提到的不多,不知怎樣才能正确的使用。一旦鈎子DLL加載進入目标程序的位址空間後,在我們調用UnHookWindowsHookEx函數之前是無法使它停止工作的,除非目标程序關閉。

這種DLL注入方式有兩個優點: 這種機制在Win 9x/Me和Win NT/2K中都是得到支援的,預計在以後的版本中也将得到支援 鈎子DLL可以在不需要的時候,可由我們主動的調用UnHookWindowsHookEx來解除安裝,比起使用系統資料庫的機制來說友善了許多盡管這是一種相當簡潔明了的方法,但它也有一些顯而易見的缺點: 首先值得我們注意的是,Windows鈎子将會降低整個系統的性能,因為它額外增加了系統在消息處理方面的時間 其次,隻有當目标程序準備接受某種消息時,鈎子所在的DLL才會被系統映射到該程序的位址空間中,鈎子才能真正開始發揮作用。是以如果我們要對某些程序的整個生命周期内的API調用情況進行監控,用這種方法顯然會遺漏某些API的調用

3.

 使用 CreateRemoteThread函數在我看來這是一個相當棒的方法,然而不幸的是,CreateRemoteThread這個函數隻能在Win NT/2K系統中才得到支援,雖然在Win 9x中這個API也能被安全的調用而不出錯,但它除了傳回一個空值之外什麼也不做。整個DLL注入過程十分簡單。我們知道,任何一個程序都可以使用LoadLibrary來動态地加載一個DLL。但問題是,我們如何讓目标程序在我們的控制下來加載我們的鈎子DLL(也就是鈎子驅動器)呢?這裡有一個API函數CreateRemoteThread,通過它可在一個程序中可建立并運作一個遠端的線程。

調用該API需要指定一個線程函數指針作為參數,該線程函數的原型如下: Function ThreadProc(lpParam: Pointer): DWORD;我們再來看一下LoadLibrary的函數原型: Function LoadLibrary(lpFileName: PChar): HModule;可以看出,這兩個函數原型實質上是完全相同的(其實傳回值是否相同關系不大,因為我們是無法得到遠端線程函數的傳回值的),隻是叫法不同而已,這種相同使得我們可以把直接把LoadLibrary當做線程函數來使用,進而在目标程序中加載鈎子DLL。

類似的,當我們需要解除安裝鈎子DLL時,也可以FreeLibrary作為線程函數來使用,在目标程序中移去鈎子DLL。一切看來是十分的簡潔友善。通過調用GetProcAddress函數,我們可以得到LoadLibrary函數的位址。由于LoadLibrary是Kernel32中的函數,而這個系統DLL的映射位址對每一個程序來說都是相同的,是以LoadLibrary函數的位址也是如此。這點将確定我們能把該函數的位址作為一個有效的參數傳遞給CreateRemoteThread使用。

AddrOfLoadLibrary := GetProcAddress(GetModuleHandle(‘Kernel32.dll’), ‘LoadLibrary’);

HremoteThread := CreateRemoteThread(HTargetProcess, nil, 0, AddrOfLoadLibrary, HookDllName, 0, nil);

要使用CreateRemoteThread,我們需要目标程序的句柄作為參數。當我們用OpenProcess函數來得到程序的句柄時,通常是希望對此程序有全權的存取操作,也就是以PROCESS_ALL_ACCESS為标志打開程序。但對于一些系統級的程序,直接這樣顯然是不行的,隻能傳回一個的空句柄(值為零)。為此,我們必須把自己設定為擁有調試級的特權,這樣将具有最大的存取權限,進而使得我們能對這些系統級的程序也可以進行一些必要的操作。

4.

通過BHO來注入DLL 有時,我們想要注入DLL的對象僅僅是Internet Explorer。幸運的是,Windows作業系統為我們提供了一個簡單的歸檔的方法(這保證了它的可靠性)―― 利用Browser Helper Objects(BHO)。一個BHO是一個在 DLL中實作的COM對象,它主要實作了一個IObjectWithSite接口,而每當IE運作時,它會自動加載所有實作了該接口的COM對象。

四、

攔截機制在鈎子應用的系統級别方面,有兩類API攔截的機制――核心級的攔截和使用者級的攔截。核心級的鈎子主要是通過一個核心模式的驅動程式來實作,顯然它的功能應該最為強大,能捕捉到系統活動的任何細節,但難度也較大,不在我們探讨的範圍之内(尤其對我這個使用Delphi的人來說,還沒涉足這塊領域,是以也無法探讨);

而使用者級的鈎子則通常是在普通的DLL中實作整個API的攔截工作,這才是我們現在所重點關注的。攔截API函數的調用,一般可有以下幾種方法:

1. 代理DLL(特洛伊木馬)一個容易想到的可行的方法是用一個同名的DLL去替換原先那個輸出我們準備攔截的API所在的DLL。當然代理DLL也要和原來的一樣,輸出所有函數。如果想到DLL中可能輸出了上百個函數,我們就應該明白這種方法的效率是不高的。另外,我們還得考慮DLL的版本問題。

2.改寫執行代碼有許多攔截的方法是基于可執行代碼的改寫。其中一個就是改變在CALL指令中使用的函數位址,這種方法有些難度,也比較容易出錯。它的基本思路是檢索出在記憶體中所有你所要攔截的API的CALL指令,然後把原先的位址改成為你自己提供的函數的位址。

另外一種代碼改寫的方法的實作方法更為複雜,它的主要的實作步驟是先找到原先的API函數的位址,然後把該函數開始的幾個位元組用一個JMP指令代替(有時還不得不改用一個INT指令),使得對該API函數的調用能夠轉向我們自己的函數調用。實作這種方法要牽涉到一系列壓棧和出棧這樣的較底層的操作,顯然對我們的彙編語言和作業系統底層方面的知識是一種考驗。這個方法倒和很多病毒的感染機制相類似。

3.以調試器的身份進行攔截另一個可選的方法是在目标函數中安置一個調試斷點,使得程序運作到此處就進入調試狀态。然而這樣一些問題也随之而來,其中較主要的是調試異常的産生将把程序中所有的線程都挂起。它也需要一個額外的調試子產品來處理所有的異常,整個程序将一直在調試狀态下運作,直至它運作結束。

4.改寫輸入位址表這種方法主要得益于現如今Windows系統中所使用的可執行檔案(包括EXE檔案和DLL檔案)的良好結構――PE檔案格式(Portable Executable File Format),是以它相當穩健,又簡單易行。要了解這種方法是如何運作的,首先你得對PE檔案格式有所了解。

一個PE檔案的結構大緻如下圖所示: 一般PE檔案一開始是一段DOS程式,當你的程式在不支援Windows的環境中運作時,它就會顯示“This Program cannot be run in DOS mode”這樣的警告語句,接着這個DOS檔案頭,就開始真正的PE檔案内容了。首先是一段稱為“IMAGE_NT_HEADER”的資料,其中是許多關于整個PE檔案的消息,在這段資料的尾端是一個稱為Data Directory的資料表,通過它能快速定位一些PE檔案中段(section)的位址。在這段資料之後,則是一個“IMAGE_SECTION_HEADER”的清單,其中的每一項都較長的描述了後面一個段的相關資訊。接着它就是PE檔案中最主要的段資料了,執行代碼、資料和資源等等資訊就分别存放在這些段中。

在所有的這些段裡,有一個被稱為“.idata”的段(輸入資料段)值得我們去注意,該段中包含着一些被稱為輸入位址表(IAT,Import Address Table)的資料清單。每個用隐式方式加載的API所在的DLL都有一個IAT與之對應,同時一個API的位址也與IAT中一項相對應。當一個應用程式加載到記憶體中後,針對每一個API函數調用,相應的産生如下的彙編指令:

JMP DWORD PTR [XXXXXXXX]

如果在VC++中使用了_delcspec(import),那麼相應的指令就成為

CALL DWORD PTR [XXXXXXXX]。

不管怎樣,上述方括号中的總是一個位址,指向了輸入位址表中一個項,是一個DWORD,而正是這個DWORD才是API函數在記憶體中的真正位址。是以我們要想攔截一個API的調用,隻要簡單的把那個DWORD改為我們自己的函數的位址,那麼所有關于這個API的調用将轉到我們自己的函數中去,攔截工作也就宣告順利的成功了。這裡要注意的是,自定義的函數的調用形式應該是API的調用方式,也就是stdcall方式,而Delphi中預設的是pascal的調用方式,也就是register方式,它們在參數的傳遞方式等方面存在着較大的差別。

另外,自定義的函數的參數形式可以和原先的API函數相同的,不過這也不是必須的,而且這樣的話在有些時候也會出現一些問題,我在後面将會提到。是以要攔截API的調用,首先我們就要得到相應的IAT的位址。系統把一個程序子產品加載到記憶體中,其實就是把PE檔案幾乎是原封不動的映射到程序的位址空間中去,而子產品句柄HModule實際上就是子產品映像在記憶體中的位址,PE檔案中一些資料項的位址,都是相對于這個位址的偏移量,是以被稱為相對虛拟位址(RVA,Relative Virtual Address)。

于是我們就可以從HModule開始,經過一系列的位址偏移而得到IAT的位址。不過我這裡有一個簡單的方法,它使用了一個現有的API函數 ImageDirectoryEntryToData,它幫助我們在定位IAT時能少走幾步,省得把偏移位址弄錯了,走上彎路。不過純粹使用RVA從HModule開始來定位IAT的位址其實并不麻煩,而且這樣還更有助于我們對PE檔案的結構的了解。上面提到的API函數是在DbgHelp.dll中輸出的(這是從Win 2K才開始有的,在這之前是由ImageHlp.dll提供的),有關這個函數的詳細介紹可參見MSDN。

在找到IAT之後,我們隻需在其中周遊,找到我們需要的API位址,然後用我們自己的函數位址去覆寫它。下面給出一段對應的源碼:

procedure RedirectApiCall; var ImportDesc:PIMAGE_IMPORT_DESCRIPTOR; FirstThunk:PIMAGE_THUNK_DATA32; sz:DWORD;

begin

//得到一個輸入描述結構清單的首位址,每個DLL都對應一個這樣的結構 ImportDesc:=ImageDirectoryEntryToData(Pointer(HTargetModule), true, IMAGE_DIRECTORY_ENTRY_IMPORT, sz);

while Pointer(ImportDesc.Name)<>nil do

begin //判斷是否是所需的DLL輸入描述

if StrIComp(PChar(DllName),PChar(HTargetModule+ImportDesc.Name))=0 then     begin

//得到IAT的首位址

FirstThunk:=PIMAGE_THUNK_DATA32(HTargetModule+ImportDesc.FirstThunk);

while FirstThunk.Func<>nil do

begin

if FirstThunk.Func=OldAddressOfAPI then

begin

//找到了比對的API位址 ……

//改寫API的位址

break;

end;

Inc(FirstThunk);

end;

end;

Inc(ImportDesc);

end;

end;

最後有一點要指出,如果我們手工執行鈎子DLL的退出目标程序,那麼在退出前應該把函數調用位址改回原先的位址,也就是API的真正位址,因為一旦你的DLL退出了,改寫的新的位址将指向一個毫無意義的記憶體區域,再使用它顯然會出現一個非法操作。

五、

替換函數的編寫 前面關鍵的兩步做完了,一個API鈎子基本上也就完成了。不過還有一些相關的東西需要我們研究一番的,包括怎樣做一個替換函數。 下面是一個做替換函數的步驟: 首先,不失一般性,我們先假設有這樣的一個API函數,它的原型如下:

function someAPI(param1: Pchar;param2: Integer): DWORD;

接着再建立一個與之有相同參數和傳回值的函數類型:

type FuncType= function (param1: Pchar;param2: Integer): DWORD;

然後我們把someAPI函數的位址存放在OldAddress指針中。接着我們就可以着手寫替換函數的代碼了:

function DummyFunc(param1: Pchar;param2: Integer): DWORD; begin ……

//做一些調用前的操作

result := FuncType(OldAddress) (param1 , param2);

//調用原先的API函數 ……

//做一些調用後的操作

end;

我們再把這個函數的位址儲存到NewAddress中,接着用這位址覆寫掉原先API的位址。這樣當目标程序調用該API的時候,實際上是調用了我們自己的函數,在其中我們可以做一些操作,然後在調用原先的API函數,結果就像什麼也沒發生過一樣。當然,我們也可以改變輸入參數,甚至是屏蔽調這個API函數的調用。

盡管上述方法是可行的,但有一個明顯的不足:這種替換函數的制作方法不具有通用性,隻能針對少量的函數。如果隻有幾個API要攔截,那麼隻需照上述說的重複幾次就行了。但如果有各種各樣的API要處理,它們的參數個數和類型以及傳回值的類型是各不相同的,還是采用這種方法就太沒效率了。

的确是的,上面給出的隻是一個最簡單最容易想到的方法,隻是一個替換函數的基本構架。正如我前面所提到的,替換函數的與原先的API函數的參數類型不必相同,一般的我們可以設計一個沒有調用參數也沒有傳回值的函數,通過一定的技巧,使它能适應各種各樣的API函數調用,不過這得要求你對彙編語言有一定的了解。

下面我就對此詳細的說一下。 首先,我們來看一下執行到一個函數内部前的堆棧情況(這裡函數的調用方式為stdcall)。 由上面的例圖可知,函數的調用參數是按照從右到左的順序壓入堆棧的(堆棧是由高端向低端發展的),同時還壓入了一個函數傳回位址。在進入函數之前,ESP正指向傳回位址。是以,我們隻要從ESP+4開始就可以取得這個函數的調用參數了,每取一個參數遞增4。另外,當從函數中傳回時,一般在EAX中存放函數的傳回值。

了解了上述知識,我們就可以設計如下的一個比較通用的替換函數,它利用了Delphi的内嵌式彙編語言的特性。

Procedure DummyFunc;  

asm add esp,4 mov eax,esp//得到第一個參數

mov eax,esp+4//得到第二個參數 ……

//做一些處理,這裡要保證esp在這之後恢複原樣

call OldAddress //調用原先的API函數 ……

//做一些其它的事情

end;

當然,這個替換函數還是比較簡單的,你可以在其中調用一些純粹用OP語言寫的函數或過程,去完成一些更複雜的操作(要是都用彙編來完成,那可得把你忙死了),不過應該這些函數的調用方式統一設定為stdcall方式,這使它們隻利用堆棧來傳遞參數,是以你也隻需時刻掌握好堆棧的變化情況就行了。如果你直接把上述彙編代碼所對應的機器指令存放在一個位元組數組中,然後把數組的位址當作函數位址來使用,效果是一樣的。

以上代碼在 Win 2K/xp & Delphi 6.0 中實作。

六、後記

做一個API鈎子的确是件不容易的事情,尤其對我這個使用Delphi的人來說,為了解決某個問題,經常在OP、C++和彙編語言的資料中東查西找,在程式調試中還不時的發生一些意想不到的事情,弄的自己是手忙腳亂。不過,好歹總算做出了一個API鈎子的雛形,還是令自己十分的高興,對計算機系統方面的知識也掌握了不少,受益非淺。當初在寫這篇文章之前,我隻是想翻譯一篇從網上Down下來的英文資料(網址為www.codeproject.com ,文章名叫“API Hook Revealed”,示例源代碼是用VC++寫的,這裡不得不佩服老外的水準,文章寫得很有深度,而且每個細節都講的十分詳細)。

不過翻着翻着,就覺得自己真是水準有限,很多地方雖然自己明白了,就是不知怎樣用中文來表達意思才明确,于是隻得把已經翻譯出來的覺得還可以一用的那部分加上一些自己在實際操作中的體會與心得,雜湊出了上面的這篇文章,盡管可能有些不倫不類,但也是化了我的很多時間,嘔心瀝血啊(慚愧,慚愧!),希望高手不要見笑,請多多指教。

繼續閱讀