做PWN題經常遇到棧溢出,有時一些棧的基礎知識總是記不清楚,腦子卡頓,是以整理一番,讓自己徹底記住它!
1. 什麼是棧?
棧,即堆棧,是一種具有一定規則的資料結構,它按照先進後出的原則存儲資料,先進入的資料被壓入棧底,最後的資料在棧頂。
堆棧資料結構的兩種基本操作:
- PUSH:将資料壓入棧頂
- POP :将棧頂資料彈出
知道了什麼是棧,我們看看棧在記憶體中是怎樣一個分布情況。
以Linux 32位平台為例,程序有4GB大小的虛拟位址空間,其中1GB留給系統核心,3GB是程序自身擁有。一個程序大緻的記憶體布局如下圖所示。
簡單說明一下
代碼段:存放可執行程式的代碼,可讀不可寫
資料段:存放程式中已經初始化的靜态(全局)變量,可讀寫
bss段:存放程式中未初始化的靜态(全局)變量,可讀寫
堆(heap):存放動态配置設定的内容,需要程式猿手動配置設定和釋放
棧(stack):存放局部變量,如函數的參數、傳回位址、局部變量等,有系統自動配置設定和釋放
2. 函數調用棧
棧的基本概念和程序記憶體布局get,讓我們繼續看看函數調用棧,C語言函數調用過程中棧的PUSH和POP了解一下。
2.1 背景知識
棧增長方向:高位址->低位址
ESP:棧指針寄存器,指向棧頂的低位址
EBP:基址指針寄存器,指向棧底的高位址
EIP:指令指針,存儲即将執行的程式指令的位址
函數調用約定:
調用方式 | cdecl | stdcall | fastcall |
參數傳遞 | 從右到左壓棧 | 從右到左壓棧 | 左邊兩個參數 分别放在ECX 和EDX寄存器, 其餘的參數從 右到左壓棧 |
棧清理 | 調用者 | 函數自身 | 函數自身 |
(C語言預設的函數調用方式為cdecl)
2.2 函數調用開始
在調用一個函數時,系統會為這個函數配置設定一個棧幀,棧幀空間為該函數所獨有。
調用者調用一個函數的過程大緻如下:
- 函數參數從右到左入棧
- 傳回位址入棧
- 上一函數ebp入棧
- ...
在上一函數ebp入棧後,就開辟了被調函數的新棧幀,接下來便是被調函數臨時變量入棧等操作,如果被調函數裡有繼續調用新函數的操作,将繼續開始上述的一系列操作,不斷循環嵌套下去。下圖表示函數調用過程中棧的布局情況。
2.3 函數調用結束
函數調用結束時的變化,主要就是按相反的順序将資料彈出棧:
- 彈出臨時變量
- 彈出調用函數的ebp值,存到ebp寄存器中
- 彈出傳回位址,存到eip寄存器中
傳回位址即是用call指令調用函數時下一條指令的位址,存到eip中,程式就知道在調用完後繼續執行下一條指令。
我們會有一個疑惑,調用函數時将函數參數從右到左入棧,調用結束時怎麼沒有将它們彈出?
在這裡,系統并不是用POP指令将它們彈出,而是通常通過ADD ESP讓它們從棧中“消失”。
3. 棧溢出原理
那麼,什麼是棧溢出呢?棧溢出是指向向棧中寫入了超出限定長度的資料,溢出的資料會覆寫棧中其它資料,進而影響程式的運作。
如果我們計算好溢出的長度,編寫好溢出資料,讓我們想要的位址資料正好覆寫到函數傳回位址,那麼被調函數調用完傳回主函數時,就會跳轉到我們覆寫的位址上。通過這樣改變程式流程,接下來我們就可以幹很多壞事了!
我們以一個執行個體(64位程式)對上述棧溢出原理和利用做一個說明。
#include<stdio.h>
int fun1()
{
int a;
gets((char *)&a);
return 0;
}
int fun2()
{
printf("stackflow success!\n");
return 0;
}
int main(int argc, char *argv[])
{
fun1();
return 0;
}
gets()是C中的危險函數之一,它不進行邊界檢查。在我們的例子中,a是int型隻有4位元組大小的空間,是以當輸入的字元大于4位元組時,就會發生溢出。而我們的目标就是,讓我們的溢出資料覆寫fun1函數的傳回位址,具體就是覆寫為fun2函數的位址,使程式的流程跳轉到fun2函數去執行。
首先,我們用gcc對這段代碼進行編譯:
gcc -z execstack -fno-stack-protector -o stackflow-example ./stackflow-example.c
(其中-z execstack開啟堆棧可執行機制,-fno-stack-protector關閉堆棧保護機制)
用gdb進行調試,可以直接在gets()函數下斷點,也可以使用next、step指令快速調試到gets()函數這,在輸入AAA後,檢視堆棧資料。
在執行完gets()函數并輸入AAA後,程式的棧分布情況如下所示,0x00007fffffffe110即是上一函數(調用者main函數)的ebp,0x4005b4是fun1函數的傳回位址。
在輸入AAAAA後呢,溢出的資料就會存在0x00007fffffffe0f0開始的棧上
是以,我們隻需要輸入AAAA+AAAAAAAA(覆寫上一函數ebp)+fun2位址(覆寫傳回位址),就可以達到我們的目标。
緊接着,我們需要找到fun2函數的起始位址,來完成我們對程式流程的劫持。這個程式比較簡單,可以直接在調試的時候快速找到fun2函數的位址,正常我們可以使用如下指令查找。
fun2函數位址==0x400586
最後完成棧溢出,改變程式執行流程!(注意位址的小端位元組序)
當然,在實際的棧溢出中,我們劫持程式的流程,一般會修改傳回位址到shellcode或者記憶體中已經有的一段指令(ROP),一些具體的攻擊利用技術,這裡暫時先不做詳細介紹了。
最後列一些C中會發生緩沖區溢出的危險函數:
- gets()
- strcpy
- strcat
- sprintf
- ......