通常我們認為一旦內存寫溢出,程序就很容易崩潰。所以服務器上通常會對一些重要進程做腳本保護,一旦崩潰立即重新拉起。
最近發現我們一個公共服務內存寫溢出時程序沒有崩潰,而是卡死了。
為了深入分析原因,我們仔細review了glibc的代碼,並發現一個較為隱蔽的bug。
先來看這個卡死的程序堆棧(64位環境,下同):
最近發現我們一個公共服務內存寫溢出時程序沒有崩潰,而是卡死了。
為了深入分析原因,我們仔細review了glibc的代碼,並發現一個較為隱蔽的bug。
先來看這個卡死的程序堆棧(64位環境,下同):
可以看到在free函數中使用了鎖。
那么再來看看glibc中free函數的主要代碼:
這段代碼相當簡單,不用過多解釋。
再對比上面的堆棧,可以推測流程大概是這樣的:frame 9釋放內存時發現內存數據校驗有誤所以進行出錯輸出,當寫syslog時需要取本地時間,而在取時區信息的函數里面也有free函數調用,所以到frame 2釋放內存想要再次獲取鎖的時候程序死鎖了。
這應該屬於glibc的bug了,雖然這個bug首先要由程序員的bug來觸發。
這應該屬於glibc的bug了,雖然這個bug首先要由程序員的bug來觸發。
為了進一步確認glibc的這個問題,我們繼續深入閱讀glibc的代碼以重現之。
首先,為什么內存寫越界會導致free出錯?解答這個問題前我們先簡單說說一些相關的malloc分配內存原理。
跟一些人想象不同的是,並不是每次malloc調用一定導致內存分配,因為當內存釋放時glibc會將內存先保留到空閑隊列當中,下次有malloc調用時可以找一個合適的內存塊直接返回,這樣就避免了真正從系統分配內存的系統調用開銷。glibc需要管理這些空閑內存塊,那么就需要一個相應的數據結構,這個數據結構定義如下:
首先,為什么內存寫越界會導致free出錯?解答這個問題前我們先簡單說說一些相關的malloc分配內存原理。
跟一些人想象不同的是,並不是每次malloc調用一定導致內存分配,因為當內存釋放時glibc會將內存先保留到空閑隊列當中,下次有malloc調用時可以找一個合適的內存塊直接返回,這樣就避免了真正從系統分配內存的系統調用開銷。glibc需要管理這些空閑內存塊,那么就需要一個相應的數據結構,這個數據結構定義如下:
映射到內存示意圖上如下圖所示:
可以看到,我們每次malloc返回的指針並不是內存塊的首指針,前面還有兩個size_t大小的參數,對於非空閑內存而言size參數最為重要。size參數存放着整個chunk的大小,由於物理內存的分配是要做字節對齊的,所以size參數的低位用不上,便作為flag使用。
內存寫溢出,通常就是把后一個chunk的size參數寫壞了。
size被寫壞,有兩種結果。一種是free函數能檢查出這個錯誤,程序就會先輸出一些錯誤信息然后abort;一種是free函數無法檢查出這個錯誤,程序便往往會直接crash。
根據最上面的堆棧推測,誘發bug的是前一種情況。我們的測試程序將會直接分配兩塊內存,並對第二塊內存chunk的size參數做簡單修改,以觸發情況一。
順便說一句,windows內存分配跟linux比較類似,也是將內存塊大小存放在malloc返回的指針位置之前。DEBUG模式下,編譯器還會在實際分配內存的兩端放兩個特殊值,這樣在內存回收時就可以檢測到內存寫溢出的問題。
其次,當free函數檢查到size異常以后,會調用malloc_printerr輸出一些錯誤信息,但它並不一定會寫syslog。
查看__libc_message的代碼可以發現,出現錯誤以后,glibc會先嘗試將錯誤信息寫入到stderr或tty,如果寫入失敗,才會去寫syslog(代碼有點啰嗦就不貼了)。
要模擬這個情況,只需將環境變量"LIBC_FATAL_STDERR_"設為1迫使出錯時寫stderr,然后將stderr關閉即可。通常daemon程序很容易處在這樣的狀態。
再次,查看tzset_internal的代碼,我們發現導致free操作的原因是靜態變量static char* old_tz釋放導致的。
old_tz存放了上一次調用tzset_internal時使用的時區字符串。如果再次調用tzset_internal時,時區不變就復用;如果不同,則free掉舊的字符串,strdup新的字符串,而strdup里面malloc了新字符串所需的內存塊。
要模擬這個情況只需先設法給old_tz一個初值,然后再做內存釋放觸發free(old_tz)即可。要給old_tz設初值只需先調用相關的時間函數即可,例如localtime這個函數經常就被用到,old_tz會初始化為默認值"/etc/localtime"。當malloc_printerr一步步調用到tzset_internal時,glibc會從環境變量"TZ"讀取新的時區字符串,通常大多數服務器是沒設置這個環境變量的,所以新tz就是空,從而導致"free(old_tz); old_tz = NULL;"這樣的操作。
所以我們的簡單演示代碼如下:
size被寫壞,有兩種結果。一種是free函數能檢查出這個錯誤,程序就會先輸出一些錯誤信息然后abort;一種是free函數無法檢查出這個錯誤,程序便往往會直接crash。
根據最上面的堆棧推測,誘發bug的是前一種情況。我們的測試程序將會直接分配兩塊內存,並對第二塊內存chunk的size參數做簡單修改,以觸發情況一。
順便說一句,windows內存分配跟linux比較類似,也是將內存塊大小存放在malloc返回的指針位置之前。DEBUG模式下,編譯器還會在實際分配內存的兩端放兩個特殊值,這樣在內存回收時就可以檢測到內存寫溢出的問題。
其次,當free函數檢查到size異常以后,會調用malloc_printerr輸出一些錯誤信息,但它並不一定會寫syslog。
查看__libc_message的代碼可以發現,出現錯誤以后,glibc會先嘗試將錯誤信息寫入到stderr或tty,如果寫入失敗,才會去寫syslog(代碼有點啰嗦就不貼了)。
要模擬這個情況,只需將環境變量"LIBC_FATAL_STDERR_"設為1迫使出錯時寫stderr,然后將stderr關閉即可。通常daemon程序很容易處在這樣的狀態。
再次,查看tzset_internal的代碼,我們發現導致free操作的原因是靜態變量static char* old_tz釋放導致的。
old_tz存放了上一次調用tzset_internal時使用的時區字符串。如果再次調用tzset_internal時,時區不變就復用;如果不同,則free掉舊的字符串,strdup新的字符串,而strdup里面malloc了新字符串所需的內存塊。
要模擬這個情況只需先設法給old_tz一個初值,然后再做內存釋放觸發free(old_tz)即可。要給old_tz設初值只需先調用相關的時間函數即可,例如localtime這個函數經常就被用到,old_tz會初始化為默認值"/etc/localtime"。當malloc_printerr一步步調用到tzset_internal時,glibc會從環境變量"TZ"讀取新的時區字符串,通常大多數服務器是沒設置這個環境變量的,所以新tz就是空,從而導致"free(old_tz); old_tz = NULL;"這樣的操作。
所以我們的簡單演示代碼如下:
g++ -pg -g test.cpp編譯得到可執行程序a.out。
使用gdb運行此程序,如預期般的死鎖。
查看堆棧如下:
查看堆棧如下:
程序堆棧跟文首的完全相同。至此問題得到確認。
我簡單查看了一下glibc的歷史版本代碼,這個bug在2.4到2.8的版本上都存在。當然這個bug首先需要程序員犯了內存寫溢出錯誤才會誘發,所以這並不是嚴重bug,大家只要知道了自然也可結合實際情況做防范。比如檢查進程是否正常不能光看進程是否存在,還需用工具做收發包檢測,或者查看定時日志是否一直有輸出之類。
就這個問題本身來看,glibc確實還可以做得更好,例如應該進一步縮小鎖的作用域,既提升並發性能,又可降低作用域內其他函數交叉調用引發的死鎖風險;另外,個人認為tzset_internal中完全沒必要動態分配內存,給old_tz一個固定大小的內存比如256byte應該基本上就可以了。