Quantcast
Channel: CodeSection,代码区,网络安全 - CodeSec
Viewing all articles
Browse latest Browse all 12749

【技术分享】看我如何编写一个Linux 调试器(四):Elves 和 dwarves

$
0
0
【技术分享】看我如何编写一个linux 调试器(四):Elves 和 dwarves

2017-10-16 14:38:41

阅读:212次
点赞(0)
收藏
来源: tartanllama.xyz





【技术分享】看我如何编写一个Linux 调试器(四):Elves 和 dwarves

作者:_veritas501





【技术分享】看我如何编写一个Linux 调试器(四):Elves 和 dwarves

译者:_veritas501

预估稿费:200 RMB

投稿方式:发送邮件至linwei#360.cn,或登陆网页版在线投稿


前言

传送门:

【技术分享】看我如何编写一个Linux 调试器(一):准备工作

【技术分享】看我如何编写一个Linux 调试器(二):断点

【技术分享】看我如何编写一个Linux 调试器(三):内存和寄存器

到现在为止,我们已经对dwarves有所耳闻,它是一种调试信息,一种理解源码而不用分析它的的方式。今天我们来介绍关于源码级调试的详细信息,以便后续教程中对它的使用。


ELF 和 DWARF简介

ELF和DWARF这两种组件你或许没听说过,但你可能已经使用过了。ELF(Executable and Linkable Format)是Linux中最广泛使用的一种文件格式。它指定了binary中不同部分的储存方式,例如代码,静态数据,调试信息和字符串。他还告诉loader如何获取二进制并准备执行,这涉及到二进制的不同部分应该放在内存的什么位置,哪些比特需要根据其他组件(重定位)等的位置来进行修复。我们在文章中对ELF介绍过多,如果你感兴趣你可以看一下这个漂亮的图表或相关标准。

DWARF是ELF中最常用的调试信息格式。它并不只限于ELF,但是它们两相互促进,一起工作的很好。这种格式允许编译器告诉调试器binary中待执行的部分在源码中的什么位置。这些信息在不同的ELF节中不同,每一部分都有自己的信息来中继。下面是定义的不同节,虽有些过时但信息很具体DWARF调试格式介绍:

.debug_abbrev是在.debug_info节中使用的缩写 .debug_aranges内存地址和编译间的映射 .debug_frameCallFrame的信息 .debug_info是DWARF数据的核心,包含了DWARF信息的条目(DWARFInformationEntries(DIEs)) .debug_line程序的行号 .debug_loc位置描述 .debug_macinfo宏描述 .debug_pubnames全局对象和函数的查找表 .debug_pubtypes全局类型的查找表 .debug_rangesDIEs引用的地址范围 .debug_str是.debug_info使用的字符串表 .debug_types类型描述

我们最感兴趣的是.debug_line 和.debug_info节,所以让我们看一下一个简单的程序的DWARF信息。

intmain(){ longa=3; longb=2; longc=a+b; a=4; }

DWARF 行号表

如果你用-g选项来编译这个程序并对编译结果使用dwarfdump,你就会看到line number section的样子类似这样:

.debug_line:linenumberinfoforasinglecu Sourcelines(fromCU-DIEat.debug_infooffset0x0000000b): NSnewstatement,BBnewbasicblock,ETendoftextsequence PEprologueend,EBepiloguebegin IS=valISAnumber,DI=valdiscriminatorvalue <pc>[lno,col]NSBBETPEEBIS=DI=uri:"filepath" 0x00400670[1,0]NSuri:"/home/simon/play/MiniDbg/examples/variable.cpp" 0x00400676[2,10]NSPE 0x0040067e[3,10]NS 0x00400686[4,14]NS 0x0040068a[4,16] 0x0040068e[4,10] 0x00400692[5,7]NS 0x0040069a[6,1]NS 0x0040069c[6,1]NSET

前面几行是一些关于如何理解dump内容的信息,主要的行号数据从0x00400670开始。本质上这将代码的内存地址与文件中的行号建立映射。NS表示地址标记着新语句的开始,这通常用于设置断点或单步执行。PE表示函数序言(function prologue)的结束,这对这只函数入口断点很有帮助。ET表示转换单元的结束。信息实际上并不像这样编码,真正的编码是一种非常节省空间,且可以通过执行它来建立这些行信息的排序程序。

假设我们想在variable.cpp的第四行设置断点,我们该怎么做?我们先查找和该文件对应的条目,然后寻找对应的行条目,寻找对应的地址,然后在那里设置断点。在这个例子中,这条条目是:

0x00400686[4,14]NS

我们想在0x00400686处设置断点。如果你想尝试你可以手动在已经编写好的调试器上尝试。

反过来也是这样。如果我们有一个内存地址,比如一个PC指针,我们想要找到它在源码中所对应的位置,我们只需从行号表中查找最接近的映射地址并将行号取出来。


DWARF调试信息

.debug_info节是DWARF的核心。它提供了类型,函数,变量的信息。这个节中最基本的单元是DWARF 信息条目(DWARF Information Entry),简称DIE。一个 DIE 包括一个能告诉你正在展现什么样的源码级实体的标签,后面跟着一系列该实体的属性。这是我上面展示的简单事例程序的 .debug_info 部分:

.debug_info COMPILE_UNIT<headeroveralloffset=0x00000000>: <0><0x0000000b>DW_TAG_compile_unit DW_AT_producerclangversion3.9.1(tags/RELEASE_391/final) DW_AT_languageDW_LANG_C_plus_plus DW_AT_name/super/secret/path/MiniDbg/examples/variable.cpp DW_AT_stmt_list0x00000000 DW_AT_comp_dir/super/secret/path/MiniDbg/build DW_AT_low_pc0x00400670 DW_AT_high_pc0x0040069c LOCAL_SYMBOLS: <1><0x0000002e>DW_TAG_subprogram DW_AT_low_pc0x00400670 DW_AT_high_pc0x0040069c DW_AT_frame_baseDW_OP_reg6 DW_AT_namemain DW_AT_decl_file0x00000001/super/secret/path/MiniDbg/examples/variable.cpp DW_AT_decl_line0x00000001 DW_AT_type<0x00000077> DW_AT_externalyes(1) <2><0x0000004c>DW_TAG_variable DW_AT_locationDW_OP_fbreg-8 DW_AT_namea DW_AT_decl_file0x00000001/super/secret/path/MiniDbg/examples/variable.cpp DW_AT_decl_line0x00000002 DW_AT_type<0x0000007e> <2><0x0000005a>DW_TAG_variable DW_AT_locationDW_OP_fbreg-16 DW_AT_nameb DW_AT_decl_file0x00000001/super/secret/path/MiniDbg/examples/variable.cpp DW_AT_decl_line0x00000003 DW_AT_type<0x0000007e> <2><0x00000068>DW_TAG_variable DW_AT_locationDW_OP_fbreg-24 DW_AT_namec DW_AT_decl_file0x00000001/super/secret/path/MiniDbg/examples/variable.cpp DW_AT_decl_line0x00000004 DW_AT_type<0x0000007e> <1><0x00000077>DW_TAG_base_type DW_AT_nameint DW_AT_encodingDW_ATE_signed DW_AT_byte_size0x00000004 <1><0x0000007e>DW_TAG_base_type DW_AT_namelongint DW_AT_encodingDW_ATE_signed DW_AT_byte_size0x00000008

第一个DIE 代表一个编译单元(CU),本质上是一个包含了所有的#includes和类似的源码文件。以下是带含义注释的属性:

DW_AT_producerclangversion3.9.1(tags/RELEASE_391/final)<--Thecompilerwhichproduced thisbinary DW_AT_languageDW_LANG_C_plus_plus<--Thesourcelanguage DW_AT_name/super/secret/path/MiniDbg/examples/variable.cpp<--Thenameofthefilewhich thisCUrepresents DW_AT_stmt_list0x00000000<--Anoffsetintothelinetable whichtracksthisCU DW_AT_comp_dir/super/secret/path/MiniDbg/build<--Thecompilationdirectory DW_AT_low_pc0x00400670<--Thestartofthecodefor thisCU DW_AT_high_pc0x0040069c<--Theendofthecodefor thisCU

其他的DIE也遵循相似的类型,你可以凭直觉猜到不同属性的意思。

现在我们使用新学的DWARF的知识来尝试解决一些实际的问题。


我现在在哪个函数里?

假设我们现在得到了PC指针,我们想知道我们现在在哪个函数里,最简单的方法是:

foreachcompileunit: ifthepcisbetweenDW_AT_low_pcandDW_AT_high_pc: foreachfunctioninthecompileunit: ifthepcisbetweenDW_AT_low_pcandDW_AT_high_pc: returnfunctioninformation

对大多数情况来说这都适用,但如果有成员函数或是内联代码,情况就会变得更加复杂。假设有内联代码,如果含有内联代码,当我们找到包含PC指针地址的函数时,我们需要递归遍历所有的子DIE以检查是否有内联函数来更好地匹配。我不会为我的调试器添加对内联代码的支持,但是如果你喜欢的话你可以自己加。


如何在函数上设置断点?

再次说明,这取决于你是否想要支持成员函数,命名空间以及其他类似的东西。对简单的函数你只需迭代遍历不同编译单元中的函数直到你找到正确的名字。如果你的编译器能填充.debug_pubnames节,那么就可以更快地找到正确的名字。

一旦找到了函数,你就能在DW_AT_low_pc提供的内存地址上设置断点。然而,那会在函数序言(function prologue)处设置断点,而在用户代码的开始处设置断点会更合适。由于行表信息可以指定序言结束的内存地址,你只需要在行表中查找DW_AT_low_pc的值,然后一直读取到被标记为序言结束的条目。一些编译器不会输出这些信息,因此另一种方式是在该函数的第二行条目指定的地址出设置断点。

假设我们想在main处设置断点,我们寻找叫做main的函数,然后获取它的DIE:

<1><0x0000002e>DW_TAG_subprogram DW_AT_low_pc0x00400670 DW_AT_high_pc0x0040069c DW_AT_frame_baseDW_OP_reg6 DW_AT_namemain DW_AT_decl_file0x00000001/super/secret/path/MiniDbg/examples/variable.cpp DW_AT_decl_line0x00000001 DW_AT_type<0x00000077> DW_AT_externalyes(1)

它告诉我们函数从0x00400670处开始。如果我们在行表中查找它,我们就可以得到这个条目:

0x00400670[1,0]NSuri:"/super/secret/path/MiniDbg/examples/variable.cpp"

我们想要跳过序言,因此我们再读一个条目:

0x00400676[2,10]NSPE

Clang在这个条目中包含了序言结束的标志。因此我们知道要停在这里并在0x00400676处下断。


如何读取变量的内容?

读取变量可能会很复杂。因为变量是一种难以捉摸的东西,他们可以在函数中传递,保存在寄存器中,放在内存中,被优化掉,藏在角落里等等。好在我们的示例非常简单,如果我们想要读取变量a的值,我们只需看看它的DW_AT_location属性:

DW_AT_locationDW_OP_fbreg-8

这告诉我们它的内存被保存在栈帧基址(base of the stack frame)的偏移为-8的地方。为了知道栈帧基址的值,我们查看所在函数的DW_AT_frame_base的属性。

DW_AT_frame_baseDW_OP_reg6

从System V x86_64 ABI的定义可知,reg6是x86上的栈指针寄存器。现在我们从栈指针中读取值并减8从而得到了我们的变量,我们需要看一下它的类型:

<2><0x0000004c>DW_TAG_variable DW_AT_namea DW_AT_type<0x0000007e>

如果我们在调试信息中寻找这种类型,我们会得到以下的DIE:

<1><0x0000007e>DW_TAG_base_type DW_AT_namelongint DW_AT_encodingDW_ATE_signed DW_AT_byte_size0x00000008

这告诉我们这种类型是一种8字节(64比特)的有符号整型。因此我们可以把这些字节解释为int64_t类型并向用户显示。

当然,类型可能比那要复杂得多,因为它们要能够表示C++中的类型,但这足以让你对它的工作原理有个基本的认识。

再来说说栈帧基址,Clang可以通过栈帧指针寄存器来跟踪栈帧基址。最新版本的GCC倾向于使用 DW_OP_call_frame_cfa,它包括解析.eh_frameELF部分,那是一个完全不同的文章,因此我不打算去写。如果你告诉GCC用DWARF 2而不是最近的版本,他会输出更便于阅读的位置列表:

DW_AT_frame_base<loclistatoffset0x00000000with4entriesfollows> low-off:0x00000000addr0x00400696high-off0x00000001addr0x00400697>DW_OP_breg7+8 low-off:0x00000001addr0x00400697high-off0x00000004addr0x0040069a>DW_OP_breg7+16 low-off:0x00000004addr0x0040069ahigh-off0x00000031addr0x004006c7>DW_OP_breg6+16 low-off:0x00000031addr0x004006c7high-off0x00000032addr0x004006c8>DW_OP_breg7+8

位置列表会根据PC指针的位置来给出不同的位置。本例中,如果PC指针在DW_AT_low_pc偏移为 0x0 处,那么栈帧基址在reg7所保存值的偏移为8的位置,如果它在0x1 和 0x4 之间,那么栈帧基址就在偏移地址为16的地方。


Take a breath

这一篇中的内容有点多,需要你大脑好好消化一下,但好消息是在下面几篇中,我们将使用一个库来帮我们完成这些事情。理解概念依然是很有意义的,尤其是当某些错误发生或是你想支持的DWARF内容没有被任何你所用的DWARF库所实现的时候。

如果你想了解更多关于 DWARF的内容,你可以从这里获取其标准。在我写这篇文章的时候,DWARF 5刚刚发布,但DWARF 4的支持更多。



【技术分享】看我如何编写一个Linux 调试器(四):Elves 和 dwarves
【技术分享】看我如何编写一个Linux 调试器(四):Elves 和 dwarves
本文由 安全客 翻译,转载请注明“转自安全客”,并附上链接。
原文链接:https://blog.tartanllama.xyz/writing-a-linux-debugger-elf-dwarf/

Viewing all articles
Browse latest Browse all 12749

Trending Articles