首先看如-1示範的用c語言編寫的例子程式。
┌————————————————————┐
│ 1. void fun() │
│ 2. { │
│ 3. printf(“Hello World\n”); │
│ 4. } │
│ 5. int main() │
│ 6. { │
│ 7. int *i; │
│ 8. i = (int*)&i + 2; │
│ 9. *i = fun; │
│ 10. } │
│ 圖-1 │
└————————————————————┘
如果我跟你說程式啟動並執行結果(我們以下的討論將在IA32-linux-gcc3.4環境中進行)是將列印出“Helllo World”,你可能會不相信。那麼這一個“詭異版HelloWorld程式”究竟是如何做到的呢?
這就涉及到了棧的問題,不過首先得補充一些理論。
我們知道:函數中的局部變數是存放於棧中的。棧中除了局部變數外,還包括函數參數、返回地址等一系列內容。其預存程序如下:當執行到某一函數時,首先將函數的各個參數依次壓入棧,隨後是函數的返回地址(即程式中產生該函數調用處的下一行代碼的地址),最後壓入棧內的是函數的局部變數。綜上所述,一個函數運行時的棧理論上具有如下結構:
┌—————————————————————┐
│高地址 │ │ │
│ │ …… │ │
│ ├——————┤ │
│ │ 參數 │ │
│ ├——————┤ │
│ │ 返回地址 │ │
│ ├——————┤ │
│ │ 局部變數 │ │
│ ├——————┤←—— 棧頂 │
│ │ │ │
│低地址 │ │ │
│ │ │ │
│ 圖-2 │
└—————————————————————┘
但是現實中棧的結構還受到編譯器的限制,從而出現了一些細節上的變化,以IA32體繫結構下的例子補充一下我們的理論,就得到了實際應用中的棧結構:
┌—————————————————————┐
│高地址 │ │ │
│ │ │ │
│ ├——————┤ │
│ │ 局部變數n │ │ │
│ ├——————┤ │ │
│ │ … │ │棧 │
│ ├——————┤ │增 │
│ │ 參數1 │ │長 │
│ ├——————┤ │方 │
│ │ 返回地址 │ │向 │
│ ├——————┤ │ │
│ │ OLD Ebp │ │ │
│ ├——————┤ │ │
│ │ 局部變數1 │ │ │
│ ├——————┤ ▼ │
│ │ … │ │
│ ├——————┤ │
│ │ 局部變數n │ │
│ ├——————┤←—— 棧頂 │
│ │ │ │
│低地址 │ │ │
│ │ │ │
│ 圖-3 │
└—————————————————————┘
作為補充,我們就圖-3給出如下的一些解釋:
1、我們不用理解“Old Ebp”項是什麼,這也不會影響我們的分析,只需要知道在IA32架構中它是一個四位元組的數值就夠了。
2、函數的各個參數入棧順序與具體的編譯時間約定有關,對於標準C而言參數是從右至左依次入棧,至於局部變數的入棧順序在gcc3.4中是按照聲明順序一次入棧。
3、進入函數之後,棧頂位置指向的就是最後聲明的局部變數的位置。
4、結合c語言的文法,我們可以知道函數可以沒有參數和局部變數,所以圖-3中的“參數”和“局部變數”不是函數棧結構的必需部分。
好了,我們現在已經可以開始結合圖-1的例子程式進行敘述了。
程式中定義了一個全域函數fun,沒有參數,簡簡單單一個函數。主函數中定義了一個int*型變數i,然後是做了一系列賦值操作。所以剛剛進入主函數的一刻對應的棧就有如下結構:
┌—————————————————————┐
│高地址 │ │ │
│ │ …… │ │
│ ├——————┤ │
│ │ 返回地址 │ │
│ ├——————┤ │
│ │ OLD Ebp │ │
│ ├——————┤ │
│ │ (int*)i │ │
│ ├——————┤←—— 棧頂 │
│ │ │ │
│低地址 │ │ │
│ │ │ │
│ 圖-4 │
└—————————————————————┘
準確地說這是主函數的棧結構,fun函數只是我們想為看結果而補充的一段代碼而已。主函數有著與普通函數一致的理論上的棧結構,不同的是它的棧結構中有些內容是系統給它的,即圖-4中用省略符號代替的部分,而具體細節就與體繫結構相關了,還得有心人自己去研究。
我們從圖-4中可以看到,此時棧頂元素就是指標i,緊接著它的就是“Old Ebp”和“返回地址”,而“返回地址”就是主函數結束後系統規定執行的指令地址。我想說到這有聰明的讀者就應該想到程式肯定是將“返回地址”做了修改,從而執行了fun函數。果真如此!請看:
i = (int*)&i + 2;
它將i的地址(就本程式而言i的地址相當於是棧頂位置)加2之後再賦給i。不過這裡加2可不是簡簡單單的一個“地址+2”,而是一個(int*)類型的加法,實際將按照位元組大小換算成“地址+2×sizeof(int*)”,再加上(int*)類型的大小是4所以最終編譯的結果將是加8(或者說是兩個“int*”大小)。怎麼樣?很拗口吧,不單單你這樣想,我也是。這是進階語言的優勢,也是它的劣勢:我們節省了大量繁瑣的計算,可同時也喪失了對程式底層的深入理解和自由控制(如果你不深究個中原委的話)。那麼此時指標i控制的這個地址指向了哪裡呢?我們還是從圖上看來得明白:
┌—————————————————————┐
│高地址 │ │ │
│ │ …… │ │
│ ├——————┤ │
│ 4位元組{ │ 返回地址 │ │
│ ├——————┤←—— │
│ 4位元組{ │ OLD Ebp │ 棧頂+8 │
│ ├——————┤ │
│ 4位元組{ │ (int*)i │ │
│ ├——————┤←—— 棧頂 │
│ │ │ │
│低地址 │ │ │
│ │ │ │
│ 圖-5 │
└—————————————————————┘
-5,指標i的地址(也相當於棧頂)加上八個位元組,我們一查,不就是“返回地址”的地址嗎?這是指標i剛好就指向了返回地址。好啊,既然得到了,還等什麼,直接:
*i = fun;
一下子我們就把“‘返回地址’的地址中的內容”(也就是返回地址)修改成了函數fun的地址。這樣我們完成了所有的準備工作,剩下的內容自然是:
Main函數結束後,轉而取出自己棧內的“返回地址”(當然是已經被我們修改過的了),跳到那裡去執行,這時就進入了函數fun中,一切順理成章,我們的分析結束。
不過我在這裡刻意隱瞞了一個情況:即運行例子程式時,除了正常輸出之外,還包含了錯誤提示。因為我們修改的是main函數的返回地址,這是系統的東西,自然要有紕漏從而出現錯誤。至於如何能正常退出,還是看看本文開頭介紹的陳碩的文章好了。
事實上本文的目的有兩個:一來為今年選修“unix進階技術”的同學們介紹一點先行知識,這有助於為解決某些上機實驗提供思路;二者也是想說一些關於代碼安全的問題。
我們從上可以看到基於棧行為我們甚至可以改變程式的正常執行過程。當然沒有那個人會傻到寫一個如我們的例子程式這樣的東西被別人攻擊,可實際上類似的漏洞是隱形,並且大量存在著。“基於緩衝區的溢出攻擊”一直以來就是包括微軟公司在內的軟體公司及個人在研究和防範的要點。鑒於這個課題的廣泛,我不想展開說,只是想給出兩條用得上,簡單有效經驗以資借鑒:
(1)不要為接受的資料預留相對過小的緩衝區,同時對傳入的資料進行越界檢查。
(2)不要定義過大的局部緩衝區,這裡說的“大”是“很大”的意思,一般是以超過系統頁大小為限。解決之道是定義一個全域的緩衝區來代替之。
與本文相關的論題應該說是已經有了很成熟的理論,只不過我們還有很長大的路要走
才能和時代的最前沿齊頭並進,希望能與有興趣的同學有所交流,共同進步。