首页 > *nix安全 > Intersec内存系列文章–第5部分:调试工具

Intersec内存系列文章–第5部分:调试工具

2014年3月17日 发表评论 阅读评论 5,561次浏览    

原文:http://techtalk.intersec.com/2013/12/memory-part-5-debugging-tools/

翻译:童进

引言

我们又回来了!前面我们用了4篇文章介绍什么是内存、如何去处理它,那么对于内存又会遇到怎样的问题呢。即使是最优秀的开发人员也会有bug。一个被普遍接受的估算是每千行代码可能有几十个bug ,这无疑是相当多的。因此,即使完全掌握了我们文章所述的内容,你仍然可能会遇到一些内存相关的bug。

内存相关的bug可能特别难发现和修复。我们以下面的程序为例:

mem_debug_program

这个程序的原意是将一个message作为参数打印“ hello <message>!”(默认的message是“world”)。

该程序的行为是完全不确定的,它有bug但不一定会crash。build_message函数返回一个指向分配在栈帧上的内存的指针。由栈的工作原理可知这块内存很容易被随后调用的函数覆盖,在这里很可能是fputs。因此,如果fputs内部使用足够多的栈内存从而覆盖了message,那么输出将不正确,甚至造成程序crash,而在其它情况下,程序将打印预期的消息。此外,因为使用了不安全的sprintf函数(它对写入的字节数没有限制),该程序可能会溢出。

因此,程序的行为依赖于命令行中输入的message大小、MAX_LINE_SIZE的取值以及fputs的实现。这种bug令人讨厌的地方是错误并不明显:在简单的使用场景下它工作良好,只有当它接收到的参数正好触发该问题时才会执行失败。因此开发人员熟练使用那些帮助他们验证或调试内存的工具是十分重要的。

在最后这篇文章中,我们将介绍一些免费工具,它们可以作为C/C++开发人员必备工具包的一部分。

调试器

首先要介绍的工具是调试器。在Linux系统中,常用的调试器极有可能是gdb 。大部分开发人员都知道gdb的基本使用:检查调用栈(bt,up,down,frame <id>,…),添加断点(break <function|line>,continue,…),单步执行(step,next,fin,…),检查内存(print <expr>,call <func>,x/<FMT> <addr>,…)。当程序因段错误而crash时,调试器是大部分开发人员的首选工具。调试器会自动捕捉信号,并能检查在那一时刻程序的状态。许多段错误很容易排查(如未初始化的指针,空指针引用,…),并且简单使用下调试器即可。

(译者注:GDB的基本使用可以参考宋宝华老师的这篇文章:《Linux gdb调试器用法全面解析》http://blog.csdn.net/21cnbao/article/details/7385161#1536434-tsina-1-50468-66a1f5d8f89e9ad52626f6f40fdeadaa

设置观察点这个功能一般鲜为人知:增加一个动态的断点,每当表达式的值发生变化时能中断程序的执行。这对于侦查内存错误的起因非常有用:在遭到损坏的内存内容处放置一个观察点,每当内存的内容被修改时程序就会中断。它对程序的性能影响很小,只要你不监控太多的内存地址,观察点可以直接由硬件管理。

回到在引言部分给出的例子中来:我们调用fputs 打印它的第一个参数(一个指针)指向的内容,然而实际打印的字符串并不是我们在build_message中构造的内容。下面是调试过程:

  • 首先,我们给build_message设置一个断点,用来检查sprintf是否正确构造我们的消息

mem_debug_break

  • 我们在字符串的第一个字符内容处设置了一个观察点,以便当message被修改时获得通知,然后让程序继续执行。调试器显示它已成功地设置了一个硬件观察点,这很好,因为软件观察点对程序整体性能影响较大。

mem_debug_watch

  • 观察点中断了程序的执行。调试器打印出了原来的值和新的价值,因此我们可以很容易地检查程序。通过调用栈我们知道自己处于动态连接器的某处代码中(可能正在对fputs进行符号解析)。

mem_debug_watch_point

在这里,调试器告诉了我们内存在哪里被修改,不过要理清这个问题则需要知道到底发生了什么。调试器提供了原始信息,开发人员则负责分析。总而言之,当你知道要查看什么时,调试器是一个很好的工具。

valgrind

valgrind是C/C++开发人员的瑞士军刀。它提供了各种工具,如内存检查器( memcheck ),内存分析器( massif ),cache分析器( cachegrind ),CPU分析器( callgrind ),线程检查器( helgrind , DRD , tsan ),…

valgrind本质上是一个虚拟机,用于监控程序与操作系统、虚拟硬件的每次交互。为了实现这一目的,它拿到一个未修改的可执行文件,将其中的CPU指令和系统调用封装为检测版本。它的可配性非常高:你可以精确定义虚拟机的期望行为:核的数目,缓存大小,系统调用的行为(对于系统调用,不同内核版本中行为会有变化)…它的主要缺点是因为不直接执行代码,这使得valgrind的运行开销非常大,并导致运行速度有5倍到50倍的下降(具体情况取决于使用的工具和配置的选项)。

运行valgrind很容易,不需要修改你的程序或系统(不过让代码变得valgrind可感知,可以使它受益)。最基本的命令就是:valgrind –tool=<toolname> <yourprogram and arguments> 。

memcheck

memcheck是Valgrind的默认工具。它是一个内存检查器,用于跟踪每一次的内存访问和分配,以寻找内存管理的错误,比如:

  • 访问未分配的内存

  • 程序的行为依赖于未初始化的内存

  • 内存泄露

要做到这些,memcheck首先要做的是维护所有已分配内存的记录表。每次分配一块新的内存时,memcheck通过记录返回的指针、分配的内存量以及相应的分配模块来跟踪它。此外,它在已分配内存周围添加了一些不应该分配的红色区域,用于检测越界访问。

毫无疑问它也会跟踪内存的每次释放,以更新内存记录表。释放内存时,记录表中的相应条目不会被立即删除,它将相应的条目标记为释放并记下释放的调用者。通过将释放的内存放在隔离区,确保释放后访问这一问题能被捕捉到,因为这样的内存不可能因其它目的而被过快的重用。

程序执行完后,memcheck将输出记录信息:未标记为释放的条目说明是存在泄露的分配。泄漏报告也表明内存是否仍被引用,不再被程序指向的内存一定丢失了。

此外,对于分配的每一个字节, memcheck也维护一个初始化状态:只有当内存的值是使用初始化后的字节计算的结果时,才被认为是初始化过的。只要一个未被初始化的字节被用于计算,计算的结果就是不确定的,并且如果该程序的行为依赖于这一结果,则其自身的行为也被认为是不确定的。

总体来说,通过巨大的性能开销和一些内存开销, memcheck可以检测大部分动态分配相关的错误。然而,它不能对代码中的静态内存或栈上分配的内存进行错误检测,因为memcheck对程序的内部知之甚少:它对于各种放在栈上的变量不可感知,因此不能检查你是否从栈上分配的缓冲区溢出到了附近的变量。

一个好的标准是对于写的每一份代码强制memcheck检查通过:如果用valgrind运行产生错误,这说明程序是不够好的。这并不能保证程序没有bug,但是它能确保内存分配没有问题。然而,该标准往往是很难达到,因为在实际编程中,memcheck会使性能下降40倍,这使得它不可能频繁运行。值得庆幸的是,一些工具如ASan可用于此目的(文章的后续部分会有介绍)。

memcheck的文档中有许多小示例,所以我们就停止转述文档,来具体看看memcheck运行我们程序时的输出:

mem_debug_memcheck

这比gdb中的调试更有意义。它告诉我们, fputs调用strlen(很明显需要计算打印字符串的长度),但strlen访问了栈指针下方的内存区域(实际上它访问了栈指针下方的两个字节) 。仍需要一些分析,但这一次却容易得多:我们计算一个字符串的长度,它在栈上,但有部分在栈外。

关于valgrind,最后一个有用的技巧是它与调试器交互的能力。使用valgrind –db-attach=yes <yourprogram>启动你的程序。这样每次memcheck报告错误时会询问你是否愿意在调试器中调试错误。

massif

massif是一个不同类型的工具,它是一个内存分析器。它同样能跟踪内存的分配和释放,不过与检查每一个内存地址不同,它建立内存使用情况的时间表。对于程序运行中的某些时刻(如程序占用更多内存的时刻),它会为每个单独的调用记录分配的大小。

最后,它输出报告,默认名称为massif.out.<pid> 。该报告是一个内存分配快照表,可读性很差。不过一些工具如ms_print可以让报告更容易理解。ms_print的输出是一个ASCII格式的直方图,它直观地显示内存使用情况:

mem_debug_massif

#列表示内存使用的顶峰,[email protected](#列也是详细记录的)。如果你的报告看起来像这样,那么程序中很可能存在内存泄漏问题,你应考虑修复它。

该图后是一个表格,里面是每个快照的内存使用情况。它看起来像这样:

mem_debug_massif_detail

开始的14行是简单的快照(仅报告了堆的使用情况),第14行后则紧随分配的详细报告。我们可以看到,在那个时刻大部分的内存是由配置加载引起的。

Address Sanitizer

Address Sanitizer (也称为ASan )是一个较新的工具。这个项目由谷歌发起,用于为大型项目(如WebKit或Chromium)提供好的内存检测工具,同时避免像memcheck那样的性能缺陷。ASan仍会减慢程序的运行,但其影响因子是2而不是40。相应的代价是ASan不会检测像使用未初始化的变量或内存泄漏这类memcheck可以检测的错误,但另一方面,它能检测静态或栈内存相关的更多错误。ASan首先被引入到LLVM/clang3.1中,并由GCC4.8进入GCC中。

ASan是一对工具:首先是编译扩展,然后是运行库。ASan的运行库会分配一个影子内存:一个超大块的内存,用于为每8字节内存记录一字节信息。默认情况下,所有内存的影子字节会被设置为0,表明它不能被访问。当内存被分配后,影子字节设置为其它值(用于记录字的哪些字节被分配、谁分配了它们),…它还会重载分配器来跟踪内存的分配和释放。就像memcheck一样,它把释放的内存放入隔离区以便能够检测释放后使用问题。

这样,每次发生内存访问时,运行库将检查相应影子字节的值,如果访问是不被允许的,ASan就会中止程序的执行: ASan在第一个错误发生时崩溃程序,它强制程序必须是ASan检查通过的。

总体而言,相较valgrind,ASan的运行库功能较少:它不能够检测内存泄漏或访问未初始化内存这样的问题。不过ASan大部分的强大功能来自于它的编译器端组件。ASan侵入程序这一事实也许看起来会对程序造成影响,但这也使得它与程序本身结合得更紧密。不过这也使得它只能检查已设置过的代码,而无法捕获发生在第三方库(如在libc)中的错误。

编译扩展的主要作用是将对内存的每一次访问封装在一个小分支中,通过检查影子内存的内容确认访问是否允许。不过因为是在编译器中进行处理,所以它可以访问大量的信息,比如正在访问什么内存,变量或结构体成员的布局是怎样的,…并且它还能可以改变这些。这正是ASan让人眼前一亮的地方:它可以在全局的变量间或栈上的变量间添加红色区域,使得对这些变量的错误访问更容易被检查到。

ASan也可以检测我们例子中的两个问题,但由于问题发生在libc的函数中,这使得检测失效。在Intersec,我们有自己的sprintf实现,这使得程序中的sprintf能够被ASan设置。当输入过长的字符串作为参数传递时,下面是ASan的输出(对输出运行asan_symbolize.py以获取符号名):

mem_debug_asan_sprintf

采用短的字符串做同样的操作,一个重新实现的fputs给出了同样类型的结果:

mem_debug_asan_fputs

然而正如在前面例子中看到的,上面给出的都是提示信息,并不是程序在哪里出错的完整答案。

结论

内存是所有计算机程序的基础资源,但很难理解和管理。工具的存在可以帮助开发人员和系统管理员,但是它们的输出需要仔细思考才能真正有意义。

这个系列的文章试图覆盖的主题范围很大,还有更多的内容(其他人也已探讨了很多)可以写。我们挑选的主题是自己认为对开发人员和系统管理员必备的,内容既包括基本知识也有对各种限制的理解,希望它能帮助大家。

(全文完)

版权声明:自由转载-非商用-非衍生-保持署名 | Creative Commons BY-NC-ND 3.0
分类: *nix安全 标签: , ,
  1. 本文目前尚无任何评论.