mmap引發的SIGBUS

轉載出處:http://blog.csdn.net/ctthuangcheng/article/details/8916015

一直以來都覺得使用mmap讀文件是非常高效、非常優雅的做法(參見《從"read"看系統調用的耗時》)。mmap之後,就可以通過內存訪問的方式訪問到文件裏的內容,省去了read這樣的系統調用。

卻不曾想過,mmap以後,如果讀文件出錯會發生什麼……

今晚看到一篇介紹apache bug的文章,裏面說到,apache使用mmap來實現對靜態文件的訪問。在讀文件之前,apache使用stat系統調用得知了文件的長度,然後按照此長度讀取已經被映射在某個內存區間上的文件。
然而如果在讀靜態文件(內存訪問)的過程中,文件被外部勢力修改了,導致文件長度被減小。則apache可能訪問到映射文件之外的內存(本來這塊內存是在映射文件之內的,但是現在文件減小了),導致進程收到SIGBUS信號,然後崩潰。

內核代碼追蹤

真的會存在這樣的情況嗎?在好奇心驅使下,看了看相關的內核代碼。(以下,關於內存管理方面的細節請參閱《linux內存管理淺析》。)

首先是mmap的調用過程,考慮最普遍的情況,一個vma會被分配,並且與對應的file建立聯繫。

mmap_region()
......
    vma = kmem_cache_zalloc(vm_area_cachep, GFP_KERNEL);
    ......
    if (file) {
        ......
        vma->vm_file = file;
        get_file(file);
        error = file->f_op->mmap(file, vma);
        ......
    } else if (vm_flags & VM_SHARED) {
    ......

這裏是通過file->f_op->mmap函數來“建立聯繫”的,而一般情況下,這個函數等於generic_file_mmap。

generic_file_mmap()
    ......
    vma->vm_ops = &generic_file_vm_ops;
    vma->vm_flags |= VM_CAN_NONLINEAR;
    ......

其中:

struct vm_operations_struct generic_file_vm_ops = {
    .fault  filemap_fault,
};

注意這裏對vma->vm_ops的賦值,下面會用到。然後,mmap就完成了,僅僅是建立了vma,及其與file的對應關係。沒有分配內存、更沒有讀文件。

接下來,當對應的虛擬內存被訪問時,將觸發訪存異常。內核捕捉到異常,再完成內存分配和讀文件的事情。(其中細節還是詳見《linux內存管理淺析》。)
do_page_fault就是內核用於捕捉訪存異常的函數。其中內核會先確認引起異常的內存地址是合法的,並且找出它所對應的vma(如果找不到就是不合法)。然後分配內存、建立頁表。對於本文中描述的mmap映射了某個文件的這種情況,內核還需要把文件對應位置上的數據讀到新分配的內存上,這個工作主要是由vma->vm_ops->fault來完成的。前面我們看到vma->vm_ops是如何被賦值的了,而且這個vma->vm_ops->fault就等於filemap_fault。

filemap_fault()
    ......
    size = (i_size_read(inode) + PAGE_CACHE_SIZE - 1) >> PAGE_CACHE_SHIFT;
    if (vmf->pgoff >= size)
        return VM_FAULT_SIGBUS;
    ......

這個函數做的第一件事情就是檢查要訪問的地址偏移(相對於文件的)是否超過了文件大小,如果超過就返回VM_FAULT_SIGBUS,這將導致SIGBUS信號被髮送給進程。

用戶程序驗證

雖然看到內核代碼就是這麼實現的了,寫個用戶程序來驗證一下總會讓人更信服。一個簡單的測試程序如下:

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <signal.h>
#include <sys/mman.h>
#include <unistd.h>
#define FILESIZE 8192
void handle_sigbus(int sig)
{
    printf("SIGBUS!\n");
    _exit(0);
}
void main()
{
    int i;
    char *p, tmp;
    int fd = open("tmp.ttt", O_RDWR);
    p = (char*)mmap(NULL,FILESIZE, PROT_READ|PROT_WRITE,MAP_SHARED, fd, 0);
    signal(SIGBUS, handle_sigbus);
    getchar();
    for (i=0; i<FILESIZE; i++) {
        tmp = p[i];
    }
    printf("ok\n");
}

在執行這個程序前:
kouu@kouu-one:~/test$ stat tmp.ttt 
File: "tmp.ttt"
Size: 239104     Blocks: 480        IO Block: 4096   普通文件

把程序跑起來,顯然8192大小的內存是可以映射的。然後程序會停在getchar()處。
kouu@kouu-one:~/test$ echo "" > tmp.ttt
kouu@kouu-one:~/test$ stat tmp.ttt 
File: "tmp.ttt"
Size: 1          Blocks: 8          IO Block: 4096   普通文件

現在我們將 tmp.ttt弄成1字節的。然後給程序一個輸入,讓它從getchar()返回。
kouu@kouu-one:~/test$ ./a.out 

SIGBUS!

立刻,程序就收到SIGBUS信號了。

解決辦法

這樣的問題在用戶態有辦法解決嗎?我的理解是:沒有!

或許你會說,爲什麼不在每次讀之前都取一下文件大小,以確保不越界呢?讀文件是通過讀內存來進行的,那麼應當每讀一個字就檢查一下嗎?這樣做的話效率將大打折扣,mmap還有什麼意義?
即便如此,“檢查通過”與“讀操作”並不是原子的,這兩個操作之間還是可能存在文件被縮小的問題(儘管可能性變小了)。

所以,目前使用mmap的程序是會存在這樣的風險,而收到SIGBUS信號。

是否你異想天開,打算把SIGBUS給捕捉了,然後忽略掉呢?
可以試一下上面的程序,在handle_sigbus函數中把_exit一句註釋掉,看看會有什麼樣的結果。
其結果就是,handle_sigbus會重複重複再重複地被調用,就像一個死循環。爲什麼會這樣呢?因爲如果在handle_sigbus函數中把收到SIGBUS的事情給忽略了,內核也就會從前面提到的訪存異常中返回,回到用戶態,然後CPU會重新執行引起異常的那條指令(這條指令因爲異常而未被執行完,必須得重新執行)。
正常情況下,這個時候頁面已經分配了、頁表已經建立了、文件也讀好了。重新執行引起異常的指令時,就不會再引起異常了。但是現在這些條件不滿足,這條指令還會引起異常,於是又走到上面講的那一套流程,然後又觸發SIGBUS,然後又被忽略……於是就成了死循環。

所以,面對這種情況,用戶程序是沒招的。
或許在打開文件之後,mmap之前,先給文件加一個強制鎖(百度一下?),這是一種解決辦法。但是使用強制鎖的限制很多(文件系統要支持、mount時要特殊處理,還要給文件加SGID),並且鎖本身很黃很暴力(確實可以阻止別人寫入,但是如果程序BUG、句柄泄漏,別人就真沒法改了),據說移植性又不好。這一招不到萬不得已還是別使……

那麼內核程序呢?

如果用戶程序通過read來讀文件,則每次讀文件都是通過系統調用來進行的。當發生錯誤的時候,read系統調用可以儘可能地返回失敗(而不必武斷地發出SIGBUS信號),然後讓程序決定下一步該怎麼辦。
但是像mmap這樣,是以讀內存的方式來讀文件。有什麼機制讓你選擇讀內存失敗了該怎麼辦嗎?沒有,只能是“不成功,便成仁”。(好比你寫下“i=j;”這麼一句,不可能還有辦法檢查讀取j或者寫i是否成功。)

然而,我不知道內核爲什麼要在判斷訪問文件越界時拋出SIGBUS,或許有些東西我沒能理解透徹。
在這個地方(filemap_fault函數中),如果發現訪問越界,是否可以返回一個0頁面,讓它給映射上呢(也就是說,如果讀越界,讀到的內容就是0)。(這個0頁面在內核中其實也是存在的,頁面的內容全是0,當程序去讀沒有被映射的頁面時,這個0頁面就映射給它,而並不用分配新的頁面。因爲頁面都沒映射,顯然沒被寫過,也就是說這些內存沒有初值,所以默認都填0了。)

並且,這裏的訪問越界,我覺得,應該沒有什麼危害。因爲再怎麼越界,都不會越過mmap時創建的那個vma,這些地址應該說都是合法的。

SIGBUS的必要性

爲什麼內核在上述情況下要拋出SIGBUS信號呢?
原來這是POSIX的規定,引用一段:

The mmap() function can be used to map a region of memory that is larger than the current size of the object. Memory access within the mapping but beyond the current end of the underlying objects may result in SIGBUS signals being sent to the process. The reason for this is that the size of the object can be manipulated by other processes and can change at any moment. The implementation should tell the application that a memory reference is outside the object where this can be detected; otherwise, written data may be lost and read data may not reflect actual data in the object.

參閱mmap文檔:http://www.opengroup.org/onlinepubs/000095399/functions/mmap.html


捕獲異常的絕招

這個問題用戶程序還有什麼招嗎?
發現還有一招,那就是使用異常處理的方法將這個錯誤catch住。在C下,我們可以使用sigsetjmp - siglongjmp來實現。(關於setjmp/longjmp,可參閱:http://www.yuanma.org/data/2007/0110/article_2084.htm

把之前的代碼改造如下(類似的方法也可以用來捕獲內存訪問越界段錯誤等問題):

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <signal.h>
#include <sys/mman.h>
#include <unistd.h>
#include <setjmp.h>
#define FILESIZE 8192
sigjmp_buf env;
void handle_sigbus(int sig)
{
    printf("SIGBUS!\n");
    siglongjmp(env, 1);
}
void main()
{
    int i;
    char *p, tmp;
    int fd = open("tmp.ttt", O_RDWR);
    p = (char*)mmap(NULL, FILESIZE, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
    signal(SIGBUS, handle_sigbus);
    getchar();
    if (!sigsetjmp(env, 1)) {
        for (i=0; i<FILESIZE; i++) {
            tmp = p[i];
        }
    }
    else {
        printf("fault cached when i=%d\n", i);
    }
    printf("ok\n");
}

注意env參數的傳遞,看起來似乎有些奇怪。env是一個保存執行上下文(棧指針、指令指針、等)的結構,setjmp函數會在這個結構中填入當前的上下文信息。然而,調用setjmp時傳遞的居然不是&env,而是env!這是怎麼回事呢?C可不支持引用傳遞的喔~
在libc裏面,jmp_buf(env其類型)有個很奇怪的定義:typedef struct __jmp_buf_tag jmp_buf[1];
知道原因了吧,原來env是一個數組的名字呀~

按照同樣的流程,先執行程序、再將文件縮小、再進行內存訪問。得到的輸出結果如下:

kouu@kouu-one:~/test$ ./a.out 

SIGBUS!
fault cached when i=4096
ok

參閱一段郵件列表:http://lkml.indiana.edu/hypermail/linux/kernel/0205.1/0525.html


像可執行文件那樣“text busy”?

在CU論壇上與網友討論中(見:http://linux.chinaunix.net/bbs/thread-1162037-1-1.html),又引出一個問題:進程所執行的可執行文件也是通過mmap進行映射的(可以通過cat /proc/$pid/maps來看到這些映射)。那麼如果我們在進程的執行期間將文件改小,是不是進程也會收到SIGBUS而崩潰呢?

如果你有辦法將文件改小的話,的確會這樣。但是你會發現,當你重寫或者拷貝覆蓋一個正在執行的文件時,控制檯會給出“text busy”的提示。linux內核保證了這個文件不可寫。

那麼這是怎麼做到的呢?mmap映射普通文件時是否可以借鑑?
這是通過建立映射時的MAP_DENYWRITE選項來實現的。這個選項在mmap的過程中會被處理:

mmap_region()
    ......
    if (file) {
        ......
        if (vm_flags & VM_DENYWRITE) {
            error = deny_write_access(file);
            ......
        }
    ......

MAP_DENYWRITE選項會被轉成vma上的標記VM_DENYWRITE。mmap時遇到這個標記會調用deny_write_access。

deny_write_access()
int deny_write_access(struct file * file)
{
    struct inode *inode = file->f_path.dentry->d_inode;

    spin_lock(&inode->i_lock);
    if (atomic_read(&inode->i_writecount) > 0) {
        spin_unlock(&inode->i_lock);
        return -ETXTBSY;
    }
    atomic_dec(&inode->i_writecount);
    spin_unlock(&inode->i_lock);

    return 0;
}

如果文件正在被寫(inode->i_writecount大於0,可能存在多個寫者),則映射失敗。因爲現在要做的是禁止別人寫,但是別人先到一步,這就沒辦法了。
否則(inode->i_writecount小於等於0),讓inode->i_writecount自減1。inode->i_writecount的值小於0時表示文件已被“deny write”。而inode->i_writecount還可能小於-1,因爲有多個進程同時讓它“deny write”。只有等它們都解除禁止時,文件才能夠被寫。

當一個文件被“deny write”之後,其他進程若想修改它,則在open這個文件的時候就會因爲無法通過“deny write”的檢查,而得到相應的錯誤碼。
檢查函數get_write_access跟deny_write_access正好是反過來的:

get_write_access()
int get_write_access(struct inode * inode)
{
    spin_lock(&inode->i_lock);
    if (atomic_read(&inode->i_writecount) < 0) {
        spin_unlock(&inode->i_lock);
        return -ETXTBSY;
    }
    atomic_inc(&inode->i_writecount);
    spin_unlock(&inode->i_lock);

    return 0;
}

這樣,試圖以寫模式打開一個已經被“deny write”的文件,就將會被阻止。文件既然不能被打開,也就不能被寫了。

然而,不幸的是,MAP_DENYWRITE選項在mmap系統調用裏面是會被忽略的,只有在內核內部使用do_mmap時才能被使用(比如exec系列的系統調用中,在加載可執行文件時,就會調用do_mmap,並使用MAP_DENYWRITE選項)。
就連動態鏈接庫也沒法倖免(它們也是由庫函數通過系統調用mmap來映射的。奇怪的是,爲什麼不用uselib系統調用呢?),搜到一篇康神的文章在說這個事情:http://blog.kangkang.org/index.php/archives/49

那麼爲什麼要忽略mmap系統調用時傳遞的MAP_DENYWRITE選項呢?man mmap,可以看到這麼一段:
MAP_DENYWRITE
    This  flag  is ignored.  (Long ago, it signaled that attempts to write to the underlying file should fail with ETXTBUSY. But this was a source of denial-of-service attacks.)

指定MAP_DENYWRITE選項可能引起一些Dos,這裏指的是:一個普通用戶可以使整個系統在某些方面拒絕服務。典型的做法是:用戶以MAP_DENYWRITE選項mmap某個日誌文件,於是需要寫這個日誌文件的應用程序將無法正常工作。
比如,login程序在用戶登錄時會寫utmp日誌(一般在/var/run/utmp),如果這個文件被某個用戶“deny write”,那麼其他用戶就沒法登錄了。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章