天天看點

緩沖區溢出分析第05課:編寫通用的ShellCode

前言

        我們這次的實驗所要研究的是如何編寫通用的ShellCode。可能大家會有疑惑,我們上次所編寫的ShellCode已經能夠很好地完成任務,哪裡不通用了呢?其實這就是因為我們上次所編寫的ShellCode,是采用“硬編址”的方式來調用相應API函數的。也就是說,我們需要首先擷取所要使用函數的位址,然後将該位址寫入ShellCode,進而實作調用。這種方式對于所有的函數,通用性都是相當地差,試想,如果系統的版本變了,那麼很多函數的位址往往都會發生變化,那麼調用肯定就會失敗了。是以本次的課程主要讨論如何在ShellCode中動态地尋找相關API函數的位址,進而解決通用性的問題。

計算函數名稱的hash值

        這裡可以首先總結一下我們将要用到的函數。

        首先為了顯示對話框,需要使用MessageBoxA這個函數,它位于user32.dll裡面。為了使用這個動态連結庫,還需要使用LoadLibraryA來讀取這個DLL檔案,而LoadLibraryA又位于kernel32.dll中。因為所有的Win32程式都會自動加載kernel32.dll,是以這裡我們無需再使用LoadLibraryA來加載kernel32.dll。最後為了正常退出程式,還需要使用ExitProcess,它同樣位于kernel32.dll裡面。

        由于ShellCode最終是要放進緩沖區的,為了使得ShellCode更加通用,能被大多數緩沖區容納,我們總是希望ShellCode盡可能地短小精悍。是以我們在系統中搜尋函數名的時候,一般情況下并不會使用諸如“LoadLibraryA”這麼長的字元串直接進行比較查找。而是首先會對函數名進行hash運算,而在系統中搜尋所要使用的函數時,也會先對系統中的函數進行hash運算,這樣隻需要比較二者的hash值就能夠判定目标函數是不是我們想要查找的了。盡管這樣會引入額外的hash算法,但是卻可以節省出存儲函數名字的空間。

        計算以上三個API函數的hash值的程式如下:

[cpp] view plaincopy
1. #include <stdio.h>
2. #include <windows.h>
3. DWORD GetHash(char *fun_name)
4. {
5. DWORD digest = 0;
6. while(*fun_name)
7. {
8. digest = ((digest << 25) | (digest >> 7 ));
9. digest += *fun_name;
10. fun_name++;
11. }
12. return digest;
13. }
14. int main()
15. {
16. DWORD hash;
17. "MessageBoxA");
18. "The hash of MessageBoxA is 0x%.8x\n", hash);
19. "ExitProcess");
20. "The hash of ExitProcess is 0x%.8x\n", hash);
21. "LoadLibraryA");
22. "The hash of LoadLibraryA is 0x%.8x\n", hash);
23. getchar();
24. return 0;
25. }      

        運作結果如下:

緩沖區溢出分析第05課:編寫通用的ShellCode

圖1

        可見,通過hash算法,我們能夠将任意長度的函數名稱變成四個位元組(DWORD)的長度。

        這裡給大家簡單分析一下上述hash值的計算方法。假設現在有一個函數,名為“AB”,然後調用GetHash函數:

hash =GetHash("AB");

        進入GetHash函數,它會将函數名稱中的字元一個一個地分别取出進行計算,有幾個字元就循環計算幾次。首先是第一次循環,取出字元“A”,然後有:

digest= ((digest << 25) | (digest >> 7 ));

        這裡由于digest在上面被指派為0,且為DWORD類型,是以這裡不管怎麼計算,它的值都是0。然後計算:

digest+= *fun_name;

        此時的digest是0,*fun_name儲存的是第一個字元“A”,它們相加也就是ASCII碼值的相加,結果就是digest的值為“00000000 0000000000000000 01000001”。然後執行語句:

fun_name++;

        令指針指向第二個字元“B”,進而進入第二次循環。首先計算:

digest= ((digest << 25) | (digest >> 7 ));

        首先将digest左移25位,即“10000010 0000000000000000 00000000”,然後将其右移7位,即“10000010 00000000 00000000 00000000”,然後江這兩個值做“或”運算,則digest的值為“10000010 0000000000000000 00000000”。事實上,上述語句的目的是實作digest的循環右移7位(或循環左移25位),由于C語言沒有直接實作循環移位的運算符号,是以隻能通過這種方式運算。然後計算:

digest+= *fun_name;

        也就是将digest的值加上“B”的ASCII碼值,結果為“1000001000000000 00000000 01000010”,這也就是最終的運算結果,以十六進制顯示就是0x82000042。

        下面就可以編寫彙編代碼,首先是讓函數的hash值入棧:

push 0x1e380a6a    ; MessageBoxA的hash值

push 0x4fd18963    ; ExitProcess的hash值

push 0x0c917432    ; LoadLibraryA的hash值

mov esi,esp             ; esi儲存的是棧頂第一個函數,即LoadLibraryA的hash值

        然後編寫用于計算hash值的代碼:

hash_loop:

movsx   eax,byte ptr[esi]   // 每次取出一個字元放入eax中

cmp     al,ah                      // 驗證eax是否為0x0,即結束符

jz      compare_hash         // 如果上述結果為零,說明hash值計算完畢,則進行hash值的比較

ror     edx,7                       // 如果cmp的結果不為零,則進行循環右移7位的操作

add     edx,eax                 // 将循環右移的值不斷累加

inc     esi                           // esi自增,用于讀取下一個字元

jmp     hash_loop             // 跳到hash_loop的位置繼續計算

        這樣通過循環,就能夠計算出函數名稱的hash值,請大家注意彙編的這種寫法。

擷取kernel32.dll的位址

        由于我們需要動态擷取LoadLibraryA()以及ExitProcess()這兩個函數的位址,而這兩個函數又是存在于kernel32.dll中的,是以這裡需要先找到kernel32.dll的位址,然後通過對其進行解析,進而查找那兩個函數。

        所有的Win32程式都會自動加載ntdll.dll以及kernel32.dll這兩個最基礎的動态連結庫。是以如果想要在 Win32平台下定位kernel32.dll中的API位址,可以使用如下方法(這裡結合WinDbg來給大家示範):

        (1)通過段選擇字FS在記憶體中找到目前的線程環境塊TEB。這裡可以利用本地調試,輸入”!teb”指令:

緩沖區溢出分析第05課:編寫通用的ShellCode

圖2

        (2)線程環境塊偏移位置為0x30的地方存放着指向程序環境塊PEB的指針。結合上圖可見,PEB的位址為0x7ffde000。

        (3)程序環境塊中偏移位置為0x0c的地方存放着指向PEB_LDR_DATA結構體的指針,其中,存放着已經被程序裝載的動态連結庫的資訊。

緩沖區溢出分析第05課:編寫通用的ShellCode

圖3

        (4)PEB_LDR_DATA結構體偏移位置為0x1c的地方存放着指向子產品初始化連結清單的頭指針InInitializationOrderModuleList。

緩沖區溢出分析第05課:編寫通用的ShellCode

圖4

        (5)子產品初始化連結清單InInitializationOrderModuleList中按順序存放着PE裝入運作時初始化子產品的資訊,第一個連結清單節點是ntdll.dll,第二個連結清單結點就是kernel32.dll。比如可以先看看InInitializationOrderModuleList中的内容:

緩沖區溢出分析第05課:編寫通用的ShellCode

圖5

        這裡的0x00191f28儲存的是第一個鍊節點的指針,解析一下這個結點:

緩沖區溢出分析第05課:編寫通用的ShellCode

圖6

        然後繼續解析,檢視第二個結點:

緩沖區溢出分析第05課:編寫通用的ShellCode

圖7

        可見第二個節點偏移0x08個位元組正是kernel32.dll,其位址為0x7c800000。如果不放心,可以驗證一下:

緩沖區溢出分析第05課:編寫通用的ShellCode

圖8

        綜合以上,可以編寫彙編代碼為:

[plain] view plaincopy
1. mov     ebx,fs:[edx+0x30]  // [TEB+0x30]是PEB的位置
2. mov     ecx,[ebx+0xC]      // [PEB+0xC]是PEB_LDR_DATA的位置
3. mov     ecx,[ecx+0x1C]   // [PEB_LDR_DATA+0x1C]是InInitializationOrderModuleList的位置
4. mov     ecx,[ecx]        // 進傳入連結表第一個就是ntdll.dll
5. mov     ebp,[ecx+0x8]       // ebp儲存的是kernel32.dll的基位址      

        這樣就實作了動态擷取kernel32.dll的位址:

緩沖區溢出分析第05課:編寫通用的ShellCode

圖9

解析kernel32.dll的導出表

        既然已經找到了kernel32.dll,由于它也是屬于PE檔案,那麼我們可以根據PE檔案的結構特征,對其導出表進行解析,不斷周遊搜尋,進而找到我們所需要的API函數。其步驟如下:

        (1)從kernel32.dll加載基址算起,偏移0x3c的地方就是其PE頭。

        (2)PE頭偏移0x78的地方存放着指向函數導出表的指針。

        (3)至此,可以按如下方式在函數導出表中算出所需函數的入口位址:

        ● 導出表偏移0x1c處的指針指向存儲導出函數偏移位址(RVA)的清單。

        ● 導出表偏移0x20處的指針指向存儲導出函數函數名的清單。

        ● 函數的RVA位址和名字按照順序存放在上述兩個清單中,我們可以在名稱清單中定位到所需的函數是第幾個,然後在位址清單中找到對應的RVA。

        ● 獲得RVA後,再加上前邊已經得到的動态連結庫的加載位址,就獲得了所需API此刻在記憶體中的虛拟位址,這個位址就是我們最終在ShellCode中調用時需要的位址。

        按照這個方法,就可以獲得kernel32.dll中的任意函數。

[plain] view plaincopy
1. // ==== 在PE檔案中查找相應的API函數 ====
2. find_functions:
3. pushad                      // 保護所有寄存器中的内容
4. mov     eax,[ebp+0x3C]      // PE頭
5. mov     ecx,[ebp+eax+0x78]  // 導出表的指針
6. add     ecx,ebp
7. mov     ebx,[ecx+0x20]      // 導出函數的名字清單
8. add     ebx,ebp
9. xor     edi,edi             // 清空edi中的内容,用作索引
10. // ==== 循環讀取導出表函數 ====
11. next_function_loop:
12. inc     edi                 // edi不斷自增,作為索引
13. mov     esi,[ebx+edi*4]     // 從清單數組中讀取
14. add     esi,ebp             // esi儲存的是函數名稱所在位址
15. cdq                         // 把edx的每一位置成eax的最高位,再把edx擴充為eax的高位,即變為64位      

        截圖如下:

緩沖區溢出分析第05課:編寫通用的ShellCode

圖10

        至此,所有彙編代碼編寫完畢。利用VC生成可執行檔案,運作結果如下:

緩沖區溢出分析第05課:編寫通用的ShellCode

圖11

        下面就是ShellCode的提取。

提取ShellCode

        這次我使用OD進行提取,并利用UE對其進行編輯。首選需要在OD中找到我們所編寫的代碼的位置:

緩沖區溢出分析第05課:編寫通用的ShellCode

圖12

        然後将這些代碼全部提取出來,可儲存為txt檔案格式,然後使用UE的“列塊模式“,就能輕松對其編輯:

緩沖區溢出分析第05課:編寫通用的ShellCode

圖13

        這樣就可以生成我們所需要的ShellCode了。

ShellCode的使用

        我們這次所生成的ShellCode比較長,是以盡管我們這次已經得到了一段具備跨平台、健壯性、穩定性、通用性等各方面比較優秀的ShellCode,但是不見得能夠用于所有的緩沖區溢出的情況。比如如果直接将這個ShellCode用于我們之前所創造的含有緩沖區溢出隐患的程式中,就會出現問題:

緩沖區溢出分析第05課:編寫通用的ShellCode

圖14

        當程式執行到0x00401511處的時候,就會卡住了,這條語句位于strcpy()中,作用是将我們所編寫的ShellCode拷貝到緩沖區中,而接下來要拷貝的,就是EDX中的“3C8B66DD“,需要拷貝到0x00130000這個位置。但是由于0x0012FFFF為系統預設的棧的底端,我們無法越過這個位置繼續拷貝,于是我們的棧溢出利用就失敗了。那麼計算一下,我們這個程式允許我們使用的棧空間的長度為0x0012FFFF減去0x0012FF78,也就是136個位元組,超過了就會利用失敗。是以從這個角度來說,我們還需要精簡我們的ShellCode,或者采取其他的方式,使得我們的代碼能夠得到執行。

        這裡我們首先将buffer的空間修改為256個位元組,然後修改我們上文中所生成的ShellCode,這裡的修改主要是用\x90将buffer空間以及EBP填充滿,然後将傳回位址修改為0x0012FE80,也就是系統為buffer配置設定的首位址。其原理就是我們正常用ShellCode填充buffer,将傳回位址覆寫為buffer首位址,這樣函數傳回時,就能夠執行我們的ShellCode了:

緩沖區溢出分析第05課:編寫通用的ShellCode

圖15

繼續閱讀