CTF竞赛权威指南读书笔记
第二章:二进制文件
编译原理
首先是编译器的概念:从源语言到目标语言的转换器。编译器执行阶段大致分为五个步骤:
(1)词法分析 读入源程序的字符流,输出为有意义的词素。
(2)语法分析 根据各个词法单元的第一个分量来创建树型的中间表示形式,通常是语法树。
(3)语义分析 使用语法树和符号表中的信息,检测源程序是否满足语言定义的语义约束,同时收集类型信息,用于代码生成,类型检查和类型转换。
(4)中间代码生成和优化 生成类机器语言的中间表示,如三地址码(TAC)。
在GCC下printf("%s\n",str);
被优化为puts("%s");
(5)代码生成和优化 把中间表示形式映射到目标机器语言。
实验步骤
1.hello.c的编写
#include <stdio.h>
int main()
{
printf("hello,world\n");
}
2.命令行的调用
总览编译过程:
gcc hello.c -o hello -save-temps --verbose
gcc指明编译器名称,hello.c为编译对象,-o hello为编译目标输出。-sava-temps则为保留编译生成中间项。–verbose为gcc编译具体流程的展示。
3.小结
从verbose指令给出的具体流程来看,GCC编译共分为四个阶段:预处理,编译,汇编,链接。中间启用了编译器ccl,汇编器as,链接器collect2三个工具。ccl进行了前两个阶段,as进行了汇编处理,collect2则进行了ld命令封装(将动态链接库与CRT中的目标文件连接到可执行文件hello)。
运行时库(Runtime library)通俗的说就是我们的程序运行的时候所依赖的库文件,在Windows平台这些库由微软提供,并且是以2种形式提供:静态库(lib)、动态库(lib+dll)。每个库还都提供debug、release 2个版本。
C/C++运行时库从形式上来讲和我们自己开发的静态库、动态库没什么两样,只是它们由微软开发和维护,并提供了一些常用的功能支持(如malloc,free, printf等等),如果我们的程序需要使用这些功能(事实上,只要是C/C++程序就一定会用到运行时库提供的功能),就要链接C/C++运行时库。我们可以自主选择是链接“静态库”还是“动态库”,是链接“debug版本”的还是“release版本”的。
而从生成的文件与其内容来开,一共有三个中间文件:hello.i,hello.s,hello.o。
hello.i是预处理过程留下的文件。其中将#include,#define,#if等宏定义以及预处理指令递归性的复制到当前位置。并且将所有注释删除,添加上行号和文件名标识。
获取hello.i文件
gcc -E hello.c -o hello.i
hello.s是编译阶段留下的文件。编译到中间语言(汇编语言)的过程基本完成。
hello.s文件的获取与查看
gcc -S hello.c -o hello.s -masm=intel -fno-asynchronous-unwind-tables
注意,fno那一串是一种缩写排除措施
hello.o是汇编阶段留下的文件。所得的文件是一个可重定位文件,文件内各指令地址均为虚拟地址,等待链接后分配。
hello.o文件的获取与查看。
gcc -c hello.c -o hello.o
objdump -sd hello.o -M intel
最后的链接阶段:
gcc hello.o -o hello -static
若 -static 去掉,则默认调用动态链接
此步主要包括地址与空间分配,符号绑定,重定位等操作。链接操作由连接器ld.so完成,结果就得到了hello文件。链接操作后对象文件中无法确定的符号地址已经被修正为实际的符号地址,程序也就可以被加载到内存中正常执行了。
ELF文件格式
名词解释: ELF(Executable and Linkable Format)"可执行可链接格式"最初由UNIX系统实验室作为应用程序二进制接口(ABI)的一部分而制定和发布,是COFF(Common file format)格式的变种。Linux系统上所运行的就是ELF格式的文件。
ELF文件类型
实验步骤
1.首先对以下程序进行编译处理:
#include <stdio.h>
int global_init_var = 10;
int global_uninit_var;
void func(int sum)
{
printf("%d\n", sum);
}
void main(void)
{
static int local_static_init_var = 20;
static int local_static_uninit_var;
int local_init_val = 30;
int local_uninit_var;
func(global_init_var + local_init_val + local_static_init_var);
}
2.编译命令:
gcc elfDemo.c -o elfDemo.exec
gcc -static elfDemo.c -o elfDemo_static.exec
gcc -c elfDemo.c -o elfDemo.rel
gcc -c -fPIC elfDemo.c -o elfDemo_pic.rel
gcc -shared elfDemo_pic.rel -o elfDemo.dyn
对gcc部分指令的官方解释。
-g 可执行程序包含调试信息:加个-g 是为了gdb 用,不然gdb用不到。
-o 指定输出文件名(o:output)-o output_filename,确定输出文件的名称为output_filename,同时这个名称不能和源文件同名。如果不给出这个选项,gcc就给出预设的可执行文件a.out。
-c 只编译不链接:产生.o文件,就是obj文件,不产生执行文件(c : compile)
-static 使用静态库进行链接操作。
-fPIC 位置独立的代码(Position Independent Code,PIC)
position-independent code (PIC),用于生成位置无关代码。位置无关代码,可以理解为代码无绝对跳转,跳转都为相对跳转。生成动态库时,需要加上 -fPIC 选项。
在 Linux 系统中,动态链接文件称为动态共享对象 (Dynamic Shared Objects,DSO),一般是以 .so 为扩展名的文件。在 Windows 系统中,动态链接文件称为动态链接库 (Dynamic Linking Library),一般是以 .dll 为扩展名。
添加 -fPIC 选项生成的动态库,是位置无关的。这样的代码本身就能被放到线性地址空间的任意位置,无需修改就能正确执行。通常的方法是获取指令指针的值,加上一个偏移得到全局变量/函数的地址。
添加 -fPIC 选项实现真正意义上的多个进程共享 .so 库。多个进程引用同一个 -fPIC 动态库时,可以共用内存。这一个库在不同进程中的虚拟地址不同,操作系统会把它们映射到同一块物理内存上。
不添加 -fPIC 选项,加载 .so 库时,需要对代码段引用的数据对象重定位,重定位会修改代码段的内容,造成每个使用这个 .so 文件代码段的进程在内核里都会生成这个 .so 文件代码段的 copy,每个 copy 都不一样,取决于这个 .so 文件代码段和数据段内存映射的位置。不添加 -fPIC 选项,消耗内存,编译的 .so 文件的优点是加载速度快。
3.结果分析
ELF文件分为三种类型,可执行文件(.exec)、可重定位文件(.rel)、和共享目标文件(.dyn):
- 可执行文件:经过链接的、可执行的目标文件,通常也被称为程序。
- 可重定位文件:由源文件编译而成且尚未链接的目标文件,通常以.o作为扩展名。用于与其它目标文件进行链接以构成可执行文件或动态链接库,通常是一段位置独立的代码。(其实位置独立可理解为位置无关)
- 共享目标文件:动态链接库文件。用于在链接过程中与其它动态链接库或可重定位文件一起构成新的目标文件,或者在可执行文件加载时,链接到进程中作为运行代码的一部分。
- 核心转储文件(书上附加):作为进程意外终止时进程地址空间的转储,也是ELF文件的一种。(转储名词:dump)在程序奔溃时,用gdb读取这些文件可以辅助我们调试和查找程序崩溃的原因。
ELF文件结构
在ELF文件格式规范时,ELF文件被统称为Object file,这与我们通常理解的“.o”文件是两个概念
链接视角划分:(以节作为基本单位)
ELF header|.text section(代码节)|.data section(数据节)|.bss section(BSS节)
ELF头
ELF文件头位于目标文件最开始的地方。包含描述整个文件的一些基本信息,例如ELF文件类型、版本/ABI(应用程序二进制接口)版本、目标机器、程序入口、段表和节表的位置和长度等。文件头部存在魔术字符(7F,45,4C,46)即字符串“\177ELF“
最开始的4个字节是所有ELF文件都必须相同的标识码,分别为0x7F、0x45、0x4c、0x46,第一个字节对应ASCII字符里面的DEL控制符,后面3个字节刚好是ELF这3个字母的ASCII码。这4个字节又被称为ELF文件的魔数,几乎所有的可执行文件格式的最开始的几个字节都是魔数:
-
a.out格式最开始两个字节为 0x01、0x07;
-
PE/COFF文件最开始两个个字节为0x4d、0x5a,即ASCII字符MZ。
这种魔数用来确认文件的类型,操作系统在加载可执行文件的时候会确认魔数是否正确,如果不正确会拒绝加载。
接下来的一个字节是用来标识ELF的文件类的,0x01表示是32位的,0x02表示是64位的;第6个字是字节序,规定该ELF文件是大端的还是小端的(见附录:字节序)。第7个字节规定ELF文件的主版本号,一般是1,因为ELF标准自1.2版以后就再也没有更新了。后面的9个字节ELF标准没有定义,一般填0,有些平台会使用这9个字节作为扩展标志。
另外,当文件被映射到内存时,可以通过搜索该字符确定映射地址,这在dump内存时非常有用。(转储)
1、为什么要dump(dump的目的)?
因为程序在计算机中运行时,在内存、CPU、I/O等设备上的数据都是动态的(或者说是易失的),也就是说数据使用完或者发生异常就会丢掉。如果我想得到某些时刻的数据(有可能是调试程序Bug或者收集某些信息),就要把他转储(dump)为静态(如文件)的形式。否则,这些数据你永远都拿不到。
2、dump转储的是什么内容(dump的对象)?
其实上边已经提到了,就是将动态(易失)的数据,保存为静态的数据(持久数据)。像程序这种本来就保存在存储介质(如硬盘)中的数据,也就没有必要dump。
现在,dump作为名词也很好理解了,一般就是指dump(动词)的结果文件。
常出现dump的场景:Unix/Linux中的coredump,Java中的headdump和threaddump,还有就是tcpdump工具。
查看命令:
readelf -h (ELF_file_name.Extend)
Elf64_Ehdr结构体
typedef struct
{
unsigned char e_ident[EI_NIDENT]; /* Magic number and other info */
Elf64_Half e_type; /* Object file type */
Elf64_Half e_machine; /* Architecture */
Elf64_Word e_version; /* Object file version */
Elf64_Addr e_entry; /* Entry point virtual address */
Elf64_Off e_phoff; /* Program header table file offset */
Elf64_Off e_shoff; /* Section header table file offset */
Elf64_Word e_flags; /* Processor-specific flags */
Elf64_Half e_ehsize; /* ELF header size in bytes */
Elf64_Half e_phentsize; /* Program header table entry size */
Elf64_Half e_phnum; /* Program header table entry count */
Elf64_Half e_shentsize; /* Section header table entry size */
Elf64_Half e_shnum; /* Section header table entry count */
Elf64_Half e_shstrndx; /* Section header string table index */
} Elf64_Ehdr;
节头表
(非必须,常有些情况去除节头表,增加反编译器的分析难度。)
记录着节的名字,长度,偏移量,读写权限等信息。节头表的位置记录在文件头的e_shoff域中。
查看命令:
readelf -S (ELF_file_name.Extend)
Elf64_Shdr结构体
typedef struct
{
Elf64_Word sh_name; /* Section name (string tbl index) */
Elf64_Word sh_type; /* Section type */
Elf64_Xword sh_flags; /* Section flags */
Elf64_Addr sh_addr; /* Section virtual addr at execution */
Elf64_Off sh_offset; /* Section file offset */
Elf64_Xword sh_size; /* Section size in bytes */
Elf64_Word sh_link; /* Link to another section */
Elf64_Word sh_info; /* Additional section information */
Elf64_Xword sh_addralign; /* Section alignment */
Elf64_Xword sh_entsize; /* Entry size if section holds table */
}Elf64_Shdr;
SECTIONS
查看指令:
objdump -x -s -d elfDemo.rel
1 .text section(代码节)
在kali中很轻易就可以看到反汇编的结果。由反汇编的分块可知,在.text节中,代码以函数为块存储。
2 .data section(数据节)
由结果得知:.data节保存已经初始化的全局变量和局部静态变量。
除.data节以外,还有.rodata节保存只读数据,包括只读变量和字符串常量。在例程中,我们的源代码里printf("%d\n")
中就包含了 %d\n 这一只读字符串,故其藏于.rodata中。
非静态局部变量值不被记录在节中!局部变量temp分配在栈中,不会在过程外被引用,因此不是符号定义
3 .bss section (BSS节)
BSS节用于保存未初始化的全局变量和局部静态变量。其没有COTENTS属性,说明该节在文件中并不存在。因此该节的sh_offset域也就没有意义了。
4.其它节
(1).dynsym与.symtab
查看:
readelf -s elfDemo.rel
前者保存了引用自外部文件的符号,只能在运行时被解析,而后者保存了本地的符号,用于调试和链接。
ELF文件通过一个符号在表中的索引值来使用该符号。索引值从零开始计数,但值为0的表项不具有实际意义,它表示未定义的符号。每个符号都有一个符号值,对于变量和函数,该值就是符号的地址。
符号、符号表
符号:通俗地说,就是前面跟着类型(如int/void等)的函数名,或变量名。
注意:这里的变量必须是除了非静态局部变量之外的其它变量。
所有符号载入史册——符号表。
分类:
①Global symbols(模块内部定义的全局符号,又称全局符号) – 由模块m定义并能被其他模块引用的全局符号。
例如,非static函数和非static的全局变量(指不带static的全局变量)
如,main.c 中的全局变量名buf②External symbols(外部定义的全局符号,又称外部符号) – 由其他模块定义并被模块m引用的全局符号(标志是extern,extern用来修饰全局变量,即声明在“最外层”)(要体现引用,否则不会进入符号表)
如,main.c 中的函数名swap是一个外部符号。i.它是一个声明,但是也仅仅是告诉链接器,这个swap实际上来自外部。
ii.还必须在模块m中引用,该符号才能出现在符号表里。对swap的引用,则体现在main函数中的swap();中。
(2).strtab与.shstrtab
查看:
readelf -x .strtab elfDemo.rel
.strtab是字符串表(STRING TABLE)
.shstrtab是段表字符串表(Section Header String Table),针对段表
.symtab是符号表,一般是变量、函数shstrtab及symtab经常引用strtab中的字符串。
(3).comment
存放编译器版本。
(4).rela.dyn
重定位是连接符号定义与符号引用的过程。可重定位文件在构建可执行文件或共享目标文件时,需要把节中的符号引用换成这些符号在进程空间中的虚拟地址。包含这些转换信息的数据就是重定位项。
杂项
有时为了方便程序调试,我们在编译时会使用“-g”选项,此时GCC就会在目标文件中添加许多调试信息,采用DWARF格式的形式保存在.debug_info段中。
查看方法:
readelf -wi text.o
.o文件使用–savetemp可以留存,也可以通过之前介绍的-c,-o方式获得。(.o,可重定位文件)
可执行文件的装载
从运行视角审视ELF文件结构(段视角)
当执行一个可执行文件时,首先要将该文件与动态链接库装载到进程空间中,形成一个进程镜像。每个进程都拥有独立的虚拟地址空间,这个空间是由段头表中的程序头决定的。ELF文件头的e_phoff域给出了段头表的位置。
查看段头表:
readelf -l elfDemo.exec
可以看到,每个段都包含了一个或多个节,相当于是对这些节进行分组,段的出现也正是出于这个目的。随着节的数量增多,在进行内存映射时,就产生空间和资源浪费的问题。所以系统并不在乎每个节的具体内容,而只是将相同权限的节放于同一段中,从而节省资源。
常见段
1.PT_LOAD,用于描述可装载的节,而动态链接的可执行文件则包含两个,将.data和.text分开存放。
2.动态段PT_DYNAMIC包含了一些动态链接器所必需的信息,如共享库列表、GOT表和重定位表等。
3.PT_NONE类型的段保存了系统相关的附加信息。
4.PT_INTER段将位置和大小信息存放在一个字符串中,是对程序解释器位置的描述。
5.PT_PHDR段保存了程序头表本身的位置和大小。
补充:
在进程镜像中不光有段,还需要栈、堆、vDSO等空间,这些空间同样通过权限来进行访问控制,从而保证程序运行时的安全。
静态链接
两个或多个不同的目标文件通过链接组成一个可执行文件。链接由链接器完成,根据时间发生的不同。分为三类:编译时链接,加载时链接和运行时链接。
段的装载地址和空间以页为单位对其,不足一页的代码或数据节也要占用一节
什么是页
将一个用户进程的地址空间(逻辑)划分成若干个大小相等的区域,称为页或页面,页面大小**由地址结构(逻辑)决定 ,**并为各页从0开始编号。
当前的链接器使用的时相似节合并的策略。
首先先对各个节的长度、属性、偏移量进行分析,
然后将输入目标文件中符号表的符号定义与符号引用统一生成全局符号表,
最后读取输入文件的各类信息对符号进行解析、重定位等操作。
相似节的合并就发生在重定位时。
完成后,程序中的每条指令和全局变量就都有唯一的运行时内存地址了。
**重定位表:告诉链接器如何修改节的内容。**每一个重定位表对应一个需要被重定位的节,例如名为.rel.text的节用于保存.text节的重定位表。
rel.text包含两个重定位入口:shared的类型R_X86_64_32用于绝对寻址。func的类型R_X86_64_PC32用于相对寻址。前者直接使用在指令编码的32位值作为有效地址,后者则使用编码的32位值加上PC(program counter)(程序指针) 的值得到有效地址。
动态链接
在程序运行或加载时,在内存中完成链接的过程叫做动态链接。
GCC默认使用动态链接编译,用-shared
表示生成共享库,-fpic
表示生成与位置无关的代码。
**位置无关代码:**可以加载而无需重定位的代码叫做位置无关代码(PIC),通过PIC,一个共享库的代码可以被无限多个进程所共享,从而节约内存资源。
由于一个程序(共享库)的数据段与代码段的相对距离总是保持不变的,因此,指令和变量之间的距离是一个运行时常量,与绝对内存地址无关。于是就有了全局偏移量表。
GOT被拆分成.got节和.got.plt节两个部分,后者需要延迟绑定,有读写权限。
延迟绑定
由于动态链接会造成链接过程变得冗长,为了加快启动速度,采用了延迟绑定技术。引入了PLT(过程链链表),配合GOT来实现延迟绑定。
对于每一个.o程序中反汇编后的< func@plt >部分,其实都蕴含着两条语句:jmp func@got.plt, push n.若func@got.plt为空,则执行下一条语句push n(将func在rel.plt中的下标压入栈中),不为空则不执行下一条。若执行了压栈,则程序会接着运行,找到链接器中的_dl_runtime_reslove()函数,然后将本身块位置与函数名提供给它,寻找func()的真实地址。
总结与提升
1.动态链接与延迟绑定详情:《程序员的自我修养:链接、装载与库》
2.ELF文件结构详情:点此进入
3.总结:
首先编译原理告诉我们,一个高级语言段是如何转化为机器码的。然后针对这个过程的关键部分,进行了探究:从生成的中间文件到中间文件组合链接成为可执行文件的全过程都有介绍。期间我们尤其注意每个操作的权限与每个文件的组成结构。
展开来讲,编译大致分五个过程,gcc编译会形成三个中间文件,四个过程,我们对其中的.o文件特别感兴趣,并且将其纳入一个新的大文件类中:ELF,随后介绍了勘探ELF文件的方法,并对其组成结构进行了介绍,最后我们研究了ELF文件并不齐全的信息是如何变齐全的这一过程进行了探究,分别讲到,静态链接,动态链接,延迟绑定,延迟绑定是空间与时间性能双收的当前的最大公约数。