通常我們認為一旦記憶體寫溢出,程式就很容易崩潰。是以伺服器上通常會對一些重要程序做腳本保護,一旦崩潰立即重新拉起。
最近發現我們一個公共服務記憶體寫溢出時程式沒有崩潰,而是卡死了。
為了深入分析原因,我們仔細review了glibc的代碼,并發現一個較為隐蔽的bug。
先來看這個卡死的程式堆棧(64位環境,下同):
<code>#0 0x00002b059302ac38 in __lll_mutex_lock_wait () from /lib64/libc.so.6</code>
<code>#1 0x00002b0592fcee5f in _L_lock_4026 () from /lib64/libc.so.6</code>
<code>#2 0x00002b0592fcbdf1 in free () from /lib64/libc.so.6</code>
<code>#3 0x00002b0592fe4148 in tzset_internal () from /lib64/libc.so.6</code>
<code>#4 0x00002b0592fe49d0 in tzset () from /lib64/libc.so.6</code>
<code>#5 0x00002b0592fe8e44 in strftime_l () from /lib64/libc.so.6</code>
<code>#6 0x00002b059301c701 in __vsyslog_chk () from /lib64/libc.so.6</code>
<code>#7 0x00002b0592fc56d0 in __libc_message () from /lib64/libc.so.6</code>
<code>#8 0x00002b0592fca77e in malloc_printerr () from /lib64/libc.so.6</code>
<code>#9 0x00002b0592fcbdfc in free () from /lib64/libc.so.6</code>
<code>#10 0x00002b05929ed657 in deflateEnd () from /lib64/libz.so.1</code>
<code>#11 0x00000000004884b8 in CHttpResp::GetOutput (this=0x2b059dd414f8,</code>
<code>#12 ……</code>
可以看到在free函數中使用了鎖。
那麼再來看看glibc中free函數的主要代碼:
<code>#define public_fREe free</code>
<code>void</code> <code>public_fREe(Void_t* mem)</code>
<code>{</code>
<code> </code><code>mchunkptr p = mem2chunk(mem);</code>
<code> </code><code>mstate ar_ptr = arena_for_chunk(p);</code>
<code> </code>
<code> </code><code>……</code>
<code> </code><code>(</code><code>void</code><code>)mutex_lock(&ar_ptr->mutex);</code>
<code> </code><code>_int_free(ar_ptr, mem);</code>
<code> </code><code>(</code><code>void</code><code>)mutex_unlock(&ar_ptr->mutex);</code>
<code>}</code>
這段代碼相當簡單,不用過多解釋。
再對比上面的堆棧,可以推測流程大概是這樣的:frame 9釋放記憶體時發現記憶體資料校驗有誤是以進行出錯輸出,當寫syslog時需要取本地時間,而在取時區資訊的函數裡面也有free函數調用,是以到frame 2釋放記憶體想要再次擷取鎖的時候程式死鎖了。
這應該屬于glibc的bug了,雖然這個bug首先要由程式員的bug來觸發。
為了進一步确認glibc的這個問題,我們繼續深入閱讀glibc的代碼以重制之。
首先,為什麼記憶體寫越界會導緻free出錯?解答這個問題前我們先簡單說說一些相關的malloc配置設定記憶體原理。
跟一些人想象不同的是,并不是每次malloc調用一定導緻記憶體配置設定,因為當記憶體釋放時glibc會将記憶體先保留到空閑隊列當中,下次有malloc調用時可以找一個合适的記憶體塊直接傳回,這樣就避免了真正從系統配置設定記憶體的系統調用開銷。glibc需要管理這些空閑記憶體塊,那麼就需要一個相應的資料結構,這個資料結構定義如下:
<code>struct</code> <code>malloc_chunk {</code>
<code> </code><code>INTERNAL_SIZE_T prev_size; </code><code>/* Size of previous chunk (if free). */</code>
<code> </code><code>INTERNAL_SIZE_T size; </code><code>/* Size in bytes, including overhead. */</code>
<code> </code><code>struct</code> <code>malloc_chunk* fd; </code><code>/* double links -- used only if free. */</code>
<code> </code><code>struct</code> <code>malloc_chunk* bk;</code>
<code> </code><code>/* Only used for large blocks: pointer to next larger size. */</code>
<code> </code><code>struct</code> <code>malloc_chunk* fd_nextsize; </code><code>/* double links -- used only if free. */</code>
<code> </code><code>struct</code> <code>malloc_chunk* bk_nextsize;</code>
<code>};</code>
映射到記憶體示意圖上如下圖所示:
<code>+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ <--真正的chunk首指針</code>
<code>| prev_size, 前一個chunk的大小 | |</code>
<code>+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+</code>
<code>| size, 低位作标志位,高位存放chunk的大小 |M|P|</code>
<code>+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ <--</code><code>malloc</code><code>成功傳回的首指針</code>
<code>| 正常時存放使用者資料; .</code>
<code>. 空閑時存放malloc_chunk結構後續成員變量。 .</code>
<code>. .</code>
<code>. |</code>
<code>+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ <--下一個chunk的首指針</code>
<code>| prev_size …… |</code>
可以看到,我們每次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;"這樣的操作。
是以我們的簡單示範代碼如下:
<code>// file: test.cpp</code>
<code>#include <time.h></code>
<code>#include <unistd.h></code>
<code>#include <stdlib.h></code>
<code>int</code> <code>main(</code><code>int</code> <code>argc, </code><code>char</code><code>** argv)</code>
<code> </code><code>// 設定環境變量,強制錯誤輸出到stderr,而不是tty</code>
<code> </code><code>setenv(</code><code>"LIBC_FATAL_STDERR_"</code><code>, </code><code>"1"</code><code>, 1);</code>
<code> </code><code>close(STDERR_FILENO); </code><code>// 關閉stderr</code>
<code> </code><code>time_t</code> <code>now = </code><code>time</code><code>(NULL);</code>
<code> </code><code>tm</code> <code>*t = </code><code>localtime</code><code>(&now); </code><code>// 觸發old_tz初始化</code>
<code> </code><code>char</code> <code>*p1 = </code><code>new</code> <code>char</code><code>[102400];</code>
<code> </code><code>char</code> <code>*p2 = </code><code>new</code> <code>char</code><code>[4096];</code>
<code> </code><code>p1[102400 + </code><code>sizeof</code><code>(</code><code>size_t</code><code>)] = 1;</code><code>// 模拟記憶體寫溢出</code>
<code> </code><code>delete</code> <code>[] p2; </code><code>// 程式在這裡死鎖</code>
<code> </code><code>delete</code> <code>[] p1;</code>
<code> </code><code>return</code> <code>0;</code>
g++ -pg -g test.cpp編譯得到可執行程式a.out。
使用gdb運作此程式,如預期般的死鎖。
檢視堆棧如下:
<code>(gdb) bt</code>
<code>#0 0x00002ba6519a4c38 in __lll_mutex_lock_wait () from /lib64/libc.so.6</code>
<code>#1 0x00002ba651948e5f in _L_lock_4026 () from /lib64/libc.so.6</code>
<code>#2 0x00002ba651945df1 in free () from /lib64/libc.so.6</code>
<code>#3 0x00002ba65195e148 in tzset_internal () from /lib64/libc.so.6</code>
<code>#4 0x00002ba65195e9d0 in tzset () from /lib64/libc.so.6</code>
<code>#5 0x00002ba651962e44 in strftime_l () from /lib64/libc.so.6</code>
<code>#6 0x00002ba651996701 in __vsyslog_chk () from /lib64/libc.so.6</code>
<code>#7 0x00002ba65193f6d0 in __libc_message () from /lib64/libc.so.6</code>
<code>#8 0x00002ba65194477e in malloc_printerr () from /lib64/libc.so.6</code>
<code>#9 0x00002ba651945dfc in free () from /lib64/libc.so.6</code>
<code>#10 0x000000000040094e in main (argc=1, argv=0x7fff5974c828) at test1.cpp:18</code>
程式堆棧跟文首的完全相同。至此問題得到确認。
我簡單檢視了一下glibc的曆史版本代碼,這個bug在2.4到2.8的版本上都存在。當然這個bug首先需要程式員犯了記憶體寫溢出錯誤才會誘發,是以這并不是嚴重bug,大家隻要知道了自然也可結合實際情況做防範。比如檢查程序是否正常不能光看程序是否存在,還需用工具做收發包檢測,或者檢視定時日志是否一直有輸出之類。
就這個問題本身來看,glibc确實還可以做得更好,例如應該進一步縮小鎖的作用域,既提升并發性能,又可降低作用域内其他函數交叉調用引發的死鎖風險;另外,個人認為tzset_internal中完全沒必要動态配置設定記憶體,給old_tz一個固定大小的記憶體比如256byte應該基本上就可以了。