天天看點

深入分析glibc記憶體釋放時的死鎖bug

通常我們認為一旦記憶體寫溢出,程式就很容易崩潰。是以伺服器上通常會對一些重要程序做腳本保護,一旦崩潰立即重新拉起。

  最近發現我們一個公共服務記憶體寫溢出時程式沒有崩潰,而是卡死了。

  為了深入分析原因,我們仔細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(&amp;ar_ptr-&gt;mutex);</code>

<code>  </code><code>_int_free(ar_ptr, mem);</code>

<code>  </code><code>(</code><code>void</code><code>)mutex_unlock(&amp;ar_ptr-&gt;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>+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+  &lt;--真正的chunk首指針</code>

<code>|  prev_size, 前一個chunk的大小               | |</code>

<code>+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+</code>

<code>|  size, 低位作标志位,高位存放chunk的大小    |M|P|</code>

<code>+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+  &lt;--</code><code>malloc</code><code>成功傳回的首指針</code>

<code>|  正常時存放使用者資料;                          .</code>

<code>.  空閑時存放malloc_chunk結構後續成員變量。       .</code>

<code>.                                             .</code>

<code>.                                             |</code>

<code>+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+  &lt;--下一個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 &lt;time.h&gt;</code>

<code>#include &lt;unistd.h&gt;</code>

<code>#include &lt;stdlib.h&gt;</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>(&amp;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應該基本上就可以了。

繼續閱讀