[超詳細]棧溢出漏洞原理實例講解

[超詳細]棧溢出漏洞原理實例講解


本篇文章通過《0day安全:軟件漏洞分析技術》書中第二章中所用到的一個程序的棧溢出漏洞的復現,以及使用OD一步步調試來學習棧溢出完整的原理。程序雖然簡單,但在一些基礎薄弱的人眼中還是無法理解,所以導致很多新手到了這裏就被“勸退”。所以在這篇博客中我會差不多一句一句的解釋彙編代碼的意思,一步一步的看棧的變化,即使遇到一些“常識”我也會進行介紹,來幫助理解棧溢出漏洞

使用軟件:

  • vc++6.0 (用來編譯程序)
  • OllyDbg
  • 十六進制編輯軟件

代碼簡介

#include<stdio.h>
#include<string.h>
#include<stdlib.h>
#define PASSWORD "1234567"  //寫入靜態密碼

int verify_password(char *password)//確認密碼是否輸入正確
{
	int authenticated;
	char buffer[8];
	authenticated=strcmp(password,PASSWORD);  
	strcpy(buffer,password);  //存在棧溢出的函數
	return authenticated;
}

void main()
{
	int valid_flag=0;
	char password[1024];
	scanf("%s",password); //輸入密碼
	valid_flag=verify_password(password);
	if(valid_flag) //返回0代表正確,返回1代表錯誤
	{
		printf("incorrect password!\n");
	}
	else
	{
		printf("success\n");
	}
	getchar();//暫停一下
}

運行結果如下:

在這裏插入圖片描述

在這裏插入圖片描述

好,接下來開始演示棧溢出的原理。

分析程序整體執行流程

首先使用Debug編譯器(最好不要使用release編譯器),Debug和Release編譯後的程序的區別就是,在調用一些系統函數的時候,Debug會保留調用點,而轉去系統函數所在地繼續執行;而Release會直接將系統函數粘貼過來,不用實現調用便可完成。使用Debug能更直觀看出函數執行到了什麼位置。使用OD打開編譯好的.exe文件。

在這裏插入圖片描述

典型的VC編譯的程序的樣子,但程序的入口點並不是main函數的入口點,接下來我們要找到main函數,有兩種方法:

  • 單步跟蹤(F8),在遇到call調用的時候使用F7跳轉到跟蹤函數內部,直到進入(我們可以判斷出是)main函數的地方,如:

    在這裏插入圖片描述

  • 根據“常識”,在GetCommandLineA後不遠處就是main入口,在main之前會有(三個)連續的壓棧操作,如:

在這裏插入圖片描述

找到main函數之後,F7跟進main函數裏,繼續分析:

在這裏插入圖片描述

如圖所示,根據左側ASCII碼的提示,和代碼中調用系統函數的名字,我們很輕易的找到了scanf函數的位置,按從右至左的順序將參數(password就是[local.257]和“%s”)入棧之後調用scanf。中間的一段初始化代碼爲初始化申請的空間爲0xcc,其中valid_flag(即[local.1],後面會說)要初始化爲0。

在這裏插入圖片描述

根據接下來的ASCII碼,可以判斷在中間的調用語句call a.00401005就是verify_password函數的位置。

之後F7繼續跟進verify_password函數內部繼續分析:

在這裏插入圖片描述

進入函數,首先看見的就是常規操作,將前棧基址入棧,擡高棧頂(給局部變量申請空間),之後可以看到在調用strcmp函數之前將他的兩個參數從右至左入棧,靜態值“1234567”是寫在程序的數據段中的,另一個是參數password,在第一個(只有一個)參數即[arg.1]位置。strcmp執行結果在eax寄存器中,然後將結果賦值給變量authenticated,即第一個變量,就是[local.1]位置(圖裏不小心畫到strcpy參數入棧裏了)。然後進行strcpy函數的參數入棧,從右至左是password和buffer,buffer就是第二個參數(因爲長度是8,所以[local.3]),將他們入棧之後調用strcpy,將password內容拷貝到buffer裏。然後(進行清理棧空間操作後)返回。

這裏介紹一下彙編語言中的幾個概念:

函數的調用:

調用函數之前先將參數依次入棧,然後調用,調用結束之後將入棧的參數出棧(清理棧空間),根據不同的調用約定,入棧的順序不同。C語言默認的調用方式是__cdecl,參數從右到左入棧。更多關於調用約定可以百度。

在函數剛被調用的時候通常會進行這樣的操作:

push ebp  //將上一個函數的棧基址(棧底)入棧
move ebp,esp  //將目前的棧頂作爲新函數棧的棧底
sub esp,0xxx  //將棧頂擡高xx,目的是爲這個函數中聲明的局部變量申請空間

然後會push幾個寄存器就是保存狀態,在函數return之前會pop恢復的。

局部變量和參數的表示:根據局部變量在函數中聲明的順序,用[local.數字]來表示,[local.1]表示[EBP-4],聲明的局部變量會放在之前擡高棧頂得到的空間中,所以這裏[EBP -數字]只需根據局部變量的出現順序和長度就可以推斷出來(也就是說[local.2]不一定是第2個局部變量)。可[local.1]類似的是[arg.1]代表第一個參數也就是[EBP +8],調用函數之前會將函數的參數依次入棧(默認從右到左入棧),而由於[EBP+4]是調用函數前存入的返回地址,所以第一個參數是[EBP+8],這個也是根據順序和長度簡單判斷就能確定指的是哪個參數,不過多贅述。

程序執行細節及棧空間變化

整個程序的執行流程上面已經進行演示了,下面按照函數的順序講解一下棧空間的變化。直接進入main函數,執行到此處:

在這裏插入圖片描述

可見剛剛將上一個棧基址入棧之後,直接將棧頂擡高了0x448,也就是申請了0x448(十進制1096)個字節,其中有1024個是我們申請的password字符串,還有一個int型的authenticated。看源代碼中兩個參數出現的位置,可以分析出,[local.1]是authenticated變量,[local.257]是password字符串([local.2]~[local.257]正好256*4 = 1024字節)。其他空間幹啥用的不知道,現在也沒必要知道。然後將ebx,esi,edi三個寄存器入棧,這裏的入棧都是在擡高棧頂之後的操作,跟我們分析棧(中緩衝區)的結構沒有什麼關係,不多贅述。

在這裏插入圖片描述

在這裏插入圖片描述

這段代碼就是將剛申請的0x448*4的空間初始化成0xcc。然後將valid_flag初始化爲0。

在這裏插入圖片描述
將scanf的兩個參數%s和password(從右至左)入棧,然後調用scanf,然後到控制窗口輸入。

在這裏插入圖片描述

輸入“1234567”後,之後回到OD繼續下一步,查看此時棧的狀態:

在這裏插入圖片描述

0x31就是ASCII碼“1”,00是字符串結束符,下面的C就是沒用上的空間。驗證一下,目前的EBP是:

在這裏插入圖片描述
[local.257]就是[ebp+257*4]正好是0x0012FB7C,剛剛的判斷沒有錯,將棧拉倒最下面(地址最高,棧反向增長,由高地址向低地址增長):

在這裏插入圖片描述

可以看見這是authenticated變量,值爲0,下面(前面帶白色矩形框)的內存是上一個函數的棧區。

在這裏插入圖片描述

接下來的add esp操作是棧頂降低0x8,就是將剛剛的兩個參數出棧,因爲VC中的調用約定默認是__cdecl約定,在這個約定下由調用者管理棧空間,所以這裏需要主函數自己清理用過的參數。然後接下來即將調用verify_password函數,將函數需要的參數password入棧。在調用之前我們將代碼停在這個位置然後看一下棧和EIP寄存器的狀態和棧頂狀態,在進入verify_password函數:

在這裏插入圖片描述

在這裏插入圖片描述

在這裏插入圖片描述

可以看見在調用之前的狀態,EIP指向下一調語句,就是call,在call執行的一瞬間EIP會變成call的下一條語(地址0x004010D5)句並且會被壓棧,截不到圖,文字敘述一下。之後我們跟蹤進入verify_password函數內部,之後再看棧頂。

在這裏插入圖片描述

進入函數的一瞬間,棧頂多了一個地址,就是剛剛我們看見的0x004010B5,記住他的位置,然後繼續看verify_password函數。

在這裏插入圖片描述

verify_password函數和main函數剛開始差不多,都是先將舊棧基址入棧,然後擡高棧頂,然後一些寄存器狀態入棧,同樣在擡高棧頂之後入棧,不過多分析。然後初始化緩衝區。注意這裏擡高棧頂0x4c的空間,也就是76字節空間。我們只需知道[local.1]是authenticated,[local.3]是buffer(buffer長度8)就好。

在這裏插入圖片描述

然後將strcmp(字符串比較)函數的兩個參數,分別是靜態的“1234567”寫在程序的數據段,就是a.0042601C和password(password也是主函數傳給這個函數的第一個參數,表示爲[arg.1])從右至左依次入棧。然後運行到執行完strcmp:

在這裏插入圖片描述

函數的執行結果會在eax中
在這裏插入圖片描述
0表示相等,如果輸入>"1234567"會是0x1,反之小於是-1也就是0xffffffff(補碼)。接下來就是清理用過的參數,然後將strcmp執行結果賦值給authenticated,即[local.1]。

在這裏插入圖片描述

然後進行最關鍵的部分,strcpy(字符串拷貝)函數,首先將函數用到的兩個參數password([arg.1])和buffer([local.3])入棧,然後調用strcpy,之後看棧的空間變化,調用前:

在這裏插入圖片描述

調用strcpy後:

在這裏插入圖片描述

從上到下畫方框的依次是buffer,authenticated,前EBP,函數返回地址。看到這裏大家基本也就能夠判斷這裏存在的棧溢出了,至於利用方法,在下面一節敘述。

之後的內容就是清理用過的參數,然後將authenticated的值給eax作爲返回值,然後恢復之前保存過的幾個寄存器的狀態,最後返回。

在這裏插入圖片描述

然後回到主函數,清理參數,獲取返回值,根據返回值決定輸出什麼。整體流程就是這樣。

在這裏插入圖片描述

棧溢出

整個程序的執行流程上面已經進行非常細緻的演示了,相信看完之後無論如何也有了很多種想法了吧。這裏再簡單贅述一下什麼是棧溢出:棧溢出就是指棧中的(緩衝區)內容被寫入了大於原本(申請的)大小的內容,導致多餘的內容覆蓋了緩衝區後面的其他地址空間內原有的內容,一張圖:

img

這張圖中是反過來的,是下面放的buff寫入了大量的超過原有空間的A導致直接覆蓋了buf後面的f函數幀結構和EBP還有EIP,只不過覆蓋EIP的不是A而是攻擊者構造的返回地址。更多關於棧溢出的原理性描述可以自己上網搜索,這裏不過多贅述。

回到我們的程序,棧溢出的位置就在strcpy函數這裏,password是一個長度爲1024的字符串,複製進入一個長度爲8的緩衝區,只要我們輸入的內容超過8(因爲字符串後面會有結束符\0)就會覆蓋其他數據。

首先驗證一下,這次在輸入的時候輸入20個字符串(正好覆蓋到EIP)內容就是“12341234123412341234”

在這裏插入圖片描述
在這裏插入圖片描述
運行正常時的棧:

在這裏插入圖片描述

可見,原返回地址的地方被最後一組“1234”覆蓋了,之後再繼續執行到rtn處就會報錯:

在這裏插入圖片描述

報錯的地址都和我們覆蓋的一樣,看來這次嘗試成功證明了棧溢出漏洞的存在,並且找到了覆蓋EIP的位置所在。

通過棧溢出控制程序執行結果

根據上一節找到的棧溢出漏洞,這裏我們要完成一個挑戰,就是輸入一個非“1234567”的字符串,讓程序輸出“success”。

第一種方法:

我們首先輸入一個不正確的密碼"8888888"(7個8),來看一下strcpy之後的棧空間:

在這裏插入圖片描述

可以看見“7個8”之後正好是字符串結束符0x00,之後緊接着就是0x00000001中的最後一個字節01,也就是說只要我輸入“8個8”那麼結束符0x00就會覆蓋01使authenticated變量從0x00000001變成0x00000000。這裏爲什麼前一個變量的末尾值會直接覆蓋後一個變量的末尾值是因爲,0x00000001這個變量是倒着存在內存中的,而在棧窗口中顯示也是倒着顯示的(棧是高地址向低地址生長),所以看着就是正的,在數據窗口看是這樣的(輸入7個8):

在這裏插入圖片描述

輸入8個8:

在這裏插入圖片描述

數據窗口中是從低地址到高地址顯示的,可以看出,字符串buffer是從內存中讀的,所以是正序,而0x00000001是來自寄存器eax所以是逆序。所以我們只要再多輸一個字符,那麼字符串結束符0x00就會覆蓋0x01,而將authenticated變爲0,那麼最終就會輸出“success”,現在執行到結束,成功覆蓋並且輸出了success。

在這裏插入圖片描述

當然這裏如果輸入的內容比“1234567”小,那麼並不能用這種方式覆蓋,因爲小於“1234567”比較的結果是-1,也就是補碼的0xffffffff,覆蓋一個字節變爲0並不能使整個值變爲0。

第二種方法:

可否直接覆蓋到返回地址到“輸出success”的地方,也就是說我們要先找到輸出success的位置。

在這裏插入圖片描述

也就是說,地址爲0x004010d0的代碼就是要輸出success的所在了,那麼我們要嘗試將返回地址覆蓋爲0x004010d0。

由於命令行中只能輸入ASCII碼,有些16進制值無法用ASCII碼錶示出來,需要稍微修改一下函數代碼,把手動輸入改爲從文件輸入,但修改之後剛找到的地址也會改變,不過已經很明顯了,接下來再找到也不會很難,代碼修改如下:


void main()
{
	int valid_flag=0;
	char password[1024];
	FILE * fp;
	if(!(fp=fopen("password.txt","rw+")))
	{
		exit(0);
	}
	fscanf(fp,"%s",password);
  //scanf("%s",password);
	valid_flag=verify_password(password);
	if(valid_flag)
	{
		printf("incorrect password!\n");
	}
	else
	{
		printf("success\n");
	}
	fclose(fp);
	getchar();
		getchar();
}

新的輸出success的地址是0x0040fbaf:

在這裏插入圖片描述

之後我們將文件password.txt使用十六進制編輯器打開,構造20字節的內容,並且最後四字節是要覆蓋的地址(反着寫):

在這裏插入圖片描述

然後進入OD查看棧的覆蓋情況,strcpy前:

在這裏插入圖片描述

strcpy後:

在這裏插入圖片描述

可見,棧中的返回地址成功的被修改了,之後繼續運行,查看輸出結果,rtn之後直接來到了這裏:

在這裏插入圖片描述

輸出了success:

在這裏插入圖片描述

之後繼續運行會報一個錯誤,是因爲我們覆蓋過程中將原EBP也覆蓋了,導致返回main之後找不到EBP,找不到EBP就會在返回的時候找不到之前的返回值,但這並不影響我們成功輸出了success。

通過棧溢出插入代碼

要進行代碼植入,我們還要對目前代碼進行修改:

#include<stdio.h>
#include<string.h>
#include<stdlib.h>
#include<windows.h>
#define PASSWORD "1234567"

int verify_password(char *password)
{
	int authenticated;
	char buffer[60];
	authenticated=strcmp(password,PASSWORD);
	strcpy(buffer,password);
	return authenticated;
}


void main()
{
	int valid_flag=0;
	char password[1024];
	FILE * fp;
	LoadLibrary("user32.dll");
	if(!(fp=fopen("password.txt","rw+")))
	{
		exit(0);
	}
	fscanf(fp,"%s",password);
	valid_flag=verify_password(password);
	if(valid_flag)
	{
		printf("incorrect password!\n");
	}
	else
	{
		printf("success\n");
	}
	fclose(fp);
	getchar();
}

主要修改的地方是verify_password函數中buffer的空間由8改爲了60,一遍我們在裏面寫入代碼;在主函數中增加了一句 LoadLibrary("user32.dll");加載user32.dll模塊,之後我們寫入的代碼要調用這裏面的MessageBox函數。

所以,我們要通過向棧中寫入代碼的方式,來讓程序彈出一個消息窗(也就是說寫入一個消息窗的代碼並讓它執行)。

那麼想要完成這個任務,我們要確定下面幾件事:

  • MessageBox函數的調用方式
  • MessageBox函數的位置
  • 完成整個調用匯編代碼的編寫
  • 確定修改之後的程序的棧空間走向和EIP位置(即如何覆蓋)

那麼首先確定MessageBox函數的調用方式,查閱資料後MessageBox一共有四個參數:

int MessageBox(
	HWND,//這個參數代表窗口所屬,如果爲NULL則代表不屬於任何窗口
	LPCTSTR,//這個參數代表消息框中顯示內容的字符串
	LPCTSTR,//這個參數代表消息標題顯示內容的字符串
	UINT//這個參數代表框的風格,NULL爲默認
)

進一步瞭解我們知道,MessageBox函數是系統通過對中間兩個字符創參數的類型(ASCII或UNICODE)來決定調用MessageBoxA或者是MessageBoxB,我們這裏使用ASCII型字符創,那麼我們可以直接去尋找MessageBoxA的地址,尋找方式如下:

找到VC++6.0中Tools目錄下的DEPENDS Walker工具,位置如下:

在這裏插入圖片描述

之後隨便將一個“有窗口的軟件”拖進工具,然後我們查找user32.dll中的MessageBoxA,找到user32.dll的基地址和MessageBoxA的偏移地址:

在這裏插入圖片描述

計算可得MessageBoxA的地址爲:0x77d10000+0x000407ea=0x77d507ea。之後我們進行彙編編碼(彙編代碼可以在od中選擇一塊區域nop掉然後編寫,會自動生成二進制格式):

      33DB          xor ebx,ebx   //這裏是爲了讓ebx爲0,因爲有兩個參數值是NULL並且字符創需要一個結束符,如果直接mov ebx,0會使二進制代碼中出現0,在strcpy時會被認爲字符創結束符而結束複製。所以使用這種方式。
      53            push ebx      //先入棧一個0作爲窗口顯示字符創的結束符
      68 6f50776e   push 0x6e77506f  //這兩句是構造窗口顯示的字符創,我們這裏讓窗口都顯示“HelloPwn”
      68 48656c6c   push 0x6c6c6548
      8BC4          mov eax,esp    //字符串(字符串開始地址就是esp棧頂)給eax
      53            push ebx       //四個參數從右至左依次入棧
      50            push eax
      50            push eax
      53            push ebx
      B8 EA07d577   mov eax,0x77d507EA   //將MessageBoxA的地址給eax
      FFD0          call eax             //調用

如果在整個過程中出現了00(一般是由於數字出現00造成的),那麼我們應該換一種表達方法,如(上述代碼中使用了xor指令也是可以的):

      B8 5b000400   mov eax,0x0004005b
      改爲
      B8 6b101410   mov eax,0x1014106b
      2D 10101010   sub eax,0x10101010

接下來我們確定棧空間,我們將6組“1234567890”寫在文件中,然後在OD中查看,還是在執行完strcpy之後,查看棧空間,strcpy前:

在這裏插入圖片描述

strcpy後:

在這裏插入圖片描述

可見由於buffer總共只有60字節的空間,我們輸入的長度爲60的字符創沾滿了buffer,然後第61個字符串結束符00覆蓋了authenticated的最後一個字節01(和上面講的原理一樣),下面就是EBP和返回地址。所以我們需要做的就是在文件中構造如下的內容,即先寫代碼,然後用90(nop填充)在69~72字節處填寫buffer的初始地址,用來覆蓋返回值,直接返回到我們寫的代碼上執行,就是上圖的0x0012fadc,構造的文件如下:

在這裏插入圖片描述

上面框是調用MessageBoxA的代碼,一堆90之後是覆蓋返回地址的地址,之後執行一下,成功彈窗,點擊確定後程序會崩潰,如果沒有成功彈窗,比如提示一些地址不可執行之類的,可以繼續按照上面的調試方法,一步一步的看一下棧中的內容,返回地址是否完整覆蓋,覆蓋的返回地址是否是代碼的起始地址等:

在這裏插入圖片描述

以上就是簡單的棧溢出的詳細講解,關於更多內容,大家可以參考《0day安全,軟件漏洞分析技術》這本書。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章