windows下shellcode編寫入門
0x00、介紹
比方說你手頭上有一個IE或FlashPlayer現成的漏洞利用程式碼,但它只能夠開啟計算器calc.exe。但是這實際上並沒有什麼卵用,不是嗎?你真正想要的是可以執行一些遠端命令或實現其他有用的功能。
在這種情況下,你可能想要利用已有的標準shellcode,比如來自Shell Storm資料庫或由Metasploit的msfvenom工具生成。不過,你必須先理解編寫shellcode的基本原則,才可以在自己的漏洞利用程式碼中有效地使用它們。對於不熟悉這個術語的同學們,可以參考一下維基百科:
在電腦保安中,shellcode是一小段程式碼,可以用於軟體漏洞利用的載荷。被稱為“shellcode”是因為它通常啟動一個命令終端,攻擊者可以通過這個終端控制受害的計算機,但是所有執行類似任務的程式碼片段都可以稱作shellcode。……Shellcode通常是以機器碼形式編寫的。
shellcode是一段可用於漏洞利用載荷的機器碼。“機器碼”又是什麼?讓我們以下面的C程式碼為例:
- 1
- 2
- 3
- 4
- 5
- 6
這段C程式碼會編譯成如下彙編程式碼:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
此處,我們需要注意下main程式以及對printf函式的呼叫。正如偵錯程式中突出顯示的,這些程式碼已經編譯成機器碼:
所以,“55 8B EC 68 00 B0 33 01 … ”便是上述C程式碼的機器碼。
0x01、shellcode如何應用到漏洞利用
舉一個簡單漏洞利用的示例,一個基於棧的緩衝區溢位漏洞。
- 1
- 2
- 3
- 4
- 5
利用此漏洞的主要思路如下:(請注意本文目的不是詳述緩衝區溢位的漏洞利用原理)
1)嚮應用程式傳送長度超過20位元組的字串,其中包含shellcode。
2)由於寫入資料越過靜態分配緩衝區的邊界,棧結構遭到破壞。同時,shellcode也會被放置在棧上。
3)字串通過自定義的記憶體地址重寫棧上某塊重要資料(如儲存的EIP或函式指標)
4)程式會從棧上跳轉到你的shellcode,開始執行其中的機器碼指令。
如果可以成功的利用此漏洞,你也能夠執行自己的shellcode,並實際利用該漏洞做點有用的事情,而不僅僅是讓程式崩潰。比如shellcode可以開啟一個命令終端,下載並執行檔案,重啟計算機、啟用遠端桌面、或其他操作。
0x02、Shellcode特點
shellcode不能是任意的機器碼。在編寫自己的shellcode時,我們必須需要注意shellcode的一些限制:
1)不能使用字串的直接偏移。
2)不能確定函式的地址(如printf)
3)必須避免一些特定字元(如NULL位元組)
關於上述的每個問題,讓我們進行一個簡短的討論。
-
字串的直接偏移
即使你在C/C++程式碼中定義一個全域性變數,一個取值為“Hello world”的字串,或直接把該字串作為引數傳遞給某個函式。但是,編譯器會把字串放置在一個特定的Section中(如.rdata或.data)。
-
函式地址
在shellcode中,我們卻不能以逸待勞了。因為我們無法確定包含所需函式的DLL檔案是否已經載入到記憶體。受ASLR(地址空間佈局隨機化)機制的影響,系統不會每次都把DLL檔案載入到相同地址上。而且,DLL檔案可能隨著Windows每次新發布的更新而發生變化,所以我們不能依賴DLL檔案中某個特定的偏移。
我們需要把DLL檔案載入到記憶體,然後直接通過shellcode查詢所需要的函式。幸運的是,Windows API為我們提供了兩個函式:LoadLibrary和GetProcAddress。我們可以使用這兩個函式來查詢函式的地址。
-
避免空位元組
空位元組(NULL)的取值為:0×00。在C/C++程式碼中,空位元組被認為是字串的結束符。正因如此,shellcode存在空位元組可能會擾亂目標應用程式的功能,而我們的shellcode也可能無法正確地複製到記憶體中。
雖然不是強制的,但類似利用strcpy()函式觸發緩衝區溢位的漏洞是非常常見的情況。該函式會逐位元組拷貝字串,直至遇到空位元組。因此,如果shellcode包含空位元組,strcpy函式便會在空位元組處終止拷貝操作,引發棧上的shellcode不完整。正如你所料,shellcode當然也不會正常的執行。
例如MOV EAX,0; XOR EAX,EAX; 兩條指令從功能上來說是等價的,但你可以清楚地看到第一條指令包含空位元組,而第二條指令卻包含空位元組。雖然空位元組在編譯後的程式碼中非常常見,但是我們可以很容易地避免。
還有,在一些特殊情況下,shellcode必須避免出現類似\r或\n的字元,甚至只能使用字母數
0x03、Linux平臺與Windows平臺的shellcode對比
相對於Windows平臺,編寫針對Linux平臺的Shellcode可能更為簡單。這是因為在linux平臺上,我們可以輕鬆地通過0×80中斷執行類似write、execve或send的系統呼叫。
例如,在linux平臺上執行“Hello world”shellcode只需要以下幾個步驟:
1)指定系統呼叫syscall序號(如“write”)。
2)指定系統呼叫syscall的引數(如,stdout,“Hellow, world”,字串長度)
3)呼叫0x80中斷來執行系統呼叫syscall。
這將會發起呼叫:write(stdout, “Hello, world”, length).
1)獲取kernel32.dll 基地址;
2)定位 GetProcAddress函式的地址;
3)使用GetProcAddress確定 LoadLibrary函式的地址;
4)然後使用 LoadLibrary載入DLL檔案(例如user32.dll);
5)使用 GetProcAddress查詢某個函式的地址(例如MessageBox);
6)指定函式引數;
7)呼叫函式。
0x04、程序環境塊(PEB)
在Windows作業系統中,PEB是一個位於所有程序記憶體中固定位置的結構體。此結構體包含關於程序的有用資訊,如可執行檔案載入到記憶體的位置,模組列表(DLL),指示程序是否被除錯的標誌,還有許多其他的資訊。
重要的是理解作業系統如何呼叫這個結構體。這個結構在不同Windows作業系統版本上並不是固定的,所以它可能隨著新的Windows發行版發生改變,但一些通用資訊會保持不變。
正如前文中討論的,DLL(由於ASLR機制)可以載入到不同的記憶體位置,因此我們不能在shellcode中使用固定的記憶體地址。不過,我們可以使用PEB這個結構,位於固定的記憶體位置,從而查詢DLL載入到記憶體中的地址。
如果熟悉C/C++程式語言,你會很容易理解這個結構體包含哪些資訊及其佈局。微軟官方文件顯示如下欄位:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
如你所見,一些稱作“保留(Reserved)”欄位沒有相應的描述,而其他一些欄位具有相應的文件描述。
對於不熟悉C/C++的同學們,你需要理解以下概念:BYTE表示1個位元組。PVOID表示1個指標(或1個記憶體地址)-因此,在0×86系統上(32位系統)佔用4個位元組。 PPEB_LDR_DATA是1個指標,指向自定義結構體PEB_LDR_DATAPEB_LDR_DATA。其中第1個欄位保留2個位元組(Reserved1[2]是一個包含2個BYTE的陣列)。BeingDebugged標誌是1個位元組,緊隨著另一個位元組(Reserved2)。Reserved3[2]是包含2個指標(2*4位元組=8位元組)的陣列,而Ldr是一個指標-4個位元組。
PEB_LDR_DATA包含如下資訊:
- 1
- 2
- 3
- 4
- 5
LIST_ENTRY結構是一個簡單的雙向連結串列,包含指向下一個元素(Flink)的指標和指向上一個元素的指標(Blink),其中每個指標佔用4個位元組:
- 1
- 2
- 3
- 4
InMemoryOrderModuleList欄位是一個指標,指向LDR_DATA_TABLE_ENTRY 結構體上的LIST_ENTRY欄位。但是它不是指向LDR_DATA_TABLE_ENTRY 起始位置的指標,而是指向這個結構的InMemoryOrderLinks欄位。Flink和Blink指向LIST_ENTRY結構體的指標。
讓我們一步一步的梳理:
1.讀取PEB結構
2.跳轉到0xC偏移處讀取Ldr指標
3.跳轉到0x14偏移處讀取 InMemoryOrderModuleList欄位
現在,我們來到了載入至記憶體首個模組的InMemoryOrderLinks元素。這個模組是可執行檔案(例如calc.exe)。我們想要遍歷所有已載入的DLL檔案。InMemoryOrderLinks是一個LIST_ENTRY結構體,前面4個位元組是Flink指標,而後面4個位元組是Blink指標,通過前面的4個位元組可以幫助我們遍歷到第2個已載入模組。只需再次執行這個過程,我們便可以訪問到第3個已載入模組的資訊。
InMemoryOrderModuleList連結串列按照如下次序顯示所有已載入模組:
- 1
- 2
- 3
正如在第1部分中討論的,我們需要訪問kernel32.dll ,以便呼叫類似GetProcAddress 和 LoadLibrary函式,幫助我們再呼叫其他Windows API函式。
為達到此目的,我們需要從當前的LDR_DATA_TABLE_ENTRY結構體上讀取Dllbase欄位(DLL載入到記憶體中的位置)。DLLBase位於此結構的0×18偏移處。但是考慮到InMemoryOrderLinks欄位又位於LDR_DATA_TABLE_ENTRY 結構體0×8偏移處,因此為獲取獲取DllBase,現在我們只需要偏移0×10個位元組。下面是查詢kernel32.dll記憶體地址所需步驟的概述:
雖然繪畫不是那麼出色,但希望你可以明白其中的工作原理。你只需瞭解使用“Flink”指標就可以遍歷所有已載入模組。別讓這張圖給嚇著了,接下來你將會看到,我們完全可以在8行左右的程式碼內實現這個遍歷操作。
0x05、PE檔案格式
可移植的可執行檔案(PE)是Windows系統上可執行檔案和動態連結庫所使用的檔案格式。此格式描述這些檔案所包含的內容:頭(header)及包含所有程式碼和資料的節(Section,又稱區段、區塊等)。網上有許多介紹PE檔案格式的檔案愛你,但我們在這裡只介紹編寫shellcode所必需的資訊:頭(header),節(section)和匯出表。
PE檔案的簡單示意圖:
正如你在這圖片中所看到的,PE檔案包含:
DOS頭
DOS存根(stub)
PE頭
節表
節(程式碼和資料節)
使用hex editor工具開啟PE檔案,可以給我們帶來更詳盡的內容:
PE格式是相當複雜的,但我們只需瞭解如何解析PE頭部來獲取匯出函式。讓我們先從DOS頭開始,DOS頭可以表示成如下結構:
你可以在C/C++編譯器的“WinNT.h”頭部檔案中找到完整的結構定義以及所需的其他結構。所有的PE檔案(EXE或DLL)都是從這個結構開始。因此,如果在記憶體中找到某個模組,我們也會在那個記憶體地址上找到這個結構體。你可以通過前兩個位元組“MZ”來識別,這兩個位元組是e_magic 欄位,表示DOS頭的“簽名”。
我們只需要瞭解該結構的 e_lfanew 欄位。這個欄位位於0x3C偏移處,它指出了PE頭所的位置。PE頭是包含了如下資訊的結構體:
它包含PE簽名(如果使用編輯器開啟一個PE檔案,你可以看到“PE”字串)。FileHeader是一個結構體,包含諸如節(程式碼和資料)數目、機器型別(X86,X64,ARM),以及“特徵(characteristics)”等資訊,可以用來判斷檔案是可執行檔案檔案(.exe)還是動態連結庫(.dll)。
對於我們而言,OptionalHeader(可選頭)是一個包含更多有用資訊的結構體:
它包含以下資訊:
AddressOfEntryPoint:exe/dll 開始執行程式碼的地址,即入口點地址。
ImageBase:DLL載入到記憶體中的地址,即映像基址。
DataDirectory-匯入或匯出函式等資訊。
我們只對最後一個欄位感興趣, DataDirectory,因為需要獲得匯出函式。DLL的工作原理:它包含各種函式的定義,然後再將這些函式匯出。所以其他應用程式只需將這個DLL載入到記憶體,然後查詢匯出函式並進行呼叫。例如,“MessageBox”是一個“user32.dll”的匯出函式(實際上,這個函式有兩個版本:ASCII和Unicode)。
此結構的 DataDirectory欄位是由 IMAGE_DATA_DIRECTORY 元素組成的陣列。 IMAGE_DATA_DIRECTORY結構的定義如下:
IMAGE_DATA_DIRECTORY結構(16位元組)位於OptionalHeader(可選頭)結構體的最後。對於我們而言,只需要瞭解第1個數據目錄是“匯出目錄”。
為了訪問匯出目錄,我們只需跟隨這個結構的 VirtualAddress(相對虛擬地址)欄位,它指向匯出目錄的開始位置。 DWORD是佔用4個位元組的資料型別,而 WORD僅佔用2個位元組。如果你計算截止到DataDirectory陣列所有元素佔用空間的大小,你會發現從PE頭的起始位置到 DataDirectory陣列的起始位置一共是120位元組(0×78)。所以我們可以在0×78偏移處找到輸出目錄的相對虛擬地址(VirtualAddress欄位)。
匯出目錄的結構如下:
我們將會使用這個結構的如下欄位:
AddressOfFunctions:指向一個DWORD型別的陣列,每個陣列元素指向一個函式地址。
AddressOfNames:指向一個DWORD型別的陣列,每個陣列元素指向一個函式名稱的字串。
AddressOfNameOrdinals:指向一個WORD型別的陣列,每個陣列元素表示相應函式的排列序號(16位整數)。
接下以包含3個函式的DLL檔案作為示例:
AddressOfFunctions = 0x11223344 -> [0x11111111, 0x22222222, 0x33333333]:0x11223344指向一個數組,該陣列包含函式的地址:0x11111111,0x22222222和0x33333333。
AddressOfNames = 0x12345678 -> [0xaaaaaaaa ->“func0”, 0xbbbbbbbb -> “func1”, 0xcccccccc -> “func2”] :0x12345678是指向一個數組,其中陣列元素指向函式名稱字串:例如0xaaaaaaaa指向字串“func1”,即匯出函式的名稱。
AddressOfNameOrdinals = 0xabcdef —> [0x00, 0x01, 0x02] :0xabcdef是一個指向整數(16位)陣列,陣列元素表示相應函式在AddressOfFunctions陣列上的偏移值。
為利用函式名稱獲取函式地址,我們需要通過解析 AddressOfNames陣列來檢查名稱。第1個函式(func0)的序號是0,第2個函式(func1)的序號是1,而第3個函式(func2)的序號是2。因此,如果我們需要查詢函式func2的地址,我們只需訪問 AddressOfFunctions陣列的第2個元素(從0開始編號)。
總之,就像這樣:
函式地址=AddressOfFunctions[ 序號(函式名稱) ]
別被嚇到了,接下來你會看到,我們完全可以使用15-20行的彙編程式碼來搞定所有事情。
0x06、組合語言
正如你在文字中看到的,我們完全可以使用C/C++高階語言來編寫shellcode。 但若想要正確地瞭解Shellcode是什麼,Shellcode如何工作,以及如何修改Shellcode,你需要理解和編寫彙編程式碼。
本章節僅提供組合語言的一些基本知識。要想深入理解組合語言,請不要依賴本章節,你可以閱讀一下諸如此類的好文章。本文的介紹並不是很完整,僅覆蓋一些常見操作,從而讓大傢俱備編寫簡單shellcode的能力。
為避免因不同組合語言差異而導致的複雜性,以下編寫的示例都是使用Microsoft Visual C++ Express版編譯器上的內部組合語言編譯器。當然,你也可以使用像MASM, NASM 或YASM之類的組合語言編譯器。
首先讓我們從開“變數”開始。處理器使用不同的暫存器(當變數考慮)來儲存臨時資料。每個暫存器都具有各自的用途,但是這裡我們將其統一視為“全域性變數”。更詳細的介紹,你可以閱讀這篇文章。
通用暫存器:EAX,EBX,ECX,EDX,ESI和EDI。每個暫存器都可以儲存4位元組的資料。同時,它們最後2個位元組也可以單獨稱作AX,BX,CX,DX,SI和DI。最後1個位元組可以AL,BL,CL,DL的名稱來訪問。
比方說程式從0×12345678地址開始執行。其中有一個特定暫存器儲存當前執行指令的地址,稱作EIP(指令指標)。執行完一條指令之後,這個暫存器會自動更改為下一條指令的地址。現在已經擁有“變數”,讓我們看看可以利用它們做些什麼。為完成一些有用的操作,我們需要使用多個指令。
指令:
mov 目的,源:把資料從源運算元拷貝到目的運算元。
add 目的,源:把源運算元加到目的運算元,或目的運算元=目的運算元+源運算元。
sub 目的,源:目的運算元減去源運算元,或目的運算元=目的運算元-源運算元。
inc 目的:目的運算元的取值加1
dec目的:目的運算元的取值自減1
示例:
- 1
- 2
- 3
- 4
- 5
- 6
- 7
你可以像下圖一樣在Visual C++平臺上測試這個程式。
我們可以點選左側的灰色線框來放置斷點,Visual C++偵錯程式將會在斷點處暫停程式的執行。當你啟動這個程式時,它會在指定的斷點處停止執行。此時,你會在開發環境的底部看到“Watch1”視窗。你可以在這個視窗上新增暫存器名稱,從而檢視它們的取值。所以,新增EAX、EBX等暫存器名稱,然後觀察它們的取值。
你可以按下F11來單步執行指令,然後在watch視窗上觀察暫存器的取值是如何變化的。或者你也可以只把滑鼠放在暫存器名稱的上方來檢視它的取值。請注意這些只是基本的除錯操作,要獲得更高階的除錯功能,你可以使用像Immunity Debugger之類的偵錯程式,但是為簡單起見,你使用Visual C++自帶的偵錯程式即可。
程式的控制流會經過一些決策序列,即通過比較兩個數值來採取不同的行為。首先,你需要學會使用標籤(label)。標籤只是為了標記程式碼的不同位置。你可以使用“跳轉至(jumps)”來訪問不同的程式碼位置。
有用的指令:
jump 地址/標籤。無條件地跳轉到某個標籤或記憶體地址
cmp 目的,源:通過目的運算元減去源運算元來比較目的運算元和源運算元(不改變運算元的值)。“結果”也不會被儲存下來,只需記住如果源運算元等於目的運算元,計算機將會設定“Zero Flag”標誌位。這個標誌位會被接下來的條件轉移指令所使用。
jz 地址/標籤:如果已設定了“Zero Flag”標誌位(jz=如果為零就跳轉),跳轉到指定標籤或地址。因此如果之前“cmp”指令所比較的引數是相等的,“Zero Flag”便會被設定,然後程式碼跳轉到指定地址或標籤。如果不等,什麼事情都不會發生,程式將接著執行下一條指令。
jnz 地址/標籤:與jz剛好相反(jnz=如果不為零就跳轉),如果“Zero Flag”未被設定,程式碼將會跳轉到指定地址。也就是所說,前面的“cmp”指令所比較的引數是不相等的。
組合語言還有許多其他的跳轉指令,但這些對入門而言已經足夠。作為示例,你可以嘗試以下程式碼:
現在,讓我們把話題轉到組合語言的重點內容:棧。棧是一種記憶體中的資料結構,你可以在其中儲存資料。你可以將其視為一塊記憶體空間,然後像堆疊盤子一樣存放資料,一個數據放在另一個數據的上面,而你只可以從頂部取資料。
關於棧,有兩條非常有用的指令:
push 資料:把資料壓入棧中
pop 暫存器:從棧頂取出資料,然後儲存在指定的暫存器
同時,有兩個暫存器“指向”棧:
ESP暫存器(棧指標):指向棧頂
EBP暫存器(基指標,或幀指標):指向棧底
在與棧打交道時,會發生一些重要的事情。比如ESP,表示棧頂,取值為0×11223344。如果我們通過“push 0xaaaaaaaa”指令把4位元組的資料壓入棧中,0xaaaaaaaa資料會存入棧的頂部,而ESP取值會減少4個位元組。所以,我們可以說棧是往低地址空間增長的。在push指令之後,ESP的取值將會變為0×11223340。
如果我們從棧上獲取資料,情況便會顛倒過來:資料從棧上移除(實際上,由於編譯優化的原因,資料仍儲存那裡,未被清除),ESP取值會增加4個位元組。
看似困難,其實不然。例如:
思考一下棧上的數學運算,假定我們在棧上壓入0×20位元組的資料(通過8條push指令,0×20=32),我們可以只修改ESP值來輕易地清理棧上的空間:addESP, 0×20。這比8條pop指令更為簡單有效。現在我們學習呼叫函式。有兩種常見的函式呼叫方式:stdcall和cdecl。WindowsAPI使用stdcall呼叫約定(方式),我們僅討論這種函式呼叫方式。不過,它們是類似的,你可以從 這裡找到更多的資訊。讓我們以下面的函式作為示例:
- 1
- 2
- 3
- 4
若要呼叫function(0×11,0×22),我們需要了解以下內容:
1.從右往左把引數壓入棧中。
2.使用“call function”指令來呼叫函式
3.call指令會自動地把下一條指令的地址壓入棧中(ESP的取值也會減小)
4.函式返回後,EAX暫存器會儲存函式執行的結果。
在該函式執行完成之後,EAX暫存器的值為0×33(0×11+0×22=0×33)。