本文詳細的介紹了在Visual Studio(以下簡稱VS)下實作API鈎子的程式設計方法,閱讀本文需要基礎:有作業系統的基本知識(程序管理,記憶體管理),會在VS下編寫和調試Win32應用程式和動态連結庫(以下簡稱DLL)。
API鈎子是一種進階程式設計技巧,常常用來完成一些特别的功能,比如詞典軟體的螢幕取詞,遊戲修改軟體的資料修改等。當然,此技術更多的是被黑客或是病毒用來攻擊其它程式,截獲需要的資料或改變目标程式的行為。本文不探讨此技術的應用,隻講實作。同時希望掌握此技術的人都能夠合法的應用它,不要去做危險或違法的事情,害人害己。
一、原理
每一個程式在作業系統中運作,都必須調用作業系統提供的函數——也就是API(應用程式程式設計接口)——來實作程式的各種功能。在Windows作業系統下,API就是那幾千個系統函數。在有些程式中并沒直接調用API的代碼,比如下面的程式:
|
事實上,cout對象的内部處理函數已經替你調用API。就算你的main函數是空的,裡面什麼代碼都不寫,隻要程式被作業系統啟動,也會調用一些基本的API,比如LoadLibrary。這個函數是用來加載DLL的,也就是在程序運作的過程中,把DLL中的程式指令和資料讀入目前程序并執行啟動代碼,我們後面會用到這個函數。
如果能夠設法用自定義函數替換宿主程序調用的目标API函數,那麼就可以截獲宿主程序傳入目标API的參數,并可以改變宿主程序的行為。但要想修改目标API函數必須先查找并打開宿主程序,并讓自定義代碼能在宿主程序中運作。是以挂API鈎子分為四步:1. 查找并打開宿主程序,2. 将注入體裝入宿主程序中運作,3. 用僞裝函數替換目标API,4. 執行僞裝函數。整個程式也分為兩部分,一部分是負責查找并打開宿主程序和注入代碼的應用程式,另一部分是包含修改代碼和僞裝函數的注入體。
二、查找指定的程序
查找指定的程序有很多方法,下面簡單的介紹三種:
1. 找到滑鼠所指窗體的程序句柄
|
2. 查找指定檔案名的程序
|
3. 查找其它指定資訊的程序
通過CreateToolhelp32Snapshot枚舉系統中正在運作的所有程序,并通過相關資料結構得到程序的資訊,具體用法可以參見:
http://msdn.microsoft.com/en-us/library/windows/desktop/ms686701.aspx三、代碼注入
上面提到過LoadLibrary可以将指定的DLL代碼注入目前程序,如果能讓宿主程序來執行這個函數,并把我們自己的DLL的檔案名傳入,那麼我們的代碼就可以在宿主程序中運作了。
|
再看另一個函數:CreateRemoteThread,它可以讓宿主程序新開一個線程,但是新線程的處理函數(LPTHREAD_START_ROUTINE)必須是宿主程序中的函數位址或系統API。
|
如果可以用讓宿主程序新開一個線程,執行LoadLibrary函數,而參數是注入體DLL的檔案名,就大功告成了。不過要完成這些操作,我們先來分析一下可行性。
我們知道,所有系統API函數的調用方式都是__stdcall,即參數采用從右到左的壓棧方式,自己在退出時清空堆棧。這一類函數的具體調用過程如下:在調用前先由調用者将所有參數以位址或數值的形式從右向左壓入棧中,然後用call指令調用該函數;進入函數後,先從棧中取出這些參數再進行運算,并在函數傳回前将之前壓入的棧資料全部彈出以維持棧平衡,最後用eax寄存儲傳遞傳回值(位址或數值)給調用者。這也就是說在指令層面上講,API函數的基本調用方式都相同,然而調用者必須在棧中壓入确定數量的參數,若壓入的參數數量不比對,函數内的取棧和彈棧操作将會使得棧資料錯亂,最終導緻程式崩潰。
通過觀察發現LoadLibrary的參數數量剛好與LPTHREAD_START_ROUTINE都隻有一個參數,那麼如果能夠擷取LoadLibrary函數在宿主程序中的位址,作為lpStartAddress傳入CreateRemoteThread,并将我們的注入體DLL的檔案名作為lpParameter傳入,那麼就可以讓宿主程序執行注入體代碼了。為了将DLL的檔案名傳入宿主程序,我們還需要以下四個API:VirtualAllocEx和VirtualFreeEx可以在宿主程序中配置設定和釋放一段記憶體空間;ReadProcessMemory和WriteProcessMemory可以在宿主程序中的指定記憶體位址讀出或寫入資料。
在注入的代碼執行完畢後,還要完成清理工作。首先是解除安裝剛剛載入的DLL,需要使用另一個系統API:FreeLibrary。過程與上面的代碼注入一樣,使用CreateRemoteThread,将FreeLibrary的位址作為lpStartAddress參數傳入。注意到FreeLibrary的參數是一個HMODULE,該句柄其實是一個Module的全局ID,一般由LoadLibrary的傳回值給出。是以可以調用GetExitCodeThread擷取前面執行的LoadLibrary線程的傳回值,再作為CreateRemoteThread的lpParameter參數傳入,這樣就完成了DLL的解除安裝。還要記得用VirtualFreeEx釋放VirtualAllocEx申請到的記憶體,并關閉打開的所有句柄,完成最後的清理工作。
現在注入代碼的步驟就比較清晰了:
- 調用OpenProcess擷取宿主程序句柄;
- 調用GetProcAddress查找LoadLibrary函數在宿主程序中的位址;
- 調用VirtualAllocEx和WriteProcessMemory将DLL檔案名字元串寫入宿主程序的記憶體;
- 調用CreateRemoteThread執行LoadLibrary在宿主程序中運作DLL;
- 調用VirtualFreeEx釋放剛申請的記憶體;
- 調用WaitForSingleObject等待注入線程結束;
- 調用GetExitCodeThread擷取前面加載的DLL的句柄;
- 調用CreateRemoveThead執行FreeLibrary解除安裝DLL;
- 調用CloseHandle關閉打開的所有句柄。
代碼注入的所有代碼整理如下。(注意:這個程式需要在win32控制台模式下編譯生成一個exe檔案。在控制台下運作時需要兩個參數:第1個參數為宿主程序的映象名稱,可以在任務管理器中檢視;第2個參數為注入體DLL的完整路徑檔案名。程式運作後就會将指定的DLL裝入指定名稱的宿主程序)
|
四、挂鈎
上面的程式已經可以将自編代碼注入到宿主程序中了,下面就要進一步讨論如何來編寫注入體(動态連結庫)以實作對目标API進行攔截。這一部分的内容比上面要深一些,需要一點彙編基礎知識。
1. 在VS中進行彙編級調試
VS為使用者提供了非常強大的調試功能,可以友善的檢視注入代碼與宿主代碼的運作情況。現在需要另建立一個項目作為宿主程序,MFC簡單對話框程式是一個不錯的選擇。下面就以GetTickCount作為目标API進行講解。先響應對話框的滑鼠左鍵按下事件,并添加GetTickCount代碼:
|
在GetTickCount前設定斷點,運作程式後點左鍵讓程式停在這裡,然後打開反彙編(調試菜單->視窗),會看到下面的反彙編代碼:

上圖中有4行彙編指令,第1列是指令所在的記憶體位址,第2列是彙編指令,第3列是操作數。在不同的機器上編譯結果也不同,是以記憶體位址會不一樣,但後面的指令和操作數都大同小異。按一下F10(逐過程),運作到0063E615這一行,再按下F11(逐語句)就會進入到GetTickCount的代碼中去,見下圖:
接下來要執行的指令是:
|
注意這一句代碼所在的位址是7C80934A,下一句代碼是7C80934F,說明這一行mov指令的長度為5。現在打開記憶體檢視視窗(調試->視窗->記憶體),并在位址裡輸入0x7C80934A,顯示如下:
可知這條mov指令對應的機器碼即是:ba 00 00 fe 7f。此時打開寄存器視窗(調試->視窗->寄存器),可以看到目前各寄存器的值。按下F10執行單步,還可以看到各寄存器的變化(變化的值用紅色标出),如下圖:
2. 指令的格式
為了繼續要了解x86架構下彙編碼和機器碼的對應關系,需要參考一部非常重要的文獻“Intel® 64 and IA-32 Architectures Software Developer's Manual”(以下簡稱IA32SDM),這是Intel公司免費提供給開發者的,可以在下面的網址找到3卷合訂本:
http://www.intel.com/content/www/us/en/processors/architectures-software-developer-manuals.html在IA32SDM的Vol. 2B - 4.2(總第1257頁)可以找到各種mov指令的說明:
上表中每一行表示了一種mov指令,第一列是這種指令的操作碼格式,第二列是指令格式,最後一列是描述資訊。格令格式一列中r*是指位長為*的寄存 器,r/m*是指位長為*的記憶體位址,Imm*是指位長為*的立即數。上文中的mov指令“mov edx, 7FFE0000h”的操作數有兩個:第一個是32位寄存器(r32)edx,第二個是一個32位立即數(Imm32)7FFE0000h。查上表可知該 mov指令就是用紅框劃出的那一種:“MOV r32, Imm32”,它對應的Opcode(操作碼)是“B8+rd”,機器碼編碼格式為OI。在1258頁可以看到各種mov指令的編碼格式,第一列與上表中的第三列對應,後面四列是四個操作數。
表中編碼格式OI包含兩個操作數,先是Opcode加寄存器代碼,後面緊跟了一個立即數。而Opcode的編寫格式參見IA32SDM的Vol. 2A - 3.1.1.1(總第606頁),摘錄如下:
The “Opcode” column in the table above shows the object code produced for each form of the instruction. When possible, codes are given as hexadecimal bytes in the same order in which they appear in memory. Definitions of entries other than hexadecimal bytes are as follows:
- REX.W — Indicates the use of a REX prefix that affects operand size or instruction semantics. The ordering of the REX prefix and other optional/mandatory instruction prefixes are discussed Chapter 2. Note that REX prefixes that promote legacy instructions to 64-bit behavior are not listed explicitly in the opcode column.
- /digit — A digit between 0 and 7 indicates that the ModR/M byte of the instruction uses only the r/m (register or memory) operand. The reg field contains the digit that provides an extension to the instruction's opcode.(翻譯:這是一個0到7的數字,表示指令的ModR/M位元組隻使用r/m操作數。ModR/M的reg位就是該數,作為操作碼的一個附加碼)
- /r — Indicates that the ModR/M byte of the instruction contains a register operand and an r/m operand.
- cb, cw, cd, cp, co, ct — A 1-byte (cb), 2-byte (cw), 4-byte (cd), 6-byte (cp), 8-byte (co) or 10-byte (ct) value following the opcode. This value is used to specify a code offset and possibly a new value for the code segment register.
- ib, iw, id, io — A 1-byte (ib), 2-byte (iw), 4-byte (id) or 8-byte (io) immediate operand to the instruction that follows the opcode, ModR/M bytes or scaleindexing bytes. The opcode determines if the operand is a signed value. All words, doublewords and quadwords are given with the low-order byte first.
- +rb, +rw, +rd, +ro — A register code, from 0 through 7, added to the hexadecimal byte given at the left of the plus sign to form a single opcode byte. See Table 3-1 for the codes(翻譯:這是一個寄存器代碼,範圍由0到7。與+号左邊的16進制數代數相加構成一個完整的操作碼位元組。具體代碼參見Table 3-1). The +ro columns in the table are applicable only in 64-bit mode.
- +i — A number used in floating-point instructions when one of the operands is ST(i) from the FPU register stack. The number i (which can range from 0 to 7) is added to the hexadecimal byte given at the left of the plus sign to form a single opcode byte.
按照标記為紅色的描述可知Opcode“B8+rd”中的B8是基礎碼值0xB8,rd表示32位寄存器EDX的代号。寄存器的代碼表可參見IA32SDM的Vol. 2A - Table 3-1(總第607頁),如下圖:
從上表中紅色線框标出的部分中可以看出,EDX對應的附加碼為2,是以這條mov指令的Opcode就是0xB8 + 0x02 = 0xBA。跟據編碼格式OI,後面緊跟一個32位的立即數0x7FFE0000,由于Intel的CPU體系是Little Ending,是以位元組序為逆序,故在記憶體檢視器中立即數顯示為“00 00 fe 7f”。綜上所述,該mov指令的完整機器碼為:“ba 00 00 fe 7f”,與記憶體檢視器的結果吻合。
2. 準備JMP
上面簡單介紹了在VS進行彙編級調試的基本方法,并以mov指令為範例講解了如何分析機器碼。掌握了這些工具和資料,就可以清晰地了解我們下面要完成的代碼在系統内部執行的細節。從上節可知,GetTickCount這個API執行的第一條指令是mov,如果能把mov的Opcode改為jmp,那就可以跳轉到自定義的函數位址執行任意代碼了。從IA32SDM(Vol. 2A - 3.2)中查出jmp指令的機器碼:
由于自定義的函數位置随機,且在win32作業系統的保護下,每個程序的段位址都是固定的,程式可以通過CS寄存器通路,但不能夠改變。是以我們有兩種選擇,一是用JMP r/m32指令執行段内絕對跳轉,二是用JMP rel32指令執行段内相對跳轉。先講解如何利用JMP r/m32執行絕對跳轉。機器碼的格式參見IA32SDM的Vol. 2A - 2.1,如下圖:
從上圖可知,機器碼由6大部分組成,而JMP r/m32指令對應的機器碼為“FF /4”(其中/4的含義參見上文中Opcode說明裡用藍色标記的文字),用到了其中3個部分:1個位元組的Opcode(即0xFF)、1個位元組的ModR/M和4個位元組的Displacement操作數。其中的ModR/M指定了CPU的尋址方式以及Opcode的附加碼,它又分為三段:Mod、Reg/Opcode和R/M,具體構成可參見IA32SDM的Vol. 2A - 2.1.3和後面的Table 2-2,如下圖:
先看一下表頭最左邊一格,第6行“/digit (Opcode)”就是機器碼“FF /4”中的4,是以看紅框标記的那一列(4的二進制為100)就可以了。“Effective Address”指定了尋指方式,為了避免對寄存器進行操作,用1條指令就完成跳轉,我們選擇最簡單的“disp32”這一行,它表示僅用指令機器碼中的第3部分Displacement表示跳轉的目标位址。這樣就确定了使用的Mod位為00,Reg/Opcode位為100,R/M位為101。計算可得ModR/M位元組為00 100 101(二進制) = 0x25。
Displacement指向一段4位元組的記憶體,這段記憶體裡存放的是最終的目标位址。是以需要先用VirtualAllocEx申請4個位元組的空間,将自定義函數的位址存入,然後再将申請的位址填入Displacement。綜上所述,完整的機器碼應該是FF 25 XX XX XX XX,最後面的4個位元組是一個存有目标函數入口位址的記憶體位址。
用JMP r/m32指令完成跳轉是比較複雜的,不僅需要申請和釋放記憶體,且整個機器指令有6個位元組。更簡單的方法就是利用JMP rel32指令執行相對跳轉,而機器碼隻有5個位元組。JMP rel32對應的機器碼是E9 cd,其中cd就是相對位址,計算方法為:目标位址 - 目前指令位址 - 5。在準備好JMP指令的機器碼後,就可以将其替換到目标API的入口位址處,欺騙宿主程序執行僞裝函數。
3. 修改入口點
看完上面的介紹,相信您已經迫不及待的想要嘗試如何對目标API挂鈎了。雖然還有很多問題沒有解決,比如怎樣傳回,怎樣執行原API功能,怎樣全身而退等等,但這些問題可以先放一放,先來看看能否利用上面的方法成功挂鈎。
首先需要建立一個DLL項目以生成注入體,自定義一個DllMain函數,如下:
|
然後編寫挂鈎函數InstallMonitor:
|
上面的代碼很好了解,aOpcode就是根據前文介紹的方法構造的jmp指令,指定跳轉到自定義的僞裝函數MonFunc,然後用WriteProcessMemory将jmp指令填寫入GetTickCount的代碼處。僞裝函數MonFunc函數很好寫:
|
至此,您就可以按上面的代碼編譯一個注入體DLL了,然後利用本文第三部分的注入程式就可以将此DLL注入到宿主程序執行。
五、完美欺騙
如果您按上文所述的方法執行出成功的結果,那麼你很可能會發現在對話框确定後宿主程序崩潰了。原因有下面幾條:
- 僞裝函數沒有正确的保持棧的平衡,導緻傳回時宿主清棧出錯;
- 僞裝函數沒有按API執行方式執行出結果,宿主不能正常的調用系統API導緻錯誤;
- 僞裝函數不是線程安全的,導緻宿主在并發調用時出錯;
- 宿主有安全防護措施,檢查到攻擊後自動恢複或自我毀滅。
本文隻讨論前3條原因的解決方案,不考慮第4條原因。下面逐條解釋。
1. 保持棧的平衡
大部分API都是有參數的,而參數是由宿主在call指令執行前壓入堆棧。Win32API的調用約定是__stdcall,表示由API負責棧的清理,那麼如果僞裝函數在傳回時沒有适當的清棧必将導緻出錯。是以,僞裝函數的參數表一定要與原API相同,才能保證編譯器生成的代碼能夠正确傳回到宿主代碼。
2. 執行原API的功能
為了能夠執行原API的功能,必須在調用它之前恢複它原來的代碼,否則就會陷入死循環。當然,應該在改寫機器碼時保留原先的機器碼,這樣就可以利用WriteProcessMemory将其恢複原狀。ReadProcessMemory這個API函數與WriteProcessMemory的功能相反,可以讀取指定位置的機器碼。還要記得,在原API調用結束後還要修改它的入口點,否則下次就無法欺騙了。整個僞裝函數的結構如下:
|
3. 線程安全
用EnterCriticalSection和LeaveCriticalSection是保證線程安全的最佳選擇,将僞裝函數用這對函數包起來就可以解決并發通路的問題。現在的代碼應該看起來是這樣:
|
4. 完整示例
下面貼出注入體DLL的完整代碼,供您參考。這個DLL對GetTickCount挂了鈎子,您可以在僞裝函數MonFunc中添加任意的自定義代碼,并在退出的時候調用UninstallMonitor結束鈎子程式。
|