記憶體不用白不用,何必在一開始就限制棧的大小,linux的機制是盡量多盡量緊湊的使用虛擬記憶體,原則就是你現在不用我就用,沒有預留的概念,當然你可以通過系統調用實現預留,就像glibc的堆管理那樣,這裡所說的完全是針對於作業系統核心的,使用者空間程式完全可以向作業系統通過brk或者mmap實現使用者空間的記憶體預留。windows的實現就不是這樣,windows要求程式在運行之前就限制好棧使用的記憶體的大小,一旦超過這個大小,哪怕向下伸展的棧下方的記憶體沒有實體使用,那麼也會觸發異常,windows將棧記憶體的使用完全暴露給了使用者空間,而linux卻沒有,linux透明的實現了棧記憶體的動態管理,一開始分配給棧的記憶體很小,隨著函數調用深度的增加和局部變數空間的增加,棧會動態得到擴充,當前這個動態擴充的前提是向下擴充的棧的下方的記憶體沒有被映射,也就是這些棧需要的記憶體還不屬於任何的vm_area_struct,一旦其它非棧的記憶體映射映射到了離3G界限非常近的地方,那麼linux使用者棧將會被限制的非常小,但是那一塊記憶體映射到哪裡完全取決於應用程式自己,核心根本不管,核心只是接受brk或者mmap的參數,然後實現記憶體映射(本文前提,棧是向下擴充的)。
正如以上所說,linux可以實現像windows那樣的棧記憶體的限制,也可以不限制棧記憶體,然後將一切交給核心來完成,下面看一下我作的實驗:
#include
#define PAGE_SIZE 4096
int g;
void stubfunc()
{
g++;
printf("g:%d/n",g);
char as[PAGE_SIZE]; //作為局部變數,效果就是每調用一次該stubfunc函數,棧就會增長最少一個頁面
int i = 0;
for( i=0;i
memset(as+i,1,1);
//getchar();這個調用使得實驗者有機會去查看/proc/XXX/maped檔案,即時看到棧在擴充
stubfunc();
}
int main( int argc, char* argv[] )
{
stubfunc(); //開啟遞迴函式調用,目的是測試linux的棧可以動態擴充到多少
}
我運行了好幾次該程式,在g達到3500到4200的時候出現段錯誤,這說明linux的棧的大小可以擴充到3500到4200個頁面的大小,當然這完全取決於核心的實現和使用者空間c庫的實現,如果用彙編實現那麼將完全取決於核心的映射策略,但是不管怎樣棧是在動態擴充。如果將此程式放到windows上運行,那麼g能達到多少將完全取決於核心的實現和在連結程式的時候指定的stack_size的大小,比如我將stack_size指定為32個頁面,那麼g的值將在達到32左右的時候出現段錯誤,因為windows的棧管理是用一套很嚴格的機制完成的,分為提交頁面和保留頁面,具體請參考我的文章《windows和linux的記憶體管理》,一旦超越了了事先設定好的界限,那麼就會出現異常,即使棧的下面都是空閑記憶體也不行,因為windows的執行完全是靜態指定的,這裡可以看到windows僅僅適合案頭應用,靈活性非常差,它只要保證案頭小應用能高效穩定執行而不能充分動態利用大型主機上的充足的資源,另外,作業系統本來不應該提供過多的策略,除非作業系統的設計者認為程式員都是白癡,棧越界檢查其實應該是使用者空間自己的事情,核心何必插手,搞windows正是被這種策略慣壞了才不求甚解的,這種方式也不能說一無是處,最起碼可以讓更多的人低門檻的步入windows編程領域,然後高效快速的進行生產,而在linux下編程的幾乎都是有兩下子的,因為核心幾乎不會為你做什麼。下面看一下linux下如何進行對棧的限制,這裡僅僅簡單的談談原理。
如果想限制棧的大小,那麼最起碼可以檢測到棧的越界,一種很顯然的做法就是在棧的限制頁面下面映射一個不可訪問的頁面,一旦棧伸展到該頁面就會出現異常,其實windows也是這麼做的,最起碼大致原理是這樣的,下面看看具體實現,注意,如果想實現一個完整的棧限制還要密切注意其它記憶體映射而不僅僅是棧下面的那個映射,你要保證你為了限制棧而設定的不可訪問的記憶體段是棧緊接著下面的,並且保證這個映射一定成功,也就是說不能讓別的記憶體段映射到此位置,但是這是一個複雜的過程,涉及到libc庫對可重定位共用庫映射的實現,本文不談:
#include
#define PAGE_SIZE 4096
int g;
void stubfunc()
{
...
}
int main( int argc, char* argv[] )
{
int * ap = &arg; //棧的大概位置,因為argc在棧上,我的結果是0xbfbcddf8
unsigned long address = ( unsigned long )ap; address = (address-X*4096)&0xfffff000; //X為一個整數,並且最後將address圓整到4096的倍數,這是MAP_FIXED的要求
char * p = (char*)mmap( address, //,在address處確定性映射,我的結果是0xbf000000
0x1000, //一個頁面的長度
PROT_NONE, //不可訪問,一旦棧擴充到這裡將會出錯
MAP_FIXED|MAP_PRIVATE|MAP_ANONYMOUS|MAP_LOCKED, //載入實體記憶體,嚴格按照所給地址分配
-1, 0 );
stubfunc(); //開始遞迴調用檢測棧限制
}
通過運行可以看到結果,g幾乎可以到了X附近,為何說是附近呢?因為該程式取的ap是一個一般位置,並不是esp的位置,用c實現完全定位esp是不容易的,用彙編比較簡單,直接取esp就可以了,這個實現簡單的闡述了棧限制的原理。
但是問題來了,既然linux沒有要求棧的確切位置,那麼是不是就是說只要訪問到當前棧的vm_area_struct頂端和棧以下的映射記憶體的末尾之間的記憶體,通過缺頁異常都會將棧擴充到該位置呢?從do_page_fault可以看出一些端倪,事實上並不是這樣,這裡可以看出linux實現的隨意性,本來的想法很好,就是說只要被訪問的記憶體位址比esp低,那麼就視為出錯,然而有一個enter指令和pusha指令,這兩個指令都會在重新設定esp之前將棧擴充,其實就是壓入很多的資料,如此一來事情就不是那麼顯然了,惡意程式完全可以利用這個漏洞,看看do_page_fault的相關邏輯:
fastcall void __kprobes do_page_fault(struct pt_regs *regs, unsigned long error_code)
{
...
vma = find_vma(mm, address);
if (!vma)
goto bad_area;
if (vma->vm_start
goto good_area;
if (!(vma->vm_flags & VM_GROWSDOWN))
goto bad_area;
if (error_code & 4) { //從2.6.18開始,以下的“+ 65536 + 32 * sizeof(unsigned long)”代替了原來的“+32”
if (address + 65536 + 32 * sizeof(unsigned long) esp)
goto bad_area;
}
...
}
在上面最後一個if之前有一段注釋被我刪去了,之所以在2.6.28之後做了更改完全是為了支援enter指令,因此才有了注釋中所說的那個很厚的墊子,只要訪問這個厚墊子內部的地址程式是不會出錯的,這個實在不應該,因為這個地址可能已經不是棧空間,並且當前執行的也不是enter或者pusha指令,請看下面的代碼:
int main( int argc, char* argv[] )
{
char stub[XXX]; //XXX是一個比較大的填充數,我取的是32768,只要能保證在下面執行psp-65664之後的結果pi的值是棧棧之外的就可以
int psp;
asm volatile("movl %%esp,%0":"=m" (psp):); //得到esp的值
int* pi = (int *)(psp-65664); //65564也就是65536+32*sizeof(unsigned long)
*pi = 10; //訪問之,將導致缺頁,棧將會被擴充,然而實際上這是一個十足的棧越界
}
上面的程式十分簡單,如果將psp-65664換成psp-65664-n(n為正數),那麼程式將出錯,實際上早就應該出錯了,因為棧已經越界了,在2.6.18之前的核心中測試上述代碼,即使將65664換成60000也會出錯,因為那些早期的版本中只允許地址在esp下面32位元組的位置以內。下面看一下最後一個代碼,這個代碼同步推進了esp指標8個單位,那麼結果就是可以允許psp-65664-8以內的地址訪問不會出錯:
int main( int argc, char* argv[] )
{
char stub[XXX]; //同上
int psp;
asm volatile("movl %%esp,%0":"=m" (psp):);
int* pi = (int *)(psp-65664);
__asm__("subl $8, %%esp/n/t":);
*pi = 10;
}
linux不是十全十美的,但是已經不錯了,反正我是這麼認為的!