书名:Linux二进制分析
ISBN:978-7-115-46923-6
本书由人民邮电出版社发行数字版。版权所有,侵权必究。
您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。
我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。
如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。
• 著 [美] Ryan O'Neill
译 棣 琦
审 Linux中国
责任编辑 傅道坤
• 人民邮电出版社出版发行 北京市丰台区成寿寺路11号
邮编 100164 电子邮件 315@ptpress.com.cn
• 读者服务热线:(010)81055410
反盗版热线:(010)81055315
Copyright © Packt Publishing 2016. First published in the English language under the title Learning Linux Binary Analysis.
All Rights Reserved.
本书由英国Packt Publishing公司授权人民邮电出版社出版。未经出版者书面许可,对本书的任何部分不得以任何方式或任何手段复制和传播。
版权所有,侵权必究。
二进制分析属于信息安全业界逆向工程中的一种技术,通过利用可执行的机器代码(二进制)来分析应用程序的控制结构和运行方式,有助于信息安全从业人员更好地分析各种漏洞、病毒以及恶意软件,从而找到相应的解决方案。
本书是目前为止唯一一本剖析Linux ELF工作机制的图书,共分为9章,其内容涵盖了Linux环境和相关工具、ELF二进制格式、Linux进程追踪、ELF病毒技术、Linux二进制保护、Linux中的ELF二进制取证分析、进程内存取证分析、扩展核心文件快照技术、Linux/proc/kcore分析等。
本书适合具有一定的Linux操作知识,且了解C语言编程技巧的信息安全从业人员阅读。
译者棣琦(本名张萌萌),曾梦想成为一名高级口译,却阴差阳错成了一个爱写代码的程序员。在IT江湖升级打怪的过程中,为了不断提高自己的技能,看书是少不了的;而要想成为高级玩家,看英文书自然也是必须。一个很偶然的机会,我接触到了本书的英文版。第一遍翻看时略显吃力,毕竟书中讲述的许多概念都是作者的原创,网上几无相关资料。但是这些稀缺的内容对于深入理解二进制分析却非常重要,译者由此尝到了知识的甜头。本着“独乐乐不如众乐乐”和“知识分享”的目的,本书的翻译之路就这样顺理成章地开始了。
要想成为一名真正的黑客,不仅要会编写程序,还需要解析程序,对已有的二进制文件进行反编译,洞悉程序的工作原理。而本书完全是作者多年来在逆向工程领域的实战经验总结,其内容从Linux二进制格式的简单介绍到二进制逆向的细节,不一而足。书中还穿插了作者自己维护的许多项目或软件代码示例。相信通过本书的学习,读者完全可以掌握Linux二进制分析相关的一套完整的知识体系,为成长为一名高水平的黑客打下坚实的基础。考虑到本书并非针对零基础的读者编写,因此建议读者能够有一定的C语言和Linux基础,以便更好地理解领会书中精华。另外,任何IT技术的学习掌握,都离不开动手操作。读者要想叩开Linux二进制世界的大门,需要亲自动手实践书中示例,才能将书本知识转换为自身技能。
最后,不能免俗的是各种致谢(虽然俗,但诚意百分百)。感谢我的父母对我闯荡江湖的支持,感谢Linux中国创始人王兴宇的信赖,感谢语音识别领域的技术大牛姚光超提出的宝贵建议,感谢我的朋友Ray对我的鼓励。当然,更要感谢各位读者的支持。 最后的最后,由于译者水平有限,外加本书作者在表达上多有晦涩之处,因此译文难免有纰漏,还望广大读者以及业内同行批评指正。
2017年9月
北京
Ryan O'Neill是一名计算机安全研究员兼软件工程师,具有逆向工程、软件开发、安全防御和取证分析技术方面的背景。他是在计算机黑客亚文化的世界中成长起来的——那个由EFnet、BBS系统以及系统可执行栈上的远程缓冲区溢出组成的世界。他在年轻时就接触了系统安全、开发和病毒编写等领域。他对计算机黑客的极大热情如今已经演变成了对软件开发和专业安全研究的热爱。Ryan在DEFCON和RuxCon等很多计算机安全会议上发表过演讲,还举办了一个为期两天的ELF二进制黑客研讨会。 他的职业生涯非常成功,曾就职于Pikewerks、Leviathan安全集团这样的大公司,最近在Backtrace担任软件工程师。 Ryan还未出版过其他图书,不过他在Phrack和VXHeaven这样的在线期刊上发表的论文让他声名远扬。还有许多其他的作品可以从他的网站(http://www.bitlackeys.org
)上找到。
首先,要向我的母亲Michelle致以真诚的感谢,我已经将对她的感谢表达在这本书里了。这一切都是从母亲为我买的第一台计算机开始的,随后是大量的图书,从UNIX编程,到内核内部原理,再到网络安全。在我生命中的某一刻,我以为会永远放弃计算机,但是大约过了5年之后,当我想要重新点燃激情时,却发现已经把书扔掉了。随后我发现母亲偷偷地把那些书帮我保存了起来,一直到我重新需要的那一天。感谢我的母亲,你是最美的,我爱你。
还要感谢我生命中最重要的一个女人,她是我的另一半,是我的孩子的母亲。毫无疑问,如果没有她,就不会有我今天生活和事业上的成就。人们常说,每一个成功男人的背后都有一个伟大的女人。这句古老的格言道出的的确是真理。感谢Marilyn给我带来了极大的喜悦,并进入了我的生活。我爱你。
我的父亲Brian O'Neill在我生活中给了我巨大的鼓舞,教会了我为人夫、为人父和为人友的许多东西。我爱我的父亲,我会一直珍惜我们之间哲学和精神层面的交流。
感谢Michael和Jade,感谢你们如此独特和美好的灵魂。我爱你们。
最后,要感谢我的3个孩子:Mick、Jayden和Jolene。也许有一天你们会读到这本书,知道你们的父亲对计算机略知一二。我会永远把你们放在生活的首位。你们3个是令我惊奇的存在,为我的生活带来了更深刻的意义和爱。
Silvio Cesare在计算机安全领域是一个传奇的名字,因为他在许多领域都有高度创新和突破性的研究,从ELF病毒,到内核漏洞分析方面的突破。非常感谢Silvio的指导和友谊。我从你那里学到的东西要远远多于从我们行业其他人处所学的东西。
Baron Oldenburg也对本书起了很大的推动作用。好多次由于时间和精力的原因我几乎要放弃了,幸好Baron帮我进行了初始的编辑和排版工作。这为本书的编写减轻了很大的负担,并最终促使本书问世。谢谢Baron!你是我真正的朋友。
Lorne Schell是一位真正的文艺复兴式的人物——软件工程师、音乐家、艺术家。本书的封面就是出自他的聪慧之手。Vitruvian(维特鲁威风格的)Elf与本书的描述艺术性的重合是多么令人惊喜!非常感谢你的才华,以及为此付出的时间和精力。
Chad Thunberg是我在Leviathan安全集团工作时的老板,他为我编写本书提供了所需要的资源和支持。非常感谢!
感谢Efnet网站所有在#bitlackeys
上的朋友的友谊和支持!
Lubomir Rintel是一名系统程序员,生活在捷克的布尔诺市。他是一位全职的软件开发人员,目前致力于Linux网络工具的开发。除此之外,他还对许多项目做出过贡献,包括Linux内核和Fedora发行版。活跃在开源软件社区多年之后,他懂得一本好书涵盖的主题要比参考手册更加广泛。他相信本书就是这样,希望你也能够像他一样喜欢这本书。另外,他还喜欢食蚁兽。
截至2015年11月,Kumar Sumeet在IT安全方面已经有4年多的研究经验了,在此期间,他开创了黑客和间谍工具的前沿。他拥有伦敦大学皇家霍洛威分校的信息安全硕士学位,最近的重点研究领域是检测网络异常和抵御威胁的机器学习技术。
Sumeet目前是Riversafe公司的一名安全顾问。Riversafe是伦敦的一家网络安全和IT数据管理咨询公司,专注于一些尖端的安全技术。该公司也是2015年在EMEA地区的Splunk Professional Services的合作伙伴。他们已经完成了涉及许多领域(包括电信、银行和金融市场、能源和航空局)的大型项目。
Sumeet也是Penetration Testing Using Raspberry Pi(Packt Publishing出版)一书的技术审稿人。
有关他的项目和研究的更多详细信息,可以访问他的网站https://krsumeet.com
,或者扫描右侧的二维码。
你也可以通过电子邮件contact@krsumeet.com
联系他。
Heron Yang一直致力于创造人们真正想要的东西。他在高中时就建立了这样坚定的信仰。随后他在台湾交通大学和卡内基梅隆大学专注于计算机科学的研究。在过去几年,他专注于在人和满足用户需求之间建立联系,致力于开发初创企业创意原型、新应用或者网站、学习笔记、出书、写博客等。
感谢Packt给我这个机会参与本书的创作过程,并感谢Judie Jose在本书的创作过程中给我的很多帮助。此外,感谢我经历过的所有挑战,这让我成为一个更好的人。本书深入二进制逆向的诸多细节,对于那些关心底层机制的人来说会是很好的资料。大家可通过heron.yang.tw@gmail.com
或者http://heron.me
跟我打招呼或讨论图书内容。
软件工程是创建能够在微处理器上存在、运行和发挥作用的造物行为。我们称这种造物为程序。逆向工程是发现程序如何运行和发挥作用的行为,进一步讲,就是使用反编译器和逆向工具进行组合,并依靠我们的专业技能来控制要进行反编译的目标程序,来理解、解析或者修改程序的行为。我们需要理解二进制格式、内存布局和给定处理器的指令集的复杂性,才能控制微处理器上某个程序的生命周期。逆向工程师是掌握了二进制领域相关知识的技术人员。本书将教会你成为一名Linux二进制黑客所需要的合理的课程、洞察力和相关任务。当一个人自称逆向工程师的时候,他自己其实已经超出了工程师的水平。一个真正的黑客不仅可以编写代码,还可以解析代码,反编译二进制文件和内存段,他追求的是修改软件程序的内部工作原理。这就是反编译工程师的动力。
从专业或者兴趣爱好的角度来看,我都会在计算机安全领域(无论是漏洞分析、恶意软件分析、防病毒软件、rootkit检测,还是病毒设计)使用自己在逆向工程方面的技能。本书的大部分内容专注于计算机安全方面。我们会分析内存转储、进程镜像重建,并对二进制分析更深奥的领域进行探索,包括Linux病毒感染和二进制取证分析。我们将会解析被恶意软件感染的二进制文件,还会感染运行中的进程。本书旨在解释Linux逆向工程所必需的组件,因此我们会深入学习ELF(可执行文件和链接格式)。ELF是Linux中可执行文件、共享库、核心转储文件和目标文件的二进制格式。本书最重要的一个方面是针对ELF二进制格式的结构复杂性给出了深入的分析。ELF节、段、动态链接等这些概念都是非常重要的,也是逆向工程方面相关知识的比较有意思的分支。我们将会深入探索ELF二进制攻击,并了解如何将这些技能应用到更广泛的工作中。
本书的目标是让读者成为对Linux二进制攻防有扎实基础的少数人之一,这将会为打开创新性研究的大门提供一个非常广泛的主题,并将读者带领到Linux操作系统高级黑客技术的前沿。你将掌握Linux二进制修补、病毒工程化/分析、内核取证分析和ELF二进制格式这一套宝贵的知识体系。读者也会对程序执行和动态链接有更深入的了解,对二进制保护和调试的内部原理有更深入的理解。
我是一名计算机安全研究员、软件工程师,也是一名黑客。本书只是有组织地对我所做过的研究进行了文档性描述,也是对已经做出研究结果的一些基础知识的描述。
本书所涵盖的很多知识都无法在互联网上找到。本书试图将一些相关联的主题集中在一起,以便作为Linux二进制和内存攻击这一主题的入门手册和参考。虽然不是非常完善,不过也涵盖了入门需要的很多核心信息。
第1章,Linux环境和相关工具,简要介绍了Linux环境和相关的工具,在整本书中都会用到。
第2章,ELF二进制格式,帮助读者了解ELF二进制格式每个主要的组件,在Linux和大多数类UNIX系统上都会用到。
第3章,Linux进程追踪,教会读者使用ptrace系统调用读写进程内存并注入代码。
第4章,ELF病毒技术——Linux/UNIX病毒,将会介绍Linux病毒的过去、现在和将来,以及病毒的工程化和围绕病毒进行的相关研究。
第5章,Linux二进制保护,解释ELF二进制保护的基本原理。
第6章,Linux下的ELF二进制取证分析,通过解析ELF目标文件来研究病毒、后门和可疑的代码注入。
第7章,进程内存取证分析,将会介绍如何解析进程的地址空间,以研究内存中的恶意软件、后门和可疑的代码注入。
第8章,ECFS——扩展核心文件快照技术,是对ECFS这一用于深入进程内存取证分析的新开源产品的介绍。
第9章,Linux /proc/kcore分析,介绍了如何使用/proc/kcore进行内存分析来检测Linux内核中的恶意软件。
阅读本书的先决条件如下:假定读者具有Linux命令行相关的操作知识,对C语言编程技巧有一定的理解,对x86汇编语言知识有基本的掌握(不是必需,但会有很大的帮助)。有句话说得好:“如果你可以读懂汇编语言,那么一切都是开源的”。
如果你是一名软件工程师或者逆向工程师,想学习Linux二进制分析相关的更多知识,本书将会为你提供在安全、取证分析和防病毒领域进行二进制分析所需要用到的一切知识。假如你是一位安全技术领域的爱好者或者是一名系统工程师,并且有C语言编程和Linux命令行相关的经验,这本书将非常适合你。
本章将集中介绍Linux环境,因为这将贯穿整本书的始终。本书的重点是对Linux二进制进行分析,那么利用好Linux自带的一些通用的本地环境工具将会对Linux二进制分析非常有帮助。Linux自带了应用普遍的binutils工具,该工具也可以在网站http://www.gnu.org/software/binutils/
中找到,里面包含了一些用于二进制分析和破解的工具。本书不会介绍二进制逆向工程的通用软件IDA Pro,但还是鼓励读者使用它。不过,在本书中不会使用IDA。然而,通过本书的学习,你可以利用现有的环境对任何Linux系统进行二进制破解。由此,便可以欣赏到作为一个真正的黑客可以利用许多免费工具的Linux环境之美。在本书中,我们将会展示各种工具的使用,随着每个章节的推进,也会不断回顾这些工具的使用方法。现在我们将本章作为参考章节,介绍Linux环境下的相关工具和技巧。如果你已经非常熟悉Linux环境以及反编译、调试、转换ELF文件的工具,可以跳过本章。
在本书中将用到许多公开发布的免费工具。本节内容将会对其中某些工具进行概要阐述。
GNU调试器(GDB)不仅可以用来调试有bug的应用程序,也可以用来研究甚至改变一个程序的控制流,还可以用来修改代码、寄存器和数据结构。对于一个致力于寻找软件漏洞或者破解一个内部非常复杂的病毒的黑客来讲,这些都是非常常见的工作。GDB主要用于分析ELF二进制文件和Linux进程,是Linux黑客的必备工具,在本书中我们也会在各种不同的例子中使用到GDB。
object dump(objdump)是一种对代码进行快速反编译的简洁方案,在反编译简单的、未被篡改的二进制文件时非常有用,但是要进行任何真正有挑战性的反编译任务,特别是针对恶意软件时,objdump就显示出了它的局限性。其最主要的一个缺陷就是需要依赖ELF节头,并且不会进行控制流分析,这极大地降低了objdump的健壮性。如果要反编译的文件没有节头,那么使用objdump的后果就是无法正确地反编译二进制文件中的代码,甚至都不能打开二进制文件。不过,对于一些比较平常的任务,如反编译未被加固、精简(stripped)或者以任何方式混淆的普通二进制文件,objdump已经足够了。objdump可以读取所有常用的ELF类型的文件。下面是关于objdump使用方法的一些常见例子。
objdump –D <elf_object>
objdump –d <elf_object>
objdump –tT <elf_object>
在第2章介绍ELF
二进制格式时,我们将更加深入地介绍objdump
和其他相关工具。
object copy(objcopy)是一款非常强大的小工具,很难用一句话对其进行概述。推荐读者参考objcopy
的使用手册,里面描述得非常详细。虽然objcopy
的某些特征只针对特定的ELF
目标文件,但是,它还可以用来分析和修改任意类型的ELF
目标文件,还可以修改ELF节,
或将ELF
节复制到ELF
二进制中(或从ELF
二进制中复制ELF
节)。
要将.data
节从一个ELF
目标文件复制到另一个文件中,可以使用下面的命令:
objcopy –only-section=.data <infile> <outfile>
objcopy
工具会在本书的后续内容中用到。现在只要记住有这样一个工具,并且知道这是对Linux二进制黑客来说非常有用的一个工具就可以了。
system call trace(strace,系统调用追踪)是基于ptrace(2)
系统调用的一款工具,strace
通过在一个循环中使用PTRACE_SYSCALL
请求来显示运行中程序的系统调用(也称为syscalls)活动相关的信息以及程序执行中捕捉到的信号量。strace
在调试过程中非常有用,也可以用来收集运行时系统调用相关的信息。
使用strace
命令来跟踪一个基本的程序:
strace /bin/ls –o ls.out
使用strace
命令附加到一个现存的进程上:
strace –p <pid> -o daemon.out
原始输出将会显示每个系统调用的文件描述编号,系统调用会将文件描述符作为参数,如下所示:
SYS_read(3, buf, sizeof(buf));
如果想查看读入到文件描述符3中的所有数据,可以运行下面的命令:
strace –e read=3 /bin/ls
也可以使用–e write=fd
命令查看写入的数据。strace
是一个非常有用的小工具,会在很多地方用到。
library trace(ltrace,库追踪)是另外一个简洁的小工具,与strace
非常类似。ltrace会解析共享库,即一个程序的链接信息,并打印出用到的库函数。
除了可以查看库函数调用之外,还可以使用-S
标记查看系统调用。ltrace
命令通过解析可执行文件的动态段,并打印出共享库和静态库的实际符号和函数,来提供更细粒度的信息:
ltrace <program> -o program.out
function trace(ftrace,函数追踪)是我自己设计的一个工具。ftrace的功能与ltrace
类似,但还可以显示出二进制文件本身的函数调用。我没有找到现成的实现这个功能的Linux工具,于是就决定自己编码实现。这个工具可以在网站https://github.com/elfmaster/ftrace
找到。下一章会对这个工具的使用进行介绍。
readelf
命令是一个非常有用的解析ELF
二进制文件的工具。在进行反编译之前,需要收集目标文件相关的信息,该命令能够提供收集信息所需要的特定于ELF
的所有数据。在本书中,我们将会使用readelf
命令收集符号、段、节、重定向入口、数据动态链接等相关信息。readelf
命令是分析ELF
二进制文件的利器。第 2 章将对该命令进行更深入的介绍,下面是几个常用的标记。
readelf –S <object>
readelf –l <object>
readelf -s <object>
readelf –h <object>
readelf –r <object>
readelf –d <object>
ERESI工程(http://www.eresi-project.org
)中包含着许多Linux二进制黑客梦寐以求的工具。令人遗憾的是,其中有些工具没有持续更新,有的与64位Linux不适配。ERESI工程支持许多的体系结构,无疑是迄今为止最具创新性的破解ELF二进制文件的工具集合。由于我个人不太熟悉ERESI工程中工具的用法,并且其中有些不再更新,因此在本书中就不再对该工程进行更深入的探讨了。不过,有两篇Phrack的文章能够说明ERESI工具的创新和强大的特性:
http://www.phrack.org/archives/issues/61/8.txt
)http://www.phrack.org/archives/issues/63/9.txt
)Linux有许多文件、设备,还有/proc
入口,它们对狂热的黑客还有反编译工程师来说都非常有用。在本书中,我们将会展示其中许多有用的文件。下面介绍本书中常用的一些文件。
/proc/<pid>/map
文件保存了一个进程镜像的布局,通过展现每个内存映射来实现,展现的内容包括可执行文件、共享库、栈、堆和VDSO等。这个文件对于快速解析一个进程的地址空间分布是至关重要的。在本书中会多次用到该文件。
/proc/kcore
是proc
文件系统的一项,是Linux内核的动态核心文件。也就是说,它是以ELF核心文件的形式所展现出来的原生内存转储,GDB可以使用/proc/kcore
来对内核进行调试和分析。第9章会更深入地介绍/proc/kcore。
这个文件在几乎所有的Linux发行版中都有,对内核黑客来说是非常有用的一个文件,包含了整个内核的所有符号。
kallsyms
与System.map
类似,区别就是kallsyms是内核所属的/proc
的一个入口并且可以动态更新。如果安装了新的LKM(Linux Kernel Module),符号会自动添加到/proc/kallsyms
中。/proc/kallsyms
包含了内核中绝大部分的符号,如果在CONFIG_KALLSYMS_ALL
内核配置中指明,则可以包含内核中全部的符号。
iomem
是一个非常有用的proc入口,与/proc/<pid>/maps
类似,不过它是跟系统内存相关的。例如,如果想知道内核的text段所映射的物理内存位置,可以搜索Kernel
字符串,然后就可以查看 code/text
段、data
段和bss
段的相关内容:
$ grep Kernel /proc/iomem
01000000-016d9b27 : Kernel code
016d9b28-01ceeebf : Kernel data
01df0000-01f26fff : Kernel bss
extended core file snapshot(ECFS,扩展核心文件快照)是一项特殊的核心转储技术,专门为进程镜像的高级取证分析所设计。这个软件的代码可以在https://github.com/elfmaster/ecfs
看到。第8章将会单独介绍ECFS及其使用方法。如果你已经进入到了高级内存取证分析阶段,你会非常想关注这一部分内容。
动态加载器/链接器以及链接的概念,在程序链接、执行的过程中都是避不开的基本组成部分。在本书中,你还会学到更多相关的概念。在Linux中,有许多可以代替动态链接器的方法可供二进制黑客使用。随着本书的深入,你会开始理解链接、重定向和动态加载(程序解释器)的过程。下面是几个很有用处的链接器相关的属性,在本书中将会用到。
LD_PRELOAD
环境变量可以设置成一个指定库的路径,动态链接时可以比其他库有更高的优先级。这就允许预加载库中的函数和符号能够覆盖掉后续链接的库中的函数和符号。这在本质上允许你通过重定向共享库函数来进行运行时修复。在后续的章节中,这项技术可以用来绕过反调试代码,也可以用作用户级rootkit。
该环境变量能够通知程序加载器来展示程序运行时的辅助向量。辅助向量是放在程序栈(通过内核的ELF常规加载方式)上的信息,附带了传递给动态链接器的程序相关的特定信息。第3章将会对此进行进一步验证,不过这些信息对于反编译和调试来说非常有用。例如,要想获取进程镜像VDSO页的内存地址(也可以使用maps
文件获取,之前介绍过),就需要查询AT_SYSINFO
。
下面是一个带有LD_SHOW_AUXV
辅助向量的例子:
$ LD_SHOW_AUXV=1 whoami
AT_SYSINFO: 0xb7779414
AT_SYSINFO_EHDR: 0xb7779000
AT_HWCAP: fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov
pat pse36 clflush mmx fxsr sse sse2
AT_PAGESZ: 4096
AT_CLKTCK: 100
AT_PHDR: 0x8048034
AT_PHENT: 32
AT_PHNUM: 9
AT_BASE: 0xb777a000
AT_FLAGS: 0x0
AT_ENTRY: 0x8048eb8
AT_UID: 1000
AT_EUID: 1000
AT_GID: 1000
AT_EGID: 1000
AT_SECURE: 0
AT_RANDOM: 0xbfb4ca2b
AT_EXECFN: /usr/bin/whoami
AT_PLATFORM: i686
elfmaster
第2章将会进一步介绍辅助向量。
链接器脚本是我们的一个兴趣点,因为链接器脚本是由链接器解释的,把程序划分成相应的节、内存和符号。默认的链接器脚本可以使用ld–verbose
查看。
ld
链接器程序有其自己解释的一套语言,当有文件(如可重定位的目标文件、共享库和头文件)输入时,ld
链接器程序会用自己的语言来决定输出文件(如可执行程序)的组织方式。例如,如果输出的是一个ELF可执行文件,链接器脚本能够决定该输出文件的布局,以及每个段里面包含哪些节。另外举一个例子:.bss
节总是放在data
段的末尾,这就是链接器脚本决定的。你可能很好奇,这为什么就成了我们的一个兴趣点呢?一方面,对编译时链接过程有一定深入的了解是很重要的。gcc
依赖于链接器和其他程序来完成编译的任务,在某些情况下,能够控制可执行文件的布局相当重要。ld
命令语言是一门相当深入的语言,尽管它超出了本书的范围,但是非常值得探究。另一方面,在对可执行文件进行反编译时,普通段地址或者文件的其他部分有时候会被修改,这就表明引入了一个自定义的链接器脚本。gcc
通过使用–T
标志来指定链接器脚本。第5章会介绍一个使用链接器脚本的例子。
本章仅介绍了Linux环境和工具相关的一些基本概念,在后续的每个章节中都会经常用到。二进制分析主要是了解一些可用的工具和资源并进行相关的整合。目前,我们只简要介绍了这部分工具,在接下来的章节中,随着对Linux二进制破解这个广阔领域进行更进一步的探索,我们会有机会对每一个工具进行深入介绍。下一章将会对 ELF 二进制格式进行更深入的探索,也会涉及其他一些有趣的概念,如动态链接、重定位、符号和节(section)等。
要反编译Linux二进制文件,首先需要理解二进制格式本身。ELF目前已经成为UNIX和类UNIX操作系统的标准二进制格式。在Linux、BSD变体以及其他操作系统中,ELF格式可用于可执行文件、共享库、目标文件、coredump文件,甚至内核引导镜像文件。因此,对于那些想要更好地理解反编译、二进制攻破和程序执行的人来说,学习ELF至关重要。要想学习ELF这样的二进制格式,可不是一蹴而就的,需要随着对不同组件的学习来逐步掌握并加以实际应用。要达到熟练应用的效果,还需要实际的动手经验。ELF二进制格式比较复杂,也很枯燥,不过可以在进行反编译或者编程任务中应用ELF二进制格式相关的编程知识,通过这样的方式学习,倒是一种很有趣的尝试。ELF跟程序加载、动态链接、符号表查找和许多其他精心设计的组件一样,都是计算机科学非常重要的一部分。
本章也许会是本书最重要的一章。在本章中,读者将会更加深入地了解程序如何映射到磁盘并加载到内存中。程序执行的内部逻辑比较复杂,对于有抱负的二进制黑客、逆向工程师或者普通的程序员来说,对二进制格式的理解将会是非常宝贵的知识财富。在Linux中,程序就是以ELF二进制的格式执行的。
像许多Linux反编译工程师一样,我也是先了解ELF的说明规范,然后把学到的内容以一种创造性的方式进行应用,通过这样的方式来进行ELF的学习。在本书中,读者将会接触到ELF相关的许多方面的知识,并了解ELF是如何跟病毒、进程内存取证、二进制保护、rootkit等相关联的。
在本章中,会涉及以下ELF相关的概念:
一个ELF文件可以被标记为以下几种类型之一。
ET_NONE
:未知类型。这个标记表明文件类型不确定,或者还未定义。ET_REL
:重定位文件。ELF类型标记为relocatable意味着该文件被标记为了一段可重定位的代码,有时也称为目标文件。可重定位目标文件通常是还未被链接到可执行程序的一段位置独立的代码(position independent code)。在编译完代码之后通常可以看到一个.o
格式的文件,这种文件包含了创建可执行文件所需要的代码和数据。ET_EXEC
:可执行文件。ELF类型为executable,表明这个文件被标记为可执行文件。这种类型的文件也称为程序,是一个进程开始执行的入口。ET_DYN
:共享目标文件。ELF类型为dynamic,意味着该文件被标记为了一个动态的可链接的目标文件,也称为共享库。这类共享库会在程序运行时被装载并链接到程序的进程镜像中。ET_CORE
:核心文件。在程序崩溃或者进程传递了一个SIGSEGV信号(分段违规)时,会在核心文件中记录整个进程的镜像信息。可以使用GDB读取这类文件来辅助调试并查找程序崩溃的原因。使用readelf–h
命令查看ELF文件,可以看到原始的ELF文件头。ELF文件头从文件的0偏移量开始,是除了文件头之后剩余部分文件的一个映射。文件头主要标记了ELF类型、结构和程序开始执行的入口地址,并提供了其他ELF头(节头和程序头)的偏移量,稍后会细讲。一旦理解了节头和程序头的含义,就容易理解文件头了。通过查看Linux的ELF(5)手册,可以了解ELF头部的结构:
#define EI_NIDENT 16
typedef struct{
unsigned char e_ident[EI_NIDENT];
uint16_t e_type;
uint16_t e_machine;
uint32_t e_version;
ElfN_Addr e_entry;
ElfN_Off e_phoff;
ElfN_Off e_shoff;
uint32_t e_flags;
uint16_t e_ehsize;
uint16_t e_phentsize;
uint16_t e_phnum;
uint16_t e_shentsize;
uint16_t e_shnum;
uint16_t e_shstrndx;
}ElfN_Ehdr;
在本章的后续内容中,我们会用一个简单的C程序来展示如何利用上面结构中的字段映射一个ELF文件。我们先继续介绍现存的其他类型的ELF头。
ELF程序头是对二进制文件中段的描述,是程序装载必需的一部分。段(segment)是在内核装载时被解析的,描述了磁盘上可执行文件的内存布局以及如何映射到内存中。可以通过引用原始ELF头中名为e_phoff
(程序头表偏移量)的偏移量来得到程序头表,如前面ElfN_Ehdr
结构中所示。
下面讨论5种常见的程序头类型。程序头描述了可执行文件(包括共享库)中的段及其类型(为哪种类型的数据或代码而保留的段)。首先,我们来看一下Elf32_Phdr
的结构,它构成了32位ELF可执行文件程序头表的一个程序头条目。
![]()
在本书的后续内容中有时还会引用Phdr的程序头结构。
下面是Elf32_Phdr
结构体:
typedef struct {
uint32_t p_type; (segment type)
Elf32_Off p_offset; (segment offset)
Elf32_Addr p_vaddr; (segment virtual address)
Elf32_Addr p_paddr; (segment physical address)
uint32_t p_filesz; (size of segment in the file)
uint32_t p_memsz; (size of segment in memory)
uint32_t p_flags; (segment flags, I.E execute|read|read)
uint32_t p_align; (segment alignment in memory)
} Elf32_Phdr;
一个可执行文件至少有一个PT_LOAD
类型的段。这类程序头描述的是可装载的段,也就是说,这种类型的段将被装载或者映射到内存中。
例如,一个需要动态链接的ELF可执行文件通常包含以下两个可装载的段(类型为PT_LOAD
):
上面的两个段将会被映射到内存中,并根据p_align
中存放的值在内存中对齐。建议读者阅读一下Linux的ELF手册,以便理解Phdr结构体中所有变量的含义,这些变量描述了段在文件和内存中的布局。
程序头主要描述了程序执行时在内存中的布局。本章稍后会使用Phdr来说明什么是程序头,以及如何在反编译软件中使用程序头。
![]()
通常将text段(也称代码段)的权限设置为
PF_X | PF_R
(读和可执行)。通常将data段的权限设置为
PF_W | PF_R
(读和写)。感染了千面人病毒(polymorphic virus)文件的text段或data段的权限可能会被修改,如通过在程序头的段标记(
p_flags
)处增加PF_W
标记来修改text段的权限。
动态段是动态链接可执行文件所特有的,包含了动态链接器所必需的一些信息。在动态段中包含了一些标记值和指针,包括但不限于以下内容:
的地址
——ELF动态链接部分(2.6节)会讨论;表2-1是完整的标记名列表。
表2-1
标 记 名 |
描 述 |
---|---|
|
符号散列表的地址 |
|
字符串表的地址 |
|
符号表地址 |
|
相对地址重定位表的地址 |
|
Rela表的字节大小 |
|
Rela表条目的字节大小 |
|
字符串表的字节大小 |
|
符号表条目的字节大小 |
|
初始化函数的地址 |
|
终止函数的地址 |
|
共享目标文件名的字符串表偏移量 |
|
库搜索路径的字符串表偏移量 |
|
修改链接器,在可执行文件之前的共享目标文件中搜索符号 |
|
Rel relocs表的地址 |
|
Rel表的字节大小 |
|
Rel表条目的字节大小 |
|
PLT引用的reloc类型(Rela或Rel) |
|
还未进行定义,为调试保留 |
|
缺少此项表明重定位只能应用于可写段 |
|
仅用于PLT的重定位条目地址 |
|
指示动态链接器在将控制权交给可执行文件之前处理所有的重定位 |
|
库搜索路径的字符串表偏移量 |
动态段包含了一些结构体,在这些结构体中存放着与动态链接相关的信息。d_tag
成员变量控制着d_un
的含义。
32位ELF文件的动态段结构体如下:
typedef struct{
Elf32_Sword d_tag;
union{
Elf32_Word d_val;
Elf32_Addr d_ptr;
} d_un;
} Elf32_Dyn;
extern Elf32_Dyn _DYNAMIC[];
本章稍后会继续对动态链接进行更深入的探讨。
PT_NOTE
类型的段可能保存了与特定供应商或者系统相关的附加信息。下面是标准ELF规范中对PT_NOTE
的定义:
有时供应商或者系统构建者需要在目标文件上标记特定的信息,以便于其他程序对一致性、兼容性等进行检查。SHT_NOTE
类型的节(section)和PT_NOTE
类型的程序头元素就可以用于这一目的。节或者程序头元素中的备注信息可以有任意数量的条目,每个条目都是一个4字节的目标处理器格式的数组。下面的标签可以解释备注信息的组织结构,不过这些标签并不是规范中的内容。
比较有意思的一点:事实上,这一段只保存了操作系统的规范信息,在可执行文件运行时是不需要这个段的(因为系统会假设一个可执行文件是本地的),这个段成了很容易被病毒感染的一个地方。由于篇幅限制,就不具体介绍了。更多NOTE段病毒感染相关的信息可以从http://vxheavens.com/ lib/vhe06.html
了解到。
PT_INTERP段只将位置和大小信息存放在一个以null为终止符的字符串中,是对程序解释器位置的描述。例如,/lib/linux-ld.so.2
一般是指动态链接器的位置,也即程序解释器的位置。
PT_PHDR段保存了程序头表本身的位置和大小。Phdr表保存了所有的Phdr对文件(以及内存镜像)中段的描述信息。
可以查阅ELF(5)手册或者ELF规范文档来查看所有的Phdr类型。我们已经介绍了一些最常用的Phdr类型,其中一些对程序执行至关重要,有一些在反编译时会经常用到。
可以使用readelf–l <filename>
命令查看文件的Phdr表:
Elf file type is EXEC (Executable file)
Entry point 0x8049a30
There are 9 program headers, starting at offset 52
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
PHDR 0x000034 0x08048034 0x08048034 0x00120 0x00120 R E 0x4
INTERP 0x000154 0x08048154 0x08048154 0x00013 0x00013 R 0x1
[Requesting program interpreter: /lib/ld-linux.so.2]
LOAD 0x000000 0x08048000 0x08048000 0x1622c 0x1622c R E 0x1000
LOAD 0x016ef8 0x0805fef8 0x0805fef8 0x003c8 0x00fe8 RW 0x1000
DYNAMIC 0x016f0c 0x0805ff0c 0x0805ff0c 0x000e0 0x000e0 RW 0x4
NOTE 0x000168 0x08048168 0x08048168 0x00044 0x00044 R 0x4
GNU_EH_FRAME 0x016104 0x0805e104 0x0805e104 0x0002c 0x0002c R 0x4
GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x4
GNU_RELRO 0x016ef8 0x0805fef8 0x0805fef8 0x00108 0x00108 R 0x1
从上面的片段中,可以看到可执行程序的入口点,还有刚刚讨论的不同段的类型。注意看中间部分的PT_LOAD
段,从最左边的偏移量到最右边的权限标识和对齐标识。
text段是可读可执行的,data段是可读可写的,这两个段都有0x1000
(4096)的对齐标识,刚好是32位可执行文件一页的大小,该标识用于在程序装载时对齐。
前面介绍了程序头相关的内容,接下来对节头(section header)相关的内容进行介绍。我想在此指出段(segment)和节(section)的区别。我经常听到有人把段和节叫混了。节,不是段。段是程序执行的必要组成部分,在每个段中,会有代码或者数据被划分为不同的节。节头表是对这些节的位置和大小的描述,主要用于链接和调试。节头对于程序的执行来说不是必需的,没有节头表,程序仍可以正常执行,因为节头表没有对程序的内存布局进行描述,对程序内存布局的描述是程序头表的任务。节头是对程序头的补充。readelf –l
命令可以显示一个段对应有哪些节,可以很直观地看到节和段之间的关系。
如果二进制文件中缺少节头,并不意味着节就不存在。只是没有办法通过节头来引用节,对于调试器或者反编译程序来说,只是可以参考的信息变少了而已。
每一个节都保存了某种类型的代码或者数据。数据可以是程序中的全局变量,也可以是链接器所需要的动态链接信息。正如前面提到的,每个ELF目标文件都有节,但是不一定有节头,尤其是有人故意将节头从节头表中删除了之后。当然,默认是有节头的。
通常情况下,这是由于可执行文件被篡改导致的(如去掉节头来增加调试的难度)。GNU的binutils工具,像objcopy
、objdump
,还有gdb
等,都需要依赖节头定位到存储符号数据的节来获取符号信息。如果没有节头,gdb
和objdump
这样的工具几乎无用武之地。
节头便于我们更细粒度地检查一个ELF目标文件的某部分或者某节。事实上,有了节头,一些需要使用节头的工具,如objdump等,就能为逆向工程带来很多便利。如果去掉了节头表,就无法获取像.dynsym
这样的节,而在.dynsym
节中包含了描述函数名和偏移量/地址的导入/导出符号。
![]()
即便从一个可执行文件中去掉了节头表,一个中级逆向工程师也可以从特定的程序头中获取相关信息来重构节头表(甚至能够重构部分符号表),因为一个程序或者共享库中一定是存在程序头的。之前讲过动态段以及各种保存了符号表和重定位入口信息的DT_TAG,可以利用这一部分来重构可执行文件的其余部分。在第8章会有详细介绍。
下面是一个32位ELF节头的结构:
typedef struct {
uint32_t sh_name; // offset into shdr string table for shdr name
uint32_t sh_type; // shdr type I.E SHT_PROGBITS
uint32_t sh_flags; // shdr flags I.E SHT_WRITE|SHT_ALLOC
Elf32_Addr sh_addr; // address of where section begins
Elf32_Off sh_offset; // offset of shdr from beginning of file
uint32_t sh_size; // size that section takes up on disk
uint32_t sh_link; // points to another section
uint32_t sh_info; // interpretation depends on section type
uint32_t sh_addralign; // alignment for address of section
uint32_t sh_entsize; // size of each certain entries that may be in
section
} Elf32_Shdr;
接下来介绍一些比较重要的节和节类型,再次强调,建议查阅ELF(5)手册和ELF官方规范文档,来查看更多节相关的信息。
.text
节是保存了程序代码指令的代码节。一段可执行程序,如果存在Phdr,.text
节就会存在于text
段中。由于.text
节保存了程序代码,因此节的类型为SHT_PROGBITS
。
.rodata
节保存了只读的数据,如一行C语言代码中的字符串。下面这条命令就是存放在.rodata
节中的:
printf("Hello World!\n");
因为.rodata节是只读的,所以只能存在于一个可执行文件的只读段中。因此,只能在text段(不是data段)中找到.rodata
节。由于.rodata
节是只读的,因此节类型为SHT_PROGBITS
。
本章稍后会对过程链接表(Procedure Linkage Table,PLT)进行详细介绍。.plt节中包含了动态链接器调用从共享库导入的函数所必需的相关代码。由于其存在于text段中,同样保存了代码,因此节类型为SHT_PROGBITS
。
不要将.data节和data
段混淆了,.data节存在于data段中,保存了初始化的全局变量等数据。由于其保存了程序的变量数据,因此类型被标记为SHT_PROGBITS
。
.bss
节保存了未进行初始化的全局数据,是data段的一部分,占用空间不超过4字节,仅表示这个节本身的空间。程序加载时数据被初始化为0,在程序执行期间可以进行赋值。由于.bss
节未保存实际的数据,因此节类型为SHT_NOBITS
。
.got节保存了全局偏移表。.got节和.plt节一起提供了对导入的共享库函数的访问入口,由动态链接器在运行时进行修改。如果攻击者获得了堆或者.bss
漏洞的一个指针大小的写原语,就可以对该节任意进行修改。我们将在本章的ELF动态链接一节(2.6节)对此进行讨论。.got.plt节跟程序执行有关,因此节类型被标记为SHT_PROGBITS
。
.dynsym
节保存了从共享库导入的动态符号信息,该节保存在text段中,节类型被标记为SHT_DYNSYM
。
.dynstr
节保存了动态符号字符串表,表中存放了一系列字符串,这些字符串代表了符号的名称,以空字符作为终止符。
重定位节保存了重定位相关的信息,这些信息描述了如何在链接或者运行时,对ELF目标文件的某部分内容或者进程镜像进行补充或修改。在本章的ELF重定位一节(2.5节)会深入讨论。重定位节保存了重定位相关的数据,因此节类型被标记为SHT_REL
。
.hash
节有时也称为.gnu.hash
,保存了一个用于查找符号的散列表。下面的散列算法是用来在Linux ELF文件中查找符号名的:
uint32.t
dl_new_hash(const char *s)
{
uint32_t h = 5381;
for(unsigned char c = *s; c != '\0'; c = *++s)
h = h * 33 + c;
return h;
}
![]()
h = h * 33 + c
也常写为h = ((h << 5) + h) + c
.symtab
节保存了ElfN_Sym
类型的符号信息,本章将在ELF符号和重定位部分(2.4节和2.5节)详细介绍。.symtab
节保存了符号信息,因此节类型被标记为SHT_SYMTAB
。
.strtab
节保存的是符号字符串表,表中的内容会被.symtab
的ElfN_Sym
结构中的st_name
条目引用。由于其保存了字符串表,因此节类型被标记为SHT_STRTAB
。
.shstrtab
节保存节头字符串表,该表是一个以空字符终止的字符串的集合,字符串保存了每个节的节名,如.text
、.data
等。有一个名为e_shstrndx
的ELF文件头条目会指向.shstrtab
节,e_shstrndx
中保存了.shstrtab
的偏移量。由于其保存了字符串表,因此节类型被标记为SHT_STRTAB
。
.ctors
(构造器)和.dtors
(析构器)这两个节保存了指向构造函数和析构函数的函数指针,构造函数是在main
函数执行之前需要执行的代码,析构函数是在main
函数之后需要执行的代码。
![]()
黑客或病毒制造者有时会利用构造函数属性实现一个函数,实现类似
PTRACE_TRACEME
这样的反调试功能,这样进程就会追踪自身,调试器就无法附加到这个进程上。通过这种方式,在程序进入main()
函数之前就会先执行反调试的代码。
还有许多其他的节名称和节类型,不过之前介绍的内容已经覆盖了动态链接文件中会涉及的大部分比较重要的节。下面我们可以看到,一个可执行文件是如何使用phdr
和shdr
来进行布局排列的。
text段的布局如下。
[.text]:
程序代码。[.rodata]:
只读数据。[.hash]:
符号散列表。[.dynsym]:
共享目标文件符号数据。[.dynstr]:
共享目标文件符号名称。[.plt]:
过程链接表。[.rel.got]:
G.O.T重定位数据。data段布局如下。
[.data]:
全局的初始化变量。[.dynamic]:
动态链接结构和对象。[.got.plt]:
全局偏移表。[.bss]:
全局未初始化变量。可以使用readelf–S
命令查看ET_REL
文件(目标文件)的节头:
ryan@alchemy:~$ gcc -c test.c
ryan@alchemy:~$ readelf -S test.o
下面是从偏移地址0x124开始的12个节头:
[Nr] Name Type Addr Off
Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000
000000 00 0 0 0
[ 1] .text PROGBITS 00000000 000034
000034 00 AX 0 0 4
[ 2] .rel.text REL 00000000 0003d0
000010 08 10 1 4
[ 3] .data PROGBITS 00000000 000068
000000 00 WA 0 0 4
[ 4] .bss NOBITS 00000000 000068
000000 00 WA 0 0 4
[ 5] .comment PROGBITS 00000000 000068
00002b 01 MS 0 0 1
[ 6] .note.GNU-stack PROGBITS 00000000 000093
000000 00 0 0 1
[ 7] .eh_frame PROGBITS 00000000 000094
000038 00 A 0 0 4
[ 8] .rel.eh_frame REL 00000000 0003e0
000008 08 10 7 4
[ 9] .shstrtab STRTAB 00000000 0000cc
000057 00 0 0 1
[10] .symtab SYMTAB 00000000 000304
0000b0 10 11 8 4
[11] .strtab STRTAB 00000000 0003b4
00001a 00 0 0 1
可重定位文件(类型为ET_REL
的ELF文件)中不存在程序头,因为.o
类型的文件会被链接到可执行文件中,但是不会被直接加载到内存中,所以使用readelf –l test.o
命令不会得到想要的结果。不过Linux中的可加载内核模块(LKM)是个例外,LKM是ET_REL
类型的文件,它会被直接加载进内核的内存中并自动进行重定位。
从上面的节头中可以看到许多介绍过的节类型,但还有一些节类型没有讲过。将test.o
编译到可执行文件中,可以看到节头中新增了一些节,如.got.plt
、.plt
、.dynsym
以及其他与动态链接及运行时重定位相关的节。
ryan@alchemy:~$ gcc evil.o –o evil
ryan@alchemy:~$ readelf –S evil
下面是从偏移位置0x1140开始的30个节头:
[Nr] Name Type Addr Off
Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000
000000 00 0 0 0
[ 1] .interp PROGBITS 08048154 000154
000013 00 A 0 0 1
[ 2] .note.ABI-tag NOTE 08048168 000168
000020 00 A 0 0 4
[ 3] .note.gnu.build-i NOTE 08048188 000188
000024 00 A 0 0 4
[ 4] .gnu.hash GNU_HASH 080481ac 0001ac
000020 04 A 5 0 4
[ 5] .dynsym DYNSYM 080481cc 0001cc
000060 10 A 6 1 4
[ 6] .dynstr STRTAB 0804822c 00022c
000052 00 A 0 0 1
[ 7] .gnu.version VERSYM 0804827e 00027e
00000c 02 A 5 0 2
[ 8] .gnu.version_r VERNEED 0804828c 00028c
000020 00 A 6 1 4
[ 9] .rel.dyn REL 080482ac 0002ac
000008 08 A 5 0 4
[10] .rel.plt REL 080482b4 0002b4
000020 08 A 5 12 4
[11] .init PROGBITS 080482d4 0002d4
00002e 00 AX 0 0 4
[12] .plt PROGBITS 08048310 000310
000050 04 AX 0 0 16
[13] .text PROGBITS 08048360 000360
00019c 00 AX 0 0 16
[14] .fini PROGBITS 080484fc 0004fc
00001a 00 AX 0 0 4
[15] .rodata PROGBITS 08048518 000518
000008 00 A 0 0 4
[16] .eh_frame_hdr PROGBITS 08048520 000520
000034 00 A 0 0 4
[17] .eh_frame PROGBITS 08048554 000554
0000c4 00 A 0 0 4
[18] .ctors PROGBITS 08049f14 000f14
000008 00 WA 0 0 4
[19] .dtors PROGBITS 08049f1c 000f1c
000008 00 WA 0 0 4
[20] .jcr PROGBITS 08049f24 000f24
000004 00 WA 0 0 4
[21] .dynamic DYNAMIC 08049f28 000f28
0000c8 08 WA 6 0 4
[22] .got PROGBITS 08049ff0 000ff0
000004 04 WA 0 0 4
[23] .got.plt PROGBITS 08049ff4 000ff4
00001c 04 WA 0 0 4
[24] .data PROGBITS 0804a010 001010
000008 00 WA 0 0 4
[25] .bss NOBITS 0804a018 001018
000008 00 WA 0 0 4
[26] .comment PROGBITS 00000000 001018
00002a 01 MS 0 0 1
[27] .shstrtab STRTAB 00000000 001042
0000fc 00 0 0 1
[28] .symtab SYMTAB 00000000 0015f0
000420 10 29 45 4
[29] .strtab STRTAB 00000000 001a10
00020d 00 0 0
从上面内容可以看出,增加了一些新的节,值得关注的是与动态链接和构造器相关的节。建议读者练习推断出修改了哪些节、新增了哪些节,以及新增的节用途何在。可以查阅ELF(5)手册或者ELF规范文档。
符号是对某些类型的数据或者代码(如全局变量或函数)的符号引用。例如,printf()
函数会在动态符号表.dynsym
中存有一个指向该函数的符号条目。在大多数共享库和动态链接可执行文件中,存在两个符号表。如前面使用readelf –S
命令输出的内容中,可以看到有两个节:.dynsym
和.symtab
。
.dynsym
保存了引用来自外部文件符号的全局符号,如printf
这样的库函数,.dynsym
保存的符号是.symtab
所保存符号的子集,.symtab
中还保存了可执行文件的本地符号,如全局变量,或者代码中定义的本地函数等。因此,.symtab
保存了所有的符号,而.dynsym
只保存动态/全局符号。
因此,就存在这样一个问题:既然.symtab
中保存了.dynsym
中所有的符号,那么为什么还需要两个符号表呢?使用readelf –S
命令查看可执行文件的输出,可以看到一部分节被标记为了A(ALLOC)、WA(WRITE/ALLOC)或者AX(ALLOC/EXEC)。.dynsym
是被标记了ALLOC的,而.symtab
则没有标记。
ALLOC表示有该标记的节会在运行时分配并装载进入内存,而.symtab
不是在运行时必需的,因此不会被装载到内存中。.dynsym
保存的符号只能在运行时被解析,因此是运行时动态链接器所需要的唯一符号。.dynsym
符号表对于动态链接可执行文件的执行来说是必需的,而.symtab
符号表只是用来进行调试和链接的,有时候为了节省空间,会将.symtab
符号表从生产二进制文件中删掉。
来看一个64位ELF文件符号项的结构:
typedef struct{
uint32_t st_name;
unsigned char st_info;
unsigned char st_other;
uint16_t st_shndx;
Elf64_Addr st_value;
Uint64_t st_size;
} Elf64_Sym;
符号项保存在.symtab
和.dynsym
节中,因此节头项的大小与ElfN_Sym
的大小相等。
st_name
保存了指向符号表中字符串表(位于.dynstr
或者.strtab
)的偏移地址,偏移地址存放着符号的名称,如printf
。
st_value
存放符号的值(可能是地址或者位置偏移量)。
st_size
存放了一个符号的大小,如全局函数指针的大小,在一个32位系统中通常是4字节。
st_other变量定义了符号的可见性。
每个符号表条目的定义都与某些节对应。st_shndx变量保存了相关节头表的索引。
st_info
指定符号类型及绑定属性。可以查阅ELF(5)手册来查看完整的类型以属性列表。符号类型以STT开头,符号绑定以STB开头,下面对几种常见的符号类型和符号绑定进行介绍。
下面是几种符号类型。
STT_NOTYPE
:符号类型未定义。STT_FUNC
:表示该符号与函数或者其他可执行代码关联。STT_OBJECT
:表示该符号与数据目标文件关联。下面是几种符号绑定。
STB_LOCAL
:本地符号在目标文件之外是不可见的,目标文件包含了符号的定义,如一个声明为static的函数。STB_GLOBAL
:全局符号对于所有要合并的目标文件来说都是可见的。一个全局符号在一个文件中进行定义后,另外一个文件可以对这个符号进行引用。STB_WEAK
:与全局绑定类似,不过比STB_GLOBAL的优先级低。被标记为STB_WEAK的符号有可能会被同名的未被标记为STB_WEAK的符号
覆盖。下面是对绑定和类型字段进行打包和解包的宏指令。
ELF32_ST_BIND(info)
或者ELF64_ST_BIND(info)
:从st_info
值中提取出一个绑定。ELF32_ST_TYPE(info)
或者ELF64_ST_TYPE(info)
:从st_info
值中提取类型。ELF32_ST_TYPE(bind,type)
或者ELF64_ST_INFO(bind,type)
: 将一个绑定和类型转换成st_info
值。来看下面源码的符号表:
static inline void foochu()
{ /* Do nothing */ }
void func1()
{ /* Do nothing */ }
_start()
{
func1();
foochu();
}
下面是查看foochu
和func1
函数符号表条目的命令:
ryan@alchemy:~$ readelf –s test | egrep 'foochu|func1'
7: 080480d8 5 FUNC LOCAL DEFAULT 2 foochu
8: 080480dd 5 FUNC GLOBAL DEFAULT 2 func1
可以看到foochu
函数的值为0x80480d8
,是一个有本地符号绑定(STB_LOCAL
)的函数(STT_FUNC
)。前面的内容讲到,本地(LOCAL
)绑定意味着符号在被定义的目标文件之外是不可见的,我们在源码中将foochu
函数用static关键字进行了声明,因此foochu
是本地的。
符号给我们带来了许多便利。符号作为ELF目标文件的一部分,可用来链接、重定位、反汇编和调试。我在2013年设计过一个比较实用的工具ftrace
。与ltrace
和strace
类似,ftrace
可以跟踪二进制文件内部所有的函数调用,也可以显示像jump这样的分支指令。我起初设计ftrace
,是在我工作中没有需要的源码时,用来帮我反编译二进制文件用的。可以把ftrace
看做一个动态分析工具。下面介绍ftrace
的几个功能。我们用下面的源码编译出一个二进制文件:
#include <stdio.h>
int func1(int a, int b, int c)
{
printf("%d %d %d\n", a, b ,c);
}
int main(void)
{
func1(1, 2, 3);
}
现在假设没有上面的源码,如果想知道编译出来的二进制文件的内部逻辑,可以对二进制文件使用ftrace
命令。首先,看一下命令摘要:
ftrace [-p <pid>] [-Sstve] <prog>
用法如下。
[-p]
:根据PID(进程id)追踪。[-t]
:检测函数参数的类型。[-s]
:打印字符串值。[-v]
:显示详细的输出。[-e]
:显示各种ELF信息(符号、依赖)。[-S]
:显示缺失了符号的函数调用。[-C]
:完成控制流分析。下面来试验一下:
ryan@alchemy:~$ ftrace -s test
[+] Function tracing begins here:
PLT_call@0x400420:__libc_start_main()
LOCAL_call@0x4003e0:_init()
(RETURN VALUE) LOCAL_call@0x4003e0: _init() = 0
LOCAL_call@0x40052c:func1(0x1,0x2,0x3) // notice values passed
PLT_call@0x400410:printf("%d %d %d\n") // notice we see string value
1 2 3
(RETURN VALUE) PLT_call@0x400410: printf("%d %d %d\n") = 6
(RETURN VALUE) LOCAL_call@0x40052c: func1(0x1,0x2,0x3) = 6
LOCAL_call@0x400470:deregister_tm_clones()
(RETURN VALUE) LOCAL_call@0x400470: deregister_tm_clones() = 7
聪明的读者可能会问:如果去掉一个二进制文件的符号表,会怎样呢?不错,你可以去掉一个二进制文件的符号表;不过,去掉符号表后,一个动态链接可执行文件会保留.dynsym
,丢弃.symtab
,因此只会显示导入库的符号。
如果一个二进制文件是通过静态编译(gcc -static
)得到的或者没有使用libc
进行链接(gcc -nostdlib
),然后使用strip
命令进行了清理,那么这个二进制文件就不会有符号表,因为动态符号表对该二进制文件来说不是必需的。在ftrace
后面使用-S
标记,将会显示所有的函数调用,即使函数没有对应的符号。加了-S
标记后,会将没有符号对应的函数名以SUB_<address_of_function>
的形式显示,与IDA Pro显示没有符号表引用的函数方式类似。
来看一段非常简单的源码:
int foo(void) {
}
_start()
{
foo();
__asm__("leave");
}
上面的源码调用了foo()
函数后就退出了。使用_start()
而不是main()
是因为我们要用下面的命令进行编译:
gcc -nostdlib test2.c -o test2
gcc
的-nostdlib
标志会命令链接器忽略标准的libc
链接惯例,只编译我们给出的代码。默认的入口是_start()
符号:
ryan@alchemy:~$ ftrace ./test2
[+] Function tracing begins here:
LOCAL_call@0x400144:foo()
(RETURN VALUE) LOCAL_call@0x400144: foo() = 0
Now let's strip the symbol table and run ftrace on it again:
ryan@alchemy:~$ strip test2
ryan@alchemy:~$ ftrace -S test2
[+] Function tracing begins here:
LOCAL_call@0x400144:sub_400144()
(RETURN VALUE) LOCAL_call@0x400144: sub_400144() = 0
注意到foo()
函数被替换成了sub_400144()
,这表示在地址0x400144
处进行了函数调用。如果在删掉符号之前看一下二进制文件test2
,就会发现0x400144
实际上就是foo()
函数的地址:
ryan@alchemy:~$ objdump -d test2
test2: file format elf64-x86-64
Disassembly of section .text:
0000000000400144<foo>:
400144: 55 push %rbp
400145: 48 89 e5 mov %rsp,%rbp
400148: 5d pop %rbp
400149: c3 retq
000000000040014a <_start>:
40014a: 55 push %rbp
40014b: 48 89 e5 mov %rsp,%rbp
40014e: e8 f1 ff ff ff callq 400144 <foo>
400153: c9 leaveq
400154: 5d pop %rbp
400155: c3 retq
为了让读者真正理解符号对逆向工程师的用处(在有符号的前提下),我们来看一下test2
这个二进制文件。在test2
文件中没有符号,不太易读。主要是因为分支指令没有对应的符号名,所以要分析控制流有点复杂,需要更多的注释,跟IDA Pro这样的反编译器类似:
$ objdump -d test2
test2: file format elf64-x86-64
Disassembly of section .text:
0000000000400144 <.text>:
400144: 55 push %rbp
400145: 48 89 e5 mov %rsp,%rbp
400148: 5d pop %rbp
400149: c3 retq
40014a: 55 push %rbp
40014b: 48 89 e5 mov %rsp,%rbp
40014e: e8 f1 ff ff ff callq 0x400144
400153: c9 leaveq
400154: 5d pop %rbp
400155: c3 retq
过程入口是每个函数的起点,因此通过检测过程序言(procedure prologue),可以帮助我们找到一个新函数的起始位置。如果使用了gcc-fomit-frame-pointer
命令进行编译的话,入口就不太好识别了。
本书已经假设读者有了一定的汇编语言知识基础,毕竟本书的着重点不是讲述x86汇编。注意前面提到的过程序言,序言代表函数的开始。过程序言通过备份栈上的基准指针来为每个新调用的函数设置栈帧(stack frame),并在栈指针为本地变量调整空间之前给栈指针赋值(先给栈指针赋值,变量随后压栈,指针随变量压栈进行调整)。首址作为一个固定地址存放在基址寄存器ebp/rbp中,通过首址的正向偏移可以依次访问栈中的变量。
我们已经对符号有了一定了解,接下来需要理解重定位。在下节内容中,我们来看一下符号、重定位和节是如何在ELF格式文件的同一个抽象层次上紧密联系起来的。
从ELF(5)手册中可以看到以下内容:
重定位就是将符号定义和符号引用进行连接的过程。可重定位文件需要包含描述如何修改节内容的相关信息,从而使得可执行文件和共享目标文件能够保存进程的程序镜像所需的正确信息。重定位条目就是我们上面说的相关信息。
我们首先介绍了符号和节相关的内容,因为接下来要讨论的重定位过程需要依赖符号和节。在重定位文件中,重定位记录保存了如何对给定的符号对应代码进行补充的相关信息。重定位实际上是一种给二进制文件打补丁的机制,如果使用了动态链接器,可以使用重定位在内存中打热补丁。用于创建可执行文件和共享库的链接程序/bin/ld
,需要某种类型的元数据来描述如何对特定的指令进行修改。这种元数据就存放在前面提到的重定位记录中。稍后我会通过一个例子来对重定位进行进一步讲解。
假设要将两个目标文件链接到一起产生一个可执行文件。obj1.o
文件中存放了调用函数foo()
的代码,而函数foo()
是存放在目标文件obj2.o
中的。链接程序会对obj1.o
和obj2.o
中的重定位记录进行分析并将这两个文件链接在一起产生一个可以独立运行的可执行程序。符号引用会被解析成符号定义,这是什么意思呢?目标文件是可重定位的代码,也就是说,目标文件中的代码会被重定位到可执行文件的段中一个给定的地址。在进行重定位之前,无法确定obj1.o
或者obj2.o
中的符号和代码在内存中的位置,因此无法进行引用。只能在链接器确定了可执行文件的段中存放的指令或者符号的位置之后才能够进行修改。
来看一下64位的重定位条目:
typedef struct{
Elf64_Addr r_offset;
Uint64_t r_info;
}Elf64_Rel;
有的重定位条目还需要addend字段:
typedef struct{
Elf64_Addr r_offset;
Uint64_t r_info;
int64_t r_addend;
}Elf64_Rela;
r_offset
指向需要进行重定位操作的位置。重定位操作详细描述了如何对存放在r_offset
中的代码或数据进行修改。
r_info
指定必须对其进行重定位的符号表索引以及要应用的重定位类型。
r_addend
指定常量加数,用于计算存储在可重定位字段中的值。
32位ELF文件的重定位记录跟64位的一样,只不过用的是32位的整型。下面的例子是即将被编译成32位目标文件的代码,我们用这个例子来说明隐式加数,其在64位的目标文件中不常见。如果重定位记录存储在不包含r_addend
字段的ElfN_Rel
类型结构中,就需要隐式加数,因此隐式加数存储在重定位目标本身中。64位的可执行文件一般使用ElfN_Rela
的结构,显式地对加数进行存储。我认为很有必要弄清楚这两种场景,对于隐式加数可能有点难以理解,下面就重点进行讲述。
看下面的这段源码:
_start()
{
foo();
}
这段代码中调用了foo()
函数,但是foo()
函数并没有在这个源码所在的文件中进行定义,因此,就需要创建一个重定位条目,以便在编译时进行符号引用:
$ objdump -d obj1.o
obj1.o: file format elf32-i386
Disassembly of section .text:
00000000 <func>:
0: 55 push %ebp
1: 89 e5 mov %esp,%ebp
3: 83 ec 08 sub $0x8,%esp
6: e8 fc ff ff ff call 7 <func+0x7>
b: c9 leave
c: c3 ret
可以看到,上面强调了对foo()
函数的调用,存储的值0xfffffffc
就是隐式加数。同时注意call 7
。数字7是将要进行修改的重定位目标的偏移量。因此,当obj1.o
(调用位于obj2.o
中的foo()
)与obj2.o
链接来产生一个可执行文件时,链接器会对偏移为7的位置所指向的重定位条目进行处理,即需要对该位置(偏移量7)进行修改。随后,在foo()
函数被包含进可执行文件后,链接器会对偏移7补齐4个字节,这样就相当于存储了foo()
函数的实际偏移地址。
![]()
调用指令
e8 fc ff ff ff
保存了隐式加数,这是上面示
例讲述的重点。值0xfffffffc
即为(−4)或者-(sizeof (uint32_t)
。在32位系统中,双字是4字节,也即该重定
位目标所占空间的大小。
$ readelf -r obj1.o
Relocation section '.rel.text' at offset 0x394 contains 1 entries:
Offset Info Type Sym.Value Sym. Name
00000007 00000902 R_386_PC32 00000000 foo
可以看到,偏移位置7处的重定位字段是由重定位条目的r_offset
字段指定的。
R_386_PC32
是重定位类型。要理解所有的重定位类型,可以查阅ELF规范。每一种重定位类型都对应一种在重定位目标上进行修改操作的计算方式。R_386_PC32
采用“S + A – P
”的方式对重定位目标进行修改。S
是索引位于重定位条目中的符号的值。A
是重定位条目中的加数。P
是要进行重定位(使用r_offset
进行计算)的存储单元的地址(节偏移或者地址)。下面看一下在32位系统中对obj1.o
和obj2.o
进行编译之后最终输出的可执行文件:
$ gcc -nostdlib obj1.o obj2.o -o relocated
$ objdump -d relocated
test: file format elf32-i386
Disassembly of section .text:
080480d8 <func>:
80480d8: 55 push %ebp
80480d9: 89 e5 mov %esp,%ebp
80480db: 83 ec 08 sub $0x8,%esp
80480de: e8 05 00 00 00 call 80480e8 <foo>
80480e3: c9 leave
80480e4: c3 ret
80480e5: 90 nop
80480e6: 90 nop
80480e7: 90 nop
080480e8 <foo>:
80480e8: 55 push %ebp
80480e9: 89 e5 mov %esp,%ebp
80480eb: 5d pop %ebp
80480ec: c3 ret
可以看到,位于0x80480de处的调用指令(重定位目标)已经被修改成了32位的偏移量5,该偏移量指向foo()
函数。R386_PC_32
重定位执行之后的结果即为5:
S + A – P: 0x80480e8 + 0xfffffffc – 0x80480df = 5
0xfffffffc
是带符号整数-4
的十六进制表示,因此计算方式也可以用下面的方式描述:
0x80480e8 + (0x80480df + sizeof(uint32_t))
要将一个偏移量计算成虚拟地址,可以用下面的公式:
address_of_call + offset + 5 (5 是调用指令的长度)
)
在这种情况下,0x80480de + 5 + 5 = 0x80480e8
。
![]()
上面的这个公式很重要,在将偏移量计算成地址的时候会经常用到。
用下面的计算方式也可以将一个地址转换成偏移量:
address – address_of_call – 4 (4是调用指令立即操作数的长度,为32位)
之前提到过,ELF规范中对ELF重定位有更深入的介绍。在下面的内容中,会涉及动态链接常用的几种重定位类型,如R386_JMP_SLOT
重定位条目。
重定位代码注入是黑客、病毒制造者或者任何想修改二进制文件中代码的人常用的一种技术。在二进制文件编译完成并链接到一个可执行文件之后,通过重定位代码技术可以重新链接二进制文件。这就意味着,可以将一个目标文件注入到可执行文件中,更改可执行文件的符号表来指向新注入的功能,并对注入的目标代码进行必要的重定位,那么注入的代码就变成了可执行文件的一部分。
一个复杂的病毒程序有可能会利用重定位代码注入技术,而不只是使用位置独立的代码。该项技术要实现代码注入,需要在目标可执行文件中挪出一定的空间,随后再进行重定位。第4章会对二进制感染和代码注入进行更透彻的讲解。
在第 1 章中提到过一个很棒的工具Eresi(http://www.eresi-project.org
),利用Eresi就可以进行重定位代码注入(也称ET_REL
注入)。我自己也设计了一个称为Quenya的用于ELF的反编译工具,这个工具比较旧,可以从链接http://www.bitlackeys.org/projects/quenya_ 32bit.tgz
进行下载。Quenya有许多功能特性,其中有一项功能就是可以向可执行文件中注入代码。如果想通过劫持一个给定的函数来修复二进制文件,那么Quenya的代码注入功能将非常有帮助。Quenya只是一个原型,没有继续开发到Eresi项目那样的规模。我本人对Quenya非常了解,在此只是把它当做一个例子。如果想得到比较准确的结果,还是推荐使用Eresi或者自己写一个反编译工具。
假设我们是攻击者,现在想攻击一个32位的程序,在该程序中调用了puts()
函数用来打印Hello World。我们的目标是劫持puts()
函数,让该程序调用evil_puts()
:
#include <sys/syscall.h>
int _write (int fd, void *buf, int count)
{
long ret;
__asm__ __volatile__ ("pushl %%ebx\n\t"
"movl %%esi,%%ebx\n\t"
"int $0x80\n\t""popl %%ebx":"=a" (ret)
:"0" (SYS_write), "S" ((long) fd),
"c" ((long) buf), "d" ((long) count));
if (ret >= 0) {
return (int) ret;
}
return -1;
}
int evil_puts(void)
{
_write(1, "HAHA puts() has been hijacked!\n", 31);
}
现在将evil_puts.c
编译成evil_puts.o
文件,然后注入到./hello_ world
程序中:
$ ./hello_world
Hello World
该程序调用了下面的命令:
puts("Hello World\n");
下面用Quenya
将evil_puts.o
文件注入并重定位到hello_ world
中:
[Quenya v0.1@alchemy] reloc evil_puts.o hello_world
0x08048624 addr: 0x8048612
0x080485c4 _write addr: 0x804861e
0x080485c4 addr: 0x804868f
0x080485c4 addr: 0x80486b7
Injection/Relocation succeeded
可以看到,在可执行文件hello_world
中已经为之前的evil_ puts.o
目标文件的write()
函数在0x804861e
处分配了一个地址,并进行了重定位。下面的hijack命令重写了全局偏移表的条目,使用evil_puts()
的地址替代了puts()
:
[Quenya v0.1@alchemy] hijack binary hello_world evil_puts puts
Attempting to hijack function: puts
Modifying GOT entry for puts
Successfully hijacked function: puts
Committing changes into executable file
[Quenya v0.1@alchemy] quit
现在会输出什么内容呢?
ryan@alchemy:~/quenya$ ./hello_world
HAHA puts() has been hijacked!
我们已经成功地将一个目标文件重定位到可执行文件中,通过改变可执行文件的控制流,来执行注入的代码。使用readelf -s hello_world
命令,可以看到evil_puts()
的符号。
为了满足读者的意愿,下面是Quenya中一小段利用了ELF重定位机制的代码。脱离了代码框架单独看这一小段代码可能会有点疑惑,但如果读者掌握了我们所介绍的重定位相关的知识,看起来就会直观很多。
switch(obj.shdr[i].sh_type)
{
case SHT_REL: /* Section contains ElfN_Rel records */
rel = (Elf32_Rel *)(obj.mem + obj.shdr[i].sh_offset);
for (j = 0; j < obj.shdr[i].sh_size / sizeof(Elf32_Rel); j++, rel++)
{
/* symbol table */
symtab = (Elf32_Sym *)obj.section[obj.shdr[i].sh_link];
/* symbol we are applying relocation to */
symbol = &symtab[ELF32_R_SYM(rel->r_info)];
/* section to modify */
TargetSection = &obj.shdr[obj.shdr[i].sh_info];
TargetIndex = obj.shdr[i].sh_info;
/* target location */
TargetAddr = TargetSection->sh_addr + rel->r_offset;
/* pointer to relocation target */
RelocPtr = (Elf32_Addr *)(obj.section[TargetIndex] + rel->r_offset);
/* relocation value */
RelVal = symbol->st_value;
RelVal += obj.shdr[symbol->st_shndx].sh_addr;
printf("0x%08x %s addr: 0x%x\n",RelVal, &SymStringTable[symbol->st_
name], TargetAddr);
switch (ELF32_R_TYPE(rel->r_info))
{
/* R_386_PC32 2 word32 S + A - P */
case R_386_PC32:
*RelocPtr += RelVal;
*RelocPtr -= TargetAddr;
break;
/* R_386_32 1 word32 S + A */
case R_386_32:
*RelocPtr += RelVal;
break;
}
}
从上面的代码中可以看到,RelocPtr
指向的重定位目标是根据重定位类型(如R_386_32
)所规定的重定位操作来进行修改的。
尽管重定位代码的二进制注入是利用重定位原理的一个很好的例子,不过用这个例子来理解链接器在实际工作中如何对多个目标文件进行链接,并不那么直观。尽管如此,这个例子仍然能够说明重定位的基本原理和实际应用场景。稍后我们会讨论共享库(ET_DYN
)注入,在这之前,先引入动态链接的概念。
在动态链接方式实现以前,普遍采用静态链接的方式来生成可执行文件。如果一个程序使用了外部的库函数,那么整个库都会被直接编译到可执行文件中。ELF支持动态链接,这在处理共享库的时候就会非常高效。
当一个程序被加载进内存时,动态链接器会把需要的共享库加载并绑定到该进程的地址空间中。动态链接的概念对很多人来说比较难以理解,因为这确实是一个相对复杂的过程,看上去就像是魔术一样。本节将揭开动态链接的神秘面纱,看一下它是如何工作以及如何被黑客利用的。
共享库在被编译到可执行文件中时是位置独立的,因此很容易被重定位到进程的地址空间中。一个共享库就是一个动态的ELF目标文件。在终端输入readelf–h lib.so
命令,会看到e_type
(ELF文件类型)是ET_DYN
。动态目标文件与可执行文件非常类似,是由程序解释器加载的,通常没有PT_INTERP
段,因而不会触发程序解释器。
当一个共享库被加载进一个进程的地址空间中时,一定有指向其他共享库的重定位。动态链接器会修改可执行文件中的GOT(Global Offset Table,全局偏移表)。GOT位于数据段(.got.plt
节)中,因为GOT必须是可写的(至少最初是可写的,可以将只读重定位看做一种安全特性),故而位于数据段中。动态链接器会使用解析好的共享库地址来修改GOT。随后会解释延迟链接的过程。
通过系统调用sys_execve()
将程序加载到内存中时,对应的可执行文件会被映射到内存的地址空间,并为该进程的地址空间分配一个栈。这个栈会用特定的方式向动态链接器传递信息。这种特定的对信息的设置和安排即为辅助向量(auxv)。栈底(在x86体系结构中,栈的地址是往下增长的,因此栈底是栈的最高址)存放了以下信息:
[argc][argv][envp][auxiliary][.ascii data for argv/envp]
辅助向量是一系列ElfN_auxv_t
的结构:
typedef struct
{
uint64_t a_type; /* Entry type */
union
{
uint64_t a_val; /* Integer value */
} a_un;
} Elf64_auxv_t;
a_type
指定了辅助向量的条目类型,a_val
为辅助向量的值。下面是动态链接器所需要的一些最重要的条目类型:
#define AT_EXECFD 2 /* File descriptor of program */
#define AT_PHDR 3 /* Program headers for program */
#define AT_PHENT 4 /* Size of program header entry */
#define AT_PHNUM 5 /* Number of program headers */
#define AT_PAGESZ 6 /* System page size */
#define AT_ENTRY 9 /* Entry point of program */
#define AT_UID 11 /* Real uid */
动态链接器从栈中检索可执行程序相关的信息,如程序头、程序的入口地址等。上面列出的只是从/usr/include/elf.h
中挑选出的几个辅助向量条目类型。
辅助向量是由内核函数create_elf_tables()
设定的,该内核函数在Linux的源码/usr/src/linux/fs/binfmt_elf.c
中。
事实上,内核的执行过程跟下面的描述类似。
1.sys_execve() →.
2.调用do_execve_common() →.
3.调用search_binary_handler() →.
4.调用load_elf_binary() →.
5.调用create_elf_tables() →.
下面是/usr/src/linux/fs/binfmt_elf.c
中的函数create_elf_ tables()
的代码,这段代码会添加辅助向量条目:
NEW_AUX_ENT(AT_PAGESZ, ELF_EXEC_PAGESIZE);
NEW_AUX_ENT(AT_PHDR, load_addr + exec->e_phoff);
NEW_AUX_ENT(AT_PHENT, sizeof(struct elf_phdr));
NEW_AUX_ENT(AT_PHNUM, exec->e_phnum);
NEW_AUX_ENT(AT_BASE, interp_load_addr);
NEW_AUX_ENT(AT_ENTRY, exec->e_entry);
可以看到,ELF的入口点和程序头地址,以及其他的值,是与内核中的NEW_AUX_ENT()
宏一起入栈的。
程序被加载进内存,辅助向量被填充好之后,控制权就交给了动态链接器。动态链接器会解析要链接到进程地址空间的用于共享库的符号和重定位。默认情况下,可执行文件会动态链接GNU C库libc.so
。ldd
命令能显示出一个给定的可执行文件所依赖的共享库列表。
在可执行文件和共享库中可以看到PLT(过程链接表)和GOT(全局偏移表)。接下重点介绍可执行程序中的PLT/GOT。当一个程序调用共享库中的函数(如strcpy()
或者printf())
时,需要到程序运行时才能解析这些函数调用,那么一定存在动态链接共享库并解析共享函数地址的机制。编译器编译动态链接的程序时,会使用一种特定的方式来处理共享库函数调用,这跟简单的本地函数调用指令截然不同。
来看一个编译好的32位ELF可执行文件对libc.so
的函数fgets()
进行调用的例子。32位可执行文件与GOT的关系比较容易观察,因为在32位文件中没有用到IP相对地址,IP相对地址是在64位可执行文件中使用的:
objdump -d test
...
8048481: e8 da fe ff ff call 8048360<fgets@plt>
...
地址0x8048360
对应函数fgets()
的PLT条目。接下来观察可执行文件中地址为0x8048360
的内容:
objdump -d test (grep for 8048360)
...
08048360<fgets@plt>: /* A jmp into the GOT */
8048360: ff 25 00 a0 04 08 jmp *0x804a000
8048366: 68 00 00 00 00 push $0x0
804836b: e9 e0 ff ff ff jmp 8048350 <_init+0x34>
...
对函数fgets()
的调用会指向地址0x8048360
,即函数fgets()
的PLT跳转表条目。从前面反编译代码的输出中可以看到,有一个间接跳转指向存放在0x804a000
中的地址,这个地址就是GOT条目,存放着libc共享库中函数fgets()
的实际地址。
然而,动态链接器采用默认的延迟链接方式时,不会在函数第一次调用时就对地址进行解析。延迟链接意味着动态链接器不会在程序加载时解析每一个函数,而是在调用时通过.plt
和.got.plt
节(分别对应各自的过程链接表和全局偏移表)来对函数进行解析。可以通过修改LD_BIND_NOW
环境变量将链接方式修改为严格加载,以便在程序加载的同时进行动态链接。动态链接器之所以默认采用延迟链接的方式,是因为延迟链接能够提高装载时的性能。不过,有时候有些不可预知的链接错误可能在程序运行一段时间后才能够发现。我在过去几年里也就碰到过一次这种情况。值得注意的是,有些安全特性,如只读重定位,只能在严格链接的模式下使用,因为.plt.got
节是只读的。在动态链接器完成对.plt.got
的补充之后才能够进行只读重定位,因此必须使用严格链接。
我们看一下fgets()
函数的重定位条目:
$ readelf -r test
Offset Info Type SymValue SymName
...
0804a000 00000107 R_386_JUMP_SLOT 00000000 fgets
...
![]()
R_386_JUMP_SLOT
是PLT/GOT
条目的一种重定位类型。在x86_64
系统中,对应的类型为:R_X86_64_JUMP_SLOT
。
从上面可以看到,重定位的偏移地址为0x804a000
,跟fgets()
函数PLT跳转的地址相同。假设函数fgets()
是第一次被调用,动态链接器需要对fgets()
的地址进行解析,并把值存入fgets()
的GOT条目中。
下面测试程序的GOT:
08049ff4 <_GLOBAL_OFFSET_TABLE_>:
8049ff4: 28 9f 04 08 00 00 sub %bl,0x804(%edi)
8049ffa: 00 00 add %al,(%eax)
8049ffc: 00 00 add %al,(%eax)
8049ffe: 00 00 add %al,(%eax)
804a000: 66 83 04 08 76 addw $0x76,(%eax,%ecx,1)
804a005: 83 04 08 86 addl $0xffffff86,(%eax,%ecx,1)
804a009: 83 04 08 96 addl $0xffffff96,(%eax,%ecx,1)
804a00d: 83 .byte 0x83
804a00e: 04 08 add $0x8,%al
重点注意地址0x08048366
,该地址存储在GOT的0x804a000
中。在低字节序中,低位字节排放在内存的低地址端,因此看上去是66 83 04 08
。由于链接器还未对函数fgets()
进行解析,故该地址并不是函数的地址,而是指向函数fgets()
的PLT条目。再来看一下函数fgets()
的PLT条目:
08048360 <fgets@plt>:
8048360: ff 25 00 a0 04 08 jmp *0x804a000
8048366: 68 00 00 00 00 push $0x0
804836b: e9 e0 ff ff ff jmp 8048350 <_init+0x34>
因此, jmp *0x804a000
指令会跳转到地址 0x804a000
中存放的0x8048366
,即push $0x0
指令。该push指令的作用是将fgets()
的GOT条目入栈。fgets()
的GOT条目偏移地址为0x0,对应的第一个GOT条目是为一个共享库符号值保留的,0x0实际上是第4个GOT条目,即GOT[3]。换句话说,共享库的地址并不是从GOT[0]开始的,而是从GOT[3]开始的,前3个条目是为其他用途保留的。
![]()
下面是GOT的3个偏移量。
GOT[0]:存放了指向可执行文件动态段的地址,动态链接器利用该地址提取动态链接相关的信息。
GOT[1]:存放
link_map
结构的地址,动态链接器利用该地址来对符号进行解析。GOT[2]:存放了指向动态链接器
_dl_runtime_resolve()
函数的地址,该函数用来解析共享库函数的实际符号地址。
fgets()
的PLT存根(stub)的最后一条指令是jmp 8048350
。该地址指向可执行文件的第一个PLT条目,即PLT-0。
我们的可执行文件的PLT-0存放了下面的代码:
8048350: ff 35 f8 9f 04 08 pushl 0x8049ff8
8048356: ff 25 fc 9f 04 08 jmp *0x8049ffc
804835c: 00 00 add %al,(%eax)
第一条pushl
指令将GOT[1]的地址压入栈中,前面提到过,GOT[1]中存放了指向link_map
结构的地址。
jmp *0x8049ffc
指令间接跳转到第3个GOT条目,即GOT[2],在GOT[2]中存放了动态链接器_dl_runtime_resolve()
函数的地址,然后将控制权转给动态链接器,解析fgets()
函数的地址。对函数fgets()
进行解析后,后续所有对PLT条目fgets()
的调用都会跳转到fgets()
的代码本身,而不是重新指向PLT,再进行一遍延迟链接的过程。
下面是对前述内容的一个总结。
1.调用fgets@PLT
(即调用fgets
函数)。
2.PLT代码做一次到GOT中地址的间接跳转。
3.GOT条目存放了指向PLT的地址,该地址存放在push
指令中。
4.push $0x0
指令将fgets()
GOT条目的偏移量压栈。
5.最后的fgets()
PLT指令是指向PLT-0代码的jmp指令。
6.PLT-0的第一条指令将GOT[1]的地址压栈,GOT[1]中存放了指向fgets()
的link_map
结构的偏移地址。
7.PLT-0的第二条指令会跳转到GOT[2]存放的地址,该地址指向动态链接器的_dl_runtime_resolve
函数,_dl_runtime_resolve
函数会通过把fgets()
函数的符号值加到.got.plt
节对应的GOT条目中,来处理R_386_JUMP_SLOT
重定位。
下一次调用fgets()
函数时,PLT条目会直接跳转到函数本身,而不是再执行一遍重定位过程。
之前在2.2.2节中引用过动态段。动态段有一个节头,可以通过节头来引用动态段,还可以通过程序头来引用动态段。动态链接器需要在程序运行时引用动态段,但是节头不能够被加载到内存中,因此动态段需要有相关的程序头。
动态段保存了一个由类型为ElfN_Dyn
的结构体组成的数组:
typedef struct {
Elf32_Sword d_tag;
union {
Elf32_Word d_val;
Elf32_Addr d_ptr;
} d_un;
} Elf32_Dyn;
d_tag
字段保存了类型的定义参数,可以参见ELF(5)手册。下面列出了动态链接器常用的比较重要的类型值。
保存了所需的共享库名的字符串表偏移量。
动态符号表的地址,对应的节名.dynsym
。
符号散列表的地址,对应的节名.hash
(有时命名为.gnu.hash
)。
符号字符串表的地址,对应的节名.dynstr
。
全局偏移表的地址。
ElfN_Dyn的d_val
成员保存了一个整型值,可以存放各种不同的数据,如一个重定位条目的大小。
d_ptr
成员保存了一个内存虚址,可以指向链接器需要的各种类型的地址,如d_tag DT_SYMTAB
符号表的地址。
![]()
前面讲的动态参数表明了如何通过动态段找到特定节的地址,这对重建节头表的取证分析重建任务非常有帮助。如果去掉了节头表,可以从动态段(
.dynstr
、.dynsym
、.hash
等)读取相关信息来重建部分节头表。其他的段,如
text
(文本)段和data
(数据)段等,也可以产生所需的相关信息(如要产生.text
节和.data
节的相关信息)。
动态链接器利用ElfN_Dyn
的d_tag
来定位动态段的不同部分,每一部分都通过d_tag
保存了指向某部分可执行文件的引用,如DT_SYMTAB
保存了动态符号表的地址,对应的d_prt
给出了指向该符号表的虚址。
动态链接器映射到内存中时,首先会处理自身的重定位,因为链接器本身就是一个共享库。接着会查看可执行程序的动态段并查找DT_NEEDED
参数,该参数保存了指向所需要的共享库的字符串或者路径名。当一个共享库被映射到内存中后,链接器会获取到共享库的动态段,并将共享库的符号表添加到符号表链中,符号表链存储了所有映射到内存中的共享库的符号表。
链接器为每个共享库生成一个link_map
结构的条目,并将其存入到一个链表中:
struct link_map
{
ElfW(Addr) l_addr; /* Base address shared object is loaded at. */
char *l_name; /* Absolute file name object was found in. */
ElfW(Dyn) *l_ld; /* Dynamic section of the shared object. */
struct link_map *l_next, *l_prev; /* Chain of loaded objects. */
};
链接器构建完依赖列表后,会挨个处理每个库的重定位(与本章之前讨论的重定位过程类似),同时会补充每个共享库的GOT。延迟链接对共享库的PLT/GOT仍然适用,因此,只有当一个函数真正被调用时,才会进行GOT重定位(R_386_JMP_SLOT
类型)。
想要了解ELF和动态链接相关的更多详细信息,可以查看ELF的在线规范文档,也可以查看一些比较有意思的glibc源码。希望读者现在不要再觉得动态链接很神秘,而是激起了更多的好奇心。第7章会介绍入侵PLT/GOT相关的技术,以重定向共享库函数调用。其中一个非常有趣的技术就是破坏动态链接。
为了更好地总结所学知识,我引入了一些比较简单的代码,下面的代码能够打印出一个32位ELF可执行文件的程序头和节名。本书后续还会列出更多ELF相关的代码示例:
/* elfparse.c – gcc elfparse.c -o elfparse */
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <elf.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <stdint.h>
#include <sys/stat.h>
#include <fcntl.h>
int main(int argc, char **argv)
{
int fd, i;
uint8_t *mem;
struct stat st;
char *StringTable, *interp;
Elf32_Ehdr *ehdr;
Elf32_Phdr *phdr;
Elf32_Shdr *shdr;
if (argc < 2) {
printf("Usage: %s <executable>\n", argv[0]);
exit(0);
}
if ((fd = open(argv[1], O_RDONLY)) < 0) {
perror("open");
exit(-1);
}
if (fstat(fd, &st) < 0) {
perror("fstat");
exit(-1);
}
/* Map the executable into memory */
mem = mmap(NULL, st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
if (mem == MAP_FAILED) {
perror("mmap");
exit(-1);
}
/*
* The initial ELF Header starts at offset 0
* of our mapped memory.
*/
ehdr = (Elf32_Ehdr *)mem;
/*
* The shdr table and phdr table offsets are
* given by e_shoff and e_phoff members of the
* Elf32_Ehdr.
*/
phdr = (Elf32_Phdr *)&mem[ehdr->e_phoff];
shdr = (Elf32_Shdr *)&mem[ehdr->e_shoff];
/*
* Check to see if the ELF magic (The first 4 bytes)
* match up as 0x7f E L F
*/
if (mem[0] != 0x7f && strcmp(&mem[1], "ELF")) {
fprintf(stderr, "%s is not an ELF file\n", argv[1]);
exit(-1);
}
/* We are only parsing executables with this code.
* so ET_EXEC marks an executable.
*/
if (ehdr->e_type != ET_EXEC) {
fprintf(stderr, "%s is not an executable\n", argv[1]);
exit(-1);
}
printf("Program Entry point: 0x%x\n", ehdr->e_entry);
/*
* We find the string table for the section header
* names with e_shstrndx which gives the index of
* which section holds the string table.
*/
StringTable = &mem[shdr[ehdr->e_shstrndx].sh_offset];
/*
* Print each section header name and address.
* Notice we get the index into the string table
* that contains each section header name with
* the shdr.sh_name member.
*/
printf("Section header list:\n\n");
for (i = 1; i < ehdr->e_shnum; i++)
printf("%s: 0x%x\n", &StringTable[shdr[i].sh_name], shdr[i].
sh_addr);
/*
* Print out each segment name, and address.
* Except for PT_INTERP we print the path to
* the dynamic linker (Interpreter).
*/
printf("\nProgram header list\n\n");
for (i = 0; i < ehdr->e_phnum; i++) {
switch(phdr[i].p_type) {
case PT_LOAD:
/*
* We know that text segment starts
* at offset 0. And only one other
* possible loadable segment exists
* which is the data segment.
*/
if (phdr[i].p_offset == 0)
printf("Text segment: 0x%x\n", phdr[i].p_vaddr);
else
printf("Data segment: 0x%x\n", phdr[i].p_vaddr);
break;
case PT_INTERP:
interp = strdup((char *)&mem[phdr[i].p_offset]);
printf("Interpreter: %s\n", interp);
break;
case PT_NOTE:
printf("Note segment: 0x%x\n", phdr[i].p_vaddr);
break;
case PT_DYNAMIC:
printf("Dynamic segment: 0x%x\n", phdr[i].p_vaddr);
break;
case PT_PHDR:
printf("Phdr segment: 0x%x\n", phdr[i].p_vaddr);
break;
}
}
exit(0);
}
我们现在已经对ELF进行了一系列的探索,我鼓励读者能够对ELF格式继续探索下去。在本书的后续内容中,还会介绍许多项目,希望能够激发读者继续学习ELF格式的热情。我已经在ELF的学习上投入了好几年的热情。我非常乐意将我所学的东西通过一种非常有趣并且有创新性的方式分享给读者,能够帮助读者掌握这一难度极大的知识。