天天看点

深入分析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应该基本上就可以了。

继续阅读