一個棧溢出的BUG


我的博客:http://blog.striveforfreedom.net

Table of Contents

1 BUG描述

最近修改一C程序,在一個結構體里加入了幾個新的字段,編譯完一跑竟然出現段錯誤(segmentation fault)崩潰了。用gdb查看,引發崩潰的是一條這樣的指令:mov register offset(%rsp)。

2 解決過程

從引發崩潰的指令可以看出,崩潰的原因是訪問了棧上的內存,然而通常來說訪問棧上內存是不會導致段錯誤的,因為棧上內存不需要程序員手動管理,一般來說很難出錯。猜測有可能是棧溢出了,需要證實這個想法。發生崩潰的機器是X86_64+Linux,用ulimit -s得知進程棧默認的soft limit是10MB,因為程序代碼並沒有調用setrlimit調整過棧的soft limit,於是需要證明出現段錯誤的進程棧大於10MB了,導致崩潰的地址可以從gdb中查看,如果知道棧的起始地址(棧底),兩者之差就是棧的大小。但如何才能知道棧的起始地址呢?我們知道Linux有個proc文件系統,系統里每個進程在/proc下都有一個以進程ID命名的文件夾,/proc/PID下包含的都是進程ID為PID的進程的相關信息,比如說該進程對應的可執行文件路徑/當前目錄/打開的文件等。其中/proc/PID/maps包含了該進程所有虛擬區域的起始和結束地址,包括棧(即文件maps里最后一個字段為[stack]對應的那一行)。拿到了棧的起始地址之后,用起始地址減去引發崩潰的那條指令中訪問的內存地址(即rsp加一個偏移),得到的值果然大於10MB了。至於棧溢出的原因,原有代碼在棧上定義了一個結構體的數組,而我在結構體里面加了幾個size比較大的字段,因此溢出了。找到原因,BUG修改起來就很簡單了,要么在shell里修改棧默認的soft limit,要么在代碼里調用setrlimit,要么在堆上分配內存。

為了說明這個BUG,我寫一段測試代碼作為例子,代碼如下:

int main(int argc, char* argv[])
{
    const unsigned len = 10 * (1U << 20);
    char data[len];
    data[0] = 'a';

    return 0;
}

編譯完一運行,出乎意料的是,進程竟然沒崩潰!這就非常奇怪了,因為我在main函數里定義了10MB大小的數組(並且訪問了第一個元素,即最地址最小的那個),且不說環境變量所占空間,單這個數組加上C運行庫調用序列所占空間就超過10MB了,而棧soft limit是10MB,按理說必然崩潰。然而實際卻沒有崩潰,一開始我懷疑代碼被優化掉了,用objdump一看並沒有優化掉,接着想了很久都沒有頭緒,最后終於想起去查看內核代碼,看看內核到底是怎么處理棧溢出的。這包含兩個方面,一是棧的soft limit是怎么讀取出來的,二是內核怎么檢查棧大小是否超過soft limit了。發生崩潰的機器上裝的是CentOS 5.7,用uname -r得到的內核版本是2.6.18-308.el5,這個版本號跟官方內核版本號對應不上,因為感覺應該很接近2.6.18,於是就查看了官方內核2.6.18的代碼(推薦下 lxr.linux.no ,查看某個版本內核代碼很方便,不用去下載幾十M的源碼包了)。

讀取資源限制(soft limit & hard limit)是由系統調用getrlimit完成的,getrlimit在內核中的入口是sys_getrlimit,代碼如下:

asmlinkage long sys_getrlimit(unsigned int resource, struct rlimit __user *rlim)
{
    if (resource >= RLIM_NLIMITS)
        return -EINVAL;
    else {
        struct rlimit value;
        task_lock(current->group_leader);
        value = current->signal->rlim[resource];
        task_unlock(current->group_leader);
        return copy_to_user(rlim, &value, sizeof(*rlim)) ? -EFAULT : 0;
    }
}

這個函數很簡單,就是把當前進程的某種資源限制讀出來,並復制到用戶空間,沒有發現什么問題。

對棧的大小限制的檢查是在頁面異常(page fault)處理中完成的,從頁面異常入口page_fault開始,查看調用序列page_fault > do_page_fault > expand_stack > acct_stack_growth,在函數acct_stack_growth中發現了對棧大小限制進行檢查的代碼,如下(省略了跟我們這個例子無關的代碼):

static int acct_stack_growth(struct vm_area_struct * vma, unsigned long size, unsigned long grow)
{
    //...
    struct rlimit *rlim = current->signal->rlim;
    //...

    /* Stack limit test */
    if (size > rlim[RLIMIT_STACK].rlim_cur)
        return -ENOMEM;

    //...
}

其中,參數size是棧的起始地址減去當前引發頁面異常的地址並按頁大小向上對齊的,很顯然,這里如果發現棧大小大於soft limit就返回錯誤,最終會給當前進程發送SIGSEGV信號,導致進程出現段錯誤崩潰。上面的內核代碼說明我的想法是正確的,然而進程並未和我預料的那樣崩潰,一想可能是內核版本不對,於是又查看了官方2.6.19版的代碼,發現這兩處的代碼並沒有改過。這就很奇怪了,過了一會我突然想到,機器上裝的是CentOS,CentOS可能修改了這處的官方內核代碼,於是我下載了和我系統對應的源碼包kernel-2.6.18-308.el5.src.rpm,安裝之后,發現該版本對應的官方內核版本是2.6.18.4,CentOS的修改過的代碼放在一個patch文件kernel-2.6.18-redhat.patch里,運行patch之后,果然發現CentOS修改了acct_stack_growth函數,修改如下(該函數有多處修改,這里只列出了跟我們這個BUG相關的修改):

static int acct_stack_growth(struct vm_area_struct * vma, unsigned long size, unsigned long grow)
{
    //...
    struct rlimit *rlim = current->signal->rlim;
    //...

    /* Stack limit test */
    if (over_stack_limit(size))
        return -ENOMEM;

    //...
}

一比較就可以發現官方內核代碼是直接比較size和棧的soft limit,而CentOS把這個比較放進了函數over_stack_limit里,再來看函數over_stack_limit:

static int over_stack_limit(unsigned long sz)
{
    if (sz < EXEC_STACK_BIAS)
        return 0;
    return (sz - EXEC_STACK_BIAS) >
        current->signal->rlim[RLIMIT_STACK].rlim_cur;
}

其中EXEC_STACK_BIAS是一個整型常量,定義如下:

#define EXEC_STACK_BIAS       (2*1024*1024)

很顯然,CentOS把棧大小限制從soft limit往上提高了2MB,如果棧大小超過棧的soft limit+EXEC_STACK_BIAS(在我們這個例子中為12MB)則說明棧溢出。到此真相大白,把上面的測試代碼修改一下(把數組大小改為12MB),再一運行進程果然崩潰。

3 小結

有時候接近問題的真相,但並沒有發現問題的全部,就這個問題來說,如果我不寫這篇文章,也就不會去寫上面那段測試代碼,就會想當然地認為在我的機器上棧的默認大小限制是10MB了。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM