實(shí)踐API鉤子攔截DLL庫(kù)調(diào)用
前言
在日常分析使用某個(gè)軟件的過(guò)程中,如果我們想要去挖掘軟件的漏洞、或者是通過(guò)打補(bǔ)丁的方式給軟件增添一些新的功能,抑或是為了記錄下軟件運(yùn)行過(guò)程中被調(diào)用的函數(shù)及其參數(shù),有時(shí)候我們需要劫持對(duì)某些DLL庫(kù)的調(diào)用過(guò)程。在一般情況下,如果我們是軟件的開(kāi)發(fā)者或者該軟件提供源碼下載,那么剛才提到的問(wèn)題只要對(duì)源碼進(jìn)行一定的修改就可以了,簡(jiǎn)直是小菜一碟。但是在更多情況下,我們無(wú)從獲取軟件或是庫(kù)的源碼,因?yàn)樗麄兏緵](méi)有采用源碼發(fā)行的方式。那這樣我們是否就一籌莫展了呢?通過(guò)閱讀這篇文章,我會(huì)告訴你最流行的“API鉤子”方法是什么,并且會(huì)以略微不同的方式展現(xiàn)給大家。
API鉤子
正如上文我們已經(jīng)提到的,劫持DLL最流行的方法被稱作“API鉤子”——一種將庫(kù)函數(shù)調(diào)用重定向到你的代碼的技術(shù)。最為流行的API鉤子庫(kù)非微軟的 Microsoft Detours (常用于游戲破解)莫屬,并且這個(gè)商業(yè)庫(kù)被打上的價(jià)值標(biāo)簽已經(jīng)高達(dá)9999.95美元(約68999元人民幣)。再舉一個(gè)例子,在Dephi語(yǔ)言中有一個(gè)庫(kù)叫做 madCodeHook,他的商業(yè)價(jià)值約為349歐元(約2564元人民幣)。
下面就讓我們來(lái)看一看API鉤子的具體實(shí)現(xiàn)原理。
對(duì)于已經(jīng)加載的DLL庫(kù)及對(duì)應(yīng)函數(shù),通過(guò)在想要鉤取的函數(shù)頭部首字節(jié)打上一個(gè)補(bǔ)丁(也叫重寫,個(gè)人認(rèn)為叫覆蓋最為貼切),補(bǔ)丁內(nèi)容為一個(gè)JMP指令,像是 JMP NEAR 這樣的形式,轉(zhuǎn)換成16進(jìn)制就是 E9 xx xx xx xx。如下圖所示:
圖1:被鉤取的函數(shù)前后內(nèi)容示意
當(dāng)控制權(quán)被傳遞到我們鉤取過(guò)的函數(shù)后,通常這時(shí)就可以執(zhí)行我們自己想要執(zhí)行的代碼了,執(zhí)行完畢后又會(huì)接著運(yùn)行原函數(shù)然后返回到之前從DLL庫(kù)中調(diào)用該函數(shù)的代碼位置。
API鉤子其實(shí)會(huì)導(dǎo)致一些問(wèn)題,而問(wèn)題的來(lái)源就在于編譯過(guò)的軟件結(jié)構(gòu)和它本身的代碼結(jié)構(gòu)。當(dāng)我們想要通過(guò)鉤子本身來(lái)調(diào)用原函數(shù)的時(shí)候(通常不加處理情況下會(huì)導(dǎo)致一個(gè)死循環(huán)),我們必須要?jiǎng)?chuàng)建一個(gè)特殊的代碼區(qū)塊來(lái)調(diào)用原函數(shù)代碼,這個(gè)代碼區(qū)塊有個(gè)別稱叫做“蹦床”(個(gè)人覺(jué)得在國(guó)內(nèi)更常被稱為跳板),這樣的話就不用管鉤子本身是否在要調(diào)用的函數(shù)體內(nèi)了。
另外需要說(shuō)明的是,API鉤子技術(shù)不是萬(wàn)能的,在受保護(hù)的DLL庫(kù)中幾乎不可能實(shí)現(xiàn)。說(shuō)得詳細(xì)一點(diǎn)就是,比如存在CRC校驗(yàn)保護(hù)的時(shí)候,無(wú)論是從硬盤上還是內(nèi)存中對(duì)庫(kù)DLL庫(kù)代碼的修改都是不可行的。
還有一點(diǎn)就是,經(jīng)典的API鉤子也不適用于DLL庫(kù)導(dǎo)出的“偽函數(shù)”,這里的偽函數(shù)是指導(dǎo)出的變量、類指針等等。因?yàn)樵谶@種類型的“函數(shù)”條件下我們根本不可能在原函數(shù)和我們的代碼之間建立一個(gè)經(jīng)典的代碼鉤子(事實(shí)上根本就沒(méi)有函數(shù)可鉤取)。那是不是就無(wú)可奈何了呢?上面我們提到的方法是改寫原函數(shù)代碼,而下面要介紹的第二種常見(jiàn)方法就是修改PE導(dǎo)出表。只不過(guò)這種方法的局限性很大,遠(yuǎn)不如前一種流行,而且只有很少的一部分鉤子庫(kù)支持它。
DLL轉(zhuǎn)發(fā)
一種更加有創(chuàng)意但是也更為麻煩的API鉤取方式叫做“DLL”轉(zhuǎn)發(fā),它通過(guò)Windows的內(nèi)部機(jī)制來(lái)實(shí)現(xiàn),基本原理就是轉(zhuǎn)發(fā)DLL調(diào)用至其他模塊。
DLL轉(zhuǎn)發(fā)技術(shù)基于“替換表“來(lái)實(shí)現(xiàn),所以也被稱為“DLL代理”,它可以導(dǎo)出所有的原始庫(kù)函數(shù),也可以傳遞所有對(duì)庫(kù)函數(shù)的調(diào)用——除了我們想要鉤取的那部分函數(shù)。而函數(shù)調(diào)用是被通過(guò)一些鮮為人知的Windows機(jī)制傳遞給原函數(shù)庫(kù)的,這樣我們就可以借此來(lái)調(diào)用其他庫(kù)函數(shù),裝作他們本來(lái)就是存儲(chǔ)在我們使用的API鉤子庫(kù)里一樣,但事實(shí)上這些代碼被存儲(chǔ)在其他的庫(kù)中。弄明白以上這些過(guò)程,我們也就不難得知為什么要叫做“DLL轉(zhuǎn)發(fā)”了。
函數(shù)調(diào)用慣例
函數(shù)調(diào)用慣例是一個(gè)低等級(jí)的用于傳遞函數(shù)參數(shù)和處理函數(shù)調(diào)用返回前的堆棧的方式。很大一部分情況下它取決于編譯時(shí)的設(shè)置,并且在大多數(shù)高級(jí)編程語(yǔ)言中可以任意選擇函數(shù)調(diào)用的方式,所以兩者任取其一均可。為了讓我們的API鉤子庫(kù)正常運(yùn)行,它的鉤取函數(shù)也必須使用和已經(jīng)被鉤取的函數(shù)相同的調(diào)用慣例。他們只有在二進(jìn)制情況下相互兼容才不會(huì)引發(fā)像堆棧破壞之類的異常。
表1. 函數(shù)調(diào)用慣例
調(diào)用慣例高度依賴于編譯器的默認(rèn)設(shè)置,比如Delphi默認(rèn)采用register調(diào)用慣例,C語(yǔ)言默認(rèn)采用cdecl調(diào)用慣例。
WinAPI函數(shù)(Windows系統(tǒng)函數(shù))默認(rèn)使用stdcall調(diào)用慣例,所以在調(diào)用之前,函數(shù)的參數(shù)都使用push指令存儲(chǔ)在棧中,然后call指令被執(zhí)行,執(zhí)行完畢后并沒(méi)有必要去修正棧指針ESP,因?yàn)樵趕tdcall調(diào)用慣例中,棧在函數(shù)返回前是自動(dòng)修正的。這里值得一提的是,一個(gè)很有趣的現(xiàn)象是WinAPI中的有些函數(shù)并不使用stdcall而是C語(yǔ)言的cdecl,cdecl并不將參數(shù)存儲(chǔ)于棧,但棧的修正會(huì)在調(diào)用完成后根據(jù)函數(shù)參數(shù)的數(shù)量被編譯器修正。舉一個(gè)例子,user32.dll中的一個(gè)函數(shù)wsprintfA()(它在C函數(shù)庫(kù)中的對(duì)應(yīng)是sprintf())就采用cdecl慣例,這種調(diào)用方式是備受推崇的,因?yàn)檫@樣除了編譯器之外沒(méi)有人知道究竟傳遞了多少個(gè)參數(shù)。
API鉤子實(shí)例
作為一個(gè)例子,我想讓它盡量簡(jiǎn)單易懂一點(diǎn),只會(huì)用到一個(gè)測(cè)試庫(kù)BlackBox.dll,它只導(dǎo)出兩個(gè)函數(shù)Sum()和Divide(),想必你已經(jīng)猜到了,第一個(gè)函數(shù)的作用是兩個(gè)數(shù)的求和,第二個(gè)函數(shù)是兩個(gè)數(shù)的除法。讓我們假設(shè)我們擁有一個(gè)完整的庫(kù)文檔,并且清楚地知道這兩個(gè)函數(shù)使用的調(diào)用慣例(假設(shè)我們有這個(gè)庫(kù)的頭文件),而且我們還知道它們各自都使用哪些參數(shù)。在其他情況下我們需要使用逆向工程來(lái)獲得這些底層信息。
代碼清單1:
- 6// 該函數(shù)將兩個(gè)數(shù)相加并將結(jié)果儲(chǔ)存于Result變量中
- // 成功返回TRUE,失敗返回ERROR
- BOOL __stdcall Sum(int Number1, int Number2, int * Result);
- // 該函數(shù)將兩個(gè)數(shù)相除并將結(jié)果儲(chǔ)存于Result變量中
- // 成功返回TRUE,失敗返回ERROR
- BOOL __stdcall Divide(int Number1, int Number2, int * Result);
在我們的樣例庫(kù)中,Divide()函數(shù)是有bug的,因?yàn)槿绻?就會(huì)導(dǎo)致程序崩潰(假設(shè)我們的程序并沒(méi)有做異常處理),現(xiàn)在我們的目標(biāo)就是來(lái)修補(bǔ)這個(gè)漏洞。
代理DLL
為了修補(bǔ)BlackBox.dll中的漏洞,我們接下來(lái)需要?jiǎng)?chuàng)建一個(gè)中間庫(kù),能夠使Divide()函數(shù)得以有效應(yīng)用而不出現(xiàn)除0異常。該應(yīng)用采用FASM編譯器(波蘭的mr Tomasza Grysztar 創(chuàng)建)的32位匯編器。在下面你會(huì)看到帶有精確注釋的樣例庫(kù)模板。
代碼清單2:樣例庫(kù)的開(kāi)頭
- -------------------------------------------------
- ; DLL 輸出文件格式
- ;-------------------------------------------------
- format PE GUI 4.0 DLL
- ; DLL 入口點(diǎn)函數(shù)名
- entry DllEntryPoint
- ; 導(dǎo)入的Windows函數(shù)和常數(shù)
- include '%fasm%\include\win32a.inc'
注意源代碼的開(kāi)頭,你可以在找到輸出文件的類型聲明,并且在頭文件、DLL庫(kù)的函數(shù)入口點(diǎn)也可以放置這些代碼。
代碼清單3:未初始化的數(shù)據(jù)段
- ;-------------------------------------------------
- ; 未初始化的數(shù)據(jù)段
- ;-------------------------------------------------
- section '.bss' readable writeable
- ; uchwyt HMODULE oryginalnej biblioteki
- hLibOrgdd ?
可執(zhí)行文件和DLL庫(kù)被分割為一個(gè)個(gè)獨(dú)立的部分,他們其中之一是未初始化的數(shù)據(jù)段,這部分并不占用硬盤的空間,僅僅擁作于記錄程序所使用的未初始化變量的整體大小信息。可執(zhí)行文件的段名稱并不重要(它被限制為最多只有8個(gè)字符),通常它會(huì)被賦以公司合同的名稱。在這個(gè)段的聲明中還會(huì)定義訪問(wèn)權(quán)限(如讀、寫、執(zhí)行),但是在FASM編譯器下.bss段的聲明還會(huì)為變量創(chuàng)建一個(gè)未初始化的段。
代碼清單4:數(shù)據(jù)段
- ;-------------------------------------------------
- ; 初始化的數(shù)據(jù)段
- ;-------------------------------------------------
- section '.data' data readable writeable
- ; 原始庫(kù)的名稱
- szDllOrgdb 'BlackBox_org.dll',0
因?yàn)樵紟?kù)已經(jīng)有了名稱了,所以這里我們重命名一個(gè)BlackBox_org.dll(它以ASCII形式存儲(chǔ)于源代碼中,以null結(jié)束),這個(gè)庫(kù)會(huì)在后面用到。
代碼清單5:帶有DLL入口點(diǎn)的代碼段
- ;-------------------------------------------------
- ; 庫(kù)的代碼段
- ;-------------------------------------------------
- section '.text' code readable executable
- ;-------------------------------------------------
- ; DLL庫(kù)入口點(diǎn) (DllMain)
- ;-------------------------------------------------
- proc DllEntryPoint hinstDLL, fdwReason, lpvReserved
- moveax,[fdwReason]
- ; DLL library 加載完畢后立即傳遞事件
- cmpeax,DLL_PROCESS_ATTACH
- je_dll_attach
- jmp_dll_exit
- ; 庫(kù)已經(jīng)加載
- _dll_attach:
- ; 獲得原始 DLL 庫(kù)的句柄
- ; 如果想要調(diào)用原始函數(shù)就會(huì)使用
- pushszDllOrg
- call[GetModuleHandleA]
- mov[hLibOrg],eax
- ; 返回 1 說(shuō)明庫(kù)初始化成功
- moveax,1
- _dll_exit:
- ret
代碼段包含所有庫(kù)函數(shù)和DLL入口點(diǎn)函數(shù)。這是一個(gè)特殊的函數(shù),它在庫(kù)加載以后被Windows系統(tǒng)函數(shù)調(diào)用。代碼段需要被標(biāo)記上可執(zhí)行的標(biāo)記,以此來(lái)告訴操作系統(tǒng)這段內(nèi)存區(qū)域包含可以執(zhí)行的代碼段。如果沒(méi)有這樣標(biāo)記,那么任何想從這塊內(nèi)存區(qū)域執(zhí)行代碼的行為都會(huì)以觸發(fā)CPU處理器的DEP(Data Execution Prevention)內(nèi)存保護(hù)機(jī)制而告終。在初始化函數(shù)內(nèi)部(DllMain),接收到 DLL_PROCESS_ATTACH 事件后我們將使用原始DLL庫(kù)名稱來(lái)獲得他的句柄,也就是 HMODULE (這樣之后就可以被調(diào)用了)。
代碼清單6:過(guò)度優(yōu)化保護(hù)
- ; 調(diào)用任何原始庫(kù)
- ; BlackBox_org.dll 中的函數(shù), 沒(méi)有它FASM編譯器就會(huì)
- ; 移除對(duì)庫(kù)的引用并且不會(huì)被自動(dòng)加載
- calldummy
我們自定義的庫(kù)會(huì)調(diào)用到原始庫(kù),但是如果我們一點(diǎn)引用也不放在源代碼中,F(xiàn)ASM編譯器會(huì)移除所有對(duì)它的引用(優(yōu)化)而且原始庫(kù)并不會(huì)被自動(dòng)加載,這就是為什么在ret指令后直接放了一個(gè)偽調(diào)用的緣故(這樣在任何時(shí)候都不會(huì)執(zhí)行)。
代碼清單7:有效的Divide()函數(shù)代碼
- ;-------------------------------------------------
- ; 我們修改后能夠處理除0錯(cuò)誤的Divide() 函數(shù)
- ;-------------------------------------------------
- proc Divide Number1, Number2, Result
- ; 檢查除數(shù)是否為0
- ; 如果是的話返回ERROR代碼
- movecx,[Number2]
- testecx,ecx
- jeDivisionError
- ; 將第一個(gè)數(shù)字載入 EAX 處理器
- moveax,[Number1]
- ;擴(kuò)展 EDX 寄存器來(lái)處理有符號(hào)數(shù)
- cdq
- ; 現(xiàn)在 EDX:EAX 寄存器對(duì)可以處理64位數(shù)據(jù)了
- ; EDX:EAX / ECX 除法的實(shí)現(xiàn), 除法在EDX:EAX寄存器對(duì)
- ; 上實(shí)現(xiàn),就像對(duì)待64位數(shù)據(jù)一樣, 除法的結(jié)果保存在EAX
- ; 寄存器中, 余數(shù)保存在EDX 寄存器中
- idiv ecx
- ; 檢查有效的指向結(jié)果的指針
- ; 如果沒(méi)有檢測(cè)到則返回error 代碼
- movedx,[Result]
- testedx,edx
- jeDivisionError
- ; 在受保護(hù)的地址存儲(chǔ)除法的結(jié)果
- mov[edx],eax
- ; 以 exit code TRUE (1) 返回
- moveax,1
- jmpDivisionExit
- ; 除法錯(cuò)誤,返回FALSE (0)
- DivisionError:
- sub eax,eax
- DivisionExit:
- ; 從除法函數(shù)中返回
- ; 布爾型的exit 代碼被設(shè)置在 EAX 寄存器中
- ret
- endp
修改后的Divide()函數(shù)的實(shí)現(xiàn)增添了對(duì)除0錯(cuò)誤的校驗(yàn),函數(shù)遇到錯(cuò)誤會(huì)返回錯(cuò)誤代碼FALSE,另外還額外做了對(duì)指向結(jié)果變量result的指針?lè)强諜z查,如果指針指向null也會(huì)報(bào)錯(cuò)。另外請(qǐng)注意,修改后的函數(shù)的調(diào)用慣例與原函數(shù)是完全一致的,并且在我們的這個(gè)例子中使用的是stdcall慣例,所以函數(shù)參數(shù)被傳遞到棧中,函數(shù)返回值儲(chǔ)存于EAX寄存器,棧指針也被FASM編譯器自動(dòng)修復(fù),方法是根據(jù)源代碼中的ret聲明生成ret (number_of_parameters * 4)指令。
代碼清單8:庫(kù)的導(dǎo)入表
- ;-------------------------------------------------
- ; 我們的庫(kù)使用的函數(shù)段
- ;-------------------------------------------------
- section '.idata' import data readable writeable
- ; 在代碼中用到的庫(kù)的列表
- library kernel,'KERNEL32.DLL',\
- blackbox, 'BlackBox_org.dll'
- ; KERNEL32.dll庫(kù)的函數(shù)列表
- importkernel,\
- GetModuleHandleA, 'GetModuleHandleA'
- ; 聲明了原始庫(kù)的用途
- ; DLL 庫(kù)會(huì)被自動(dòng)加載
- importblackbox,\
- dummy, 'Divide'
FASM編譯器允許我們手動(dòng)地定義我們自己的庫(kù)調(diào)用到的庫(kù)和函數(shù),除了標(biāo)準(zhǔn)系統(tǒng)庫(kù),我們需要在這里添加一個(gè)對(duì) BlackBox.dll 的引用。多虧于此,當(dāng)Windows加載我們的鉤子庫(kù)的同時(shí)也會(huì)根據(jù)地址空間加載原始庫(kù),從而無(wú)需再手動(dòng)調(diào)用 LoadLibraryA() 函數(shù)來(lái)加載它。 在某些情況下想要使用導(dǎo)入表來(lái)加載庫(kù)甚至是強(qiáng)制性要求使用 LoadLibraryA() 的,它需要使用多線程應(yīng)用程序中TLS(Thread Local Storage)機(jī)制的動(dòng)態(tài)鏈接庫(kù)來(lái)支持。
代碼清單9:函數(shù)導(dǎo)出表
- ;-------------------------------------------------
- ; 導(dǎo)出表段包含我們的庫(kù)中導(dǎo)出的函數(shù)
- ; 這里我們也許要聲明原始庫(kù)中聲明的函數(shù)
- ;-------------------------------------------------
- section '.edata' export data readable
- ; 導(dǎo)出函數(shù)列表及其指針
- export'BlackBox.dll',\
- Sum, 'Sum',\
- Divide, 'Divide'
- ; 轉(zhuǎn)發(fā)表名稱, 首先目的庫(kù)被存儲(chǔ) (無(wú)需.DLL擴(kuò)展)
- ; 然后最終的函數(shù)名稱被存儲(chǔ)
- Sum db 'BlackBox_org.Sum',0
在這個(gè)段中我們必須聲明原始庫(kù)中的所有函數(shù),而且我們想要鉤取的函數(shù)必須在代碼中得以應(yīng)用,想要傳遞給原始庫(kù)的函數(shù)存儲(chǔ)在一個(gè)特殊的文本格式中:
DestinationDllLibrary.FunctionName
或
DestinationDllLibrary.#1
以此來(lái)順序?qū)牒瘮?shù)而非按照名稱的順序。該機(jī)制的所有內(nèi)部工作均交由Windows系統(tǒng)自身處理。以上為DLL轉(zhuǎn)發(fā)。
代碼清單10:重定位部分
- ;-------------------------------------------------
- ; 重定位部分
- ;-------------------------------------------------
- section '.reloc' fixups data discardable
我們的庫(kù)中最后一個(gè)段是重定位段,它保證了我們的庫(kù)能夠正常運(yùn)行。這是因?yàn)閯?dòng)態(tài)鏈接庫(kù)被加載的基地址是非常多變的,而引起這個(gè)多變性的原因在于指針使用的絕對(duì)地址和匯編器的指令使用的絕對(duì)地址必須根據(jù)當(dāng)前內(nèi)存中的基地址做出更新,而這個(gè)基地址的信息正是由編譯器在重定位段中生成的。
總結(jié)
這篇API鉤子介紹的方法可以被成功應(yīng)用于各種使用動(dòng)態(tài)鏈接庫(kù)的場(chǎng)合,較傳統(tǒng)的經(jīng)典API鉤子方法而言各有利弊,但是在我看來(lái)本文的方法為實(shí)踐打開(kāi)了更大的拓展空間,并提供了一種更加簡(jiǎn)單的改變軟件完整功能性的方法。該方法同樣可以在高級(jí)語(yǔ)言中以適當(dāng)?shù)膶?dǎo)出函數(shù)定義文件(DEF)的方式實(shí)現(xiàn)。