1. 程式人生 > >windows下shellcode編寫入門

windows下shellcode編寫入門

0x00、介紹

比方說你手頭上有一個IE或FlashPlayer現成的漏洞利用程式碼,但它只能夠開啟計算器calc.exe。但是這實際上並沒有什麼卵用,不是嗎?你真正想要的是可以執行一些遠端命令或實現其他有用的功能。

在這種情況下,你可能想要利用已有的標準shellcode,比如來自Shell Storm資料庫或由Metasploit的msfvenom工具生成。不過,你必須先理解編寫shellcode的基本原則,才可以在自己的漏洞利用程式碼中有效地使用它們。對於不熟悉這個術語的同學們,可以參考一下維基百科:

在電腦保安中,shellcode是一小段程式碼,可以用於軟體漏洞利用的載荷。被稱為“shellcode”是因為它通常啟動一個命令終端,攻擊者可以通過這個終端控制受害的計算機,但是所有執行類似任務的程式碼片段都可以稱作shellcode。……Shellcode通常是以機器碼形式編寫的。

shellcode是一段可用於漏洞利用載荷的機器碼。“機器碼”又是什麼?讓我們以下面的C程式碼為例:

#include 
int main()
{
    printf("Hello, World!\n");
    return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

這段C程式碼會編譯成如下彙編程式碼:

_main PROC
    push ebp
    mov ebp, esp
    push OFFSET HelloWorld ; "Hello, World!\n"
    call _printf
    add esp, 4
    xor eax, eax
    pop
ebp ret 0 _main ENDP
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

此處,我們需要注意下main程式以及對printf函式的呼叫。正如偵錯程式中突出顯示的,這些程式碼已經編譯成機器碼:

這裡寫圖片描述

所以,“55 8B EC 68 00 B0 33 01 … ”便是上述C程式碼的機器碼。

0x01、shellcode如何應用到漏洞利用

舉一個簡單漏洞利用的示例,一個基於棧的緩衝區溢位漏洞。

void exploit(char *data)
{
    char buffer[20];      // 緩衝區位於棧上
    strcpy(buffer, data); // 使用strcpy複製資料
}
  • 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位元組) 
關於上述的每個問題,讓我們進行一個簡短的討論。

  1. 字串的直接偏移

    即使你在C/C++程式碼中定義一個全域性變數,一個取值為“Hello world”的字串,或直接把該字串作為引數傳遞給某個函式。但是,編譯器會把字串放置在一個特定的Section中(如.rdata或.data)。

    這裡寫圖片描述

  2. 函式地址

    在shellcode中,我們卻不能以逸待勞了。因為我們無法確定包含所需函式的DLL檔案是否已經載入到記憶體。受ASLR(地址空間佈局隨機化)機制的影響,系統不會每次都把DLL檔案載入到相同地址上。而且,DLL檔案可能隨著Windows每次新發布的更新而發生變化,所以我們不能依賴DLL檔案中某個特定的偏移。

    我們需要把DLL檔案載入到記憶體,然後直接通過shellcode查詢所需要的函式。幸運的是,Windows API為我們提供了兩個函式:LoadLibrary和GetProcAddress。我們可以使用這兩個函式來查詢函式的地址。

  3. 避免空位元組

    空位元組(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++程式語言,你會很容易理解這個結構體包含哪些資訊及其佈局。微軟官方文件顯示如下欄位:

typedef struct _PEB {
  BYTE                          Reserved1[2];
  BYTE                          BeingDebugged;
  BYTE                          Reserved2[1];
  PVOID                         Reserved3[2];
  PPEB_LDR_DATA                 Ldr;
  PRTL_USER_PROCESS_PARAMETERS  ProcessParameters;
  BYTE                          Reserved4[104];
  PVOID                         Reserved5[52];
  PPS_POST_PROCESS_INIT_ROUTINE PostProcessInitRoutine;
  BYTE                          Reserved6[128];
  PVOID                         Reserved7[1];
  ULONG                         SessionId;
} PEB, *PPEB;
  • 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包含如下資訊:

typedef struct _PEB_LDR_DATA {
  BYTE       Reserved1[8];
  PVOID      Reserved2[3];
  LIST_ENTRY InMemoryOrderModuleList;
} PEB_LDR_DATA, *PPEB_LDR_DATA;
  • 1
  • 2
  • 3
  • 4
  • 5

LIST_ENTRY結構是一個簡單的雙向連結串列,包含指向下一個元素(Flink)的指標和指向上一個元素的指標(Blink),其中每個指標佔用4個位元組:

typedef struct _LIST_ENTRY {
  struct _LIST_ENTRY  *Flink;
  struct _LIST_ENTRY  *Blink;
} LIST_ENTRY, *PLIST_ENTRY;
  • 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. calc.exe (可執行檔案)
2. ntdll.dll
3. kernel32.dll 
  • 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 
示例:

; Comments can be specified by starting with a ;

mov EAX, 5   ; Put value 5 in the EAX
add EAX, 2   ; Add 2 to EAX, EAX will be 7
inc EAX      ; EAX will be 8
mov EBX, 2   ; Store value 2 in EBX
sub EAX, EBX ; EAX will be 6
  • 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呼叫約定(方式),我們僅討論這種函式呼叫方式。不過,它們是類似的,你可以從 這裡找到更多的資訊。讓我們以下面的函式作為示例:

int function(int x, int y)
{
    return x + y;
}
  • 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)。