基础学习
[TOC]
建立学习参考目标
(1)短期参考比自己优秀的同龄人。阅读他们的文章和工作成果从细节中观察他们的学习方 式和工作方式。
(2)中期参考你的方向上的业内专家。了解他们的成长轨迹’跟踪他们关注的内容。
(3)长期参考业内老牌企业和先锋企业把握行业发展、技术趋势’为未来做积累
二进制文件
从源代码到可执行文件
编译原理
编译器的作用是读人以某种语言(源语言)编写的程序,输出等价的用另一种语言(目标语言) 编写的程序。
GCC编译
GCC编译主要包括四个阶段:预处理、编译、汇编、链接。*(-o 选项用于指定编译后生成的输出文件名)*
1 | gcc hello.c -o hello //终端运行即可获得可执行文件hello |
hello.c–>hello.s–>hello.o
预处理
将#开头的预处理指令转换后直接插入程序文本中,得到另一个c程序 hello.i
1 | gcc -E hello.c -o hello.i //-E可以单独执行预处理 |
预处理的一些处理规则如下:
- 递归处理#include预处理指令将对应文件的内容复制到该指令的位置;
- 删除所有的#define指令并且在其被引用的位置递归地展开所有的宏定义;
- 处理所有条件预处理指令:#if #ifdef 等;
- 删除所有注释;
- 添加行号利文件名标识;
编译
将hello.c或hello.i变为汇编代码(实际上GCC已经将预处理和编译合并处理)
1 | gcc -S hello.c -o hello.s |
汇编
汇编器将汇编代码转换为机器码,即将hello.s汇编为hello.o目标文件
1 | gcc -c hello.c -o hello.o |
此时的hello.o是一个可重定位文件,可以使用objdump命令来查看其内容
1 | file hello.o //查看文件类型 |
- ELF:表示这是一个可执行与链接格式(Executable and Linkable Format)的文件。这是一种广泛用于Unix系统的标准文件格式。
- 64-bit:说明这个文件是为64位架构设计的。
- LSB:代表小端字节序(Little Endian Byte Order),这是数据存储的一种方式,在这种模式下,较低有效位的数据存放在内存的低地址处。
- relocatable:表明这是一个目标文件(object file),它可以被链接器用来创建一个可执行文件或另一个目标文件。
- x86-64:指明该文件适用于x86_64架构,也就是我们常说的AMD64或Intel 64架构。
- **version 1 (SYSV)**:指的是这个ELF文件遵循的是System V ABI版本1规范。
- not stripped:意味着调试信息和其他元数据没有从这个目标文件中移除。
1 | objdump -sd hello.o -M intel //查看文件内容 |
链接
链接可分为静态链接和动态链接两种,gcc默认使用动态链接,添加编译选项 -stdtic 即可指定使用静态链接。这一阶段将目标文件及其依赖库进行链接,生成可执行文件,主要包括地址和空间分配、符号绑定和重定位等操作。
(依赖库是指在软件项目中,为了实现某些功能而需要的外部代码库或组件)
1 | gcc hello.o -o hello -static |
链接操作由链接器(ld.so)完成,结果就得到了hello文件,这是一个静态链接的可执行文件,其包含了大量的库文件。
通过链接操作,对象文件中无法确定的符号地址已经被修正为实际的符号地址,程序也就可以被加载到内存中正常执行了。
ELF文件的结构
当提到目标文件时,即指各种类型的ELF文件。对于 .o 文件,我们则可直接称为可重定向文件。
在审视一个目标文件时,有两种视角可供选择,一种是链接视角,通过节(Section)来划分;另一种是运行视角,通过段(Segment)来进行划分。
链接视角
三个节
通常目标文件都会包含代码(.text)、数据(.data)和BSS(.bss)三个节。
- 代码节用于保存可执行的机器指令
- 数据节用于保存已初始化的全局变量和局部静态变量
- BSS节则用于保存未初始化的全局变量和局部静态变量
除了上边的三个节,简化的目标文件还应包含一个文件头(ELF header)
将程序指令和程序数据分开存放,从安全的角度讲,当程序被加载后,数据和指令分别被映射到两个虚拟区域。由于数据区域对于进程来说是可读写的,而指令区域对于进程来说是只读的,所以这两个虚存区域的权限可以被分别设置成可读写和只读,防止程序的指令被改写和利用。
ELF文件头(没学透)
位于目标文件最开始的位置,包含描述整个文件的一些基本信息,例如:ELF文件类型、版本/ABI版本(指的是应用程序二进制接口的版本)、目标机器、程序入口、段表和节表的位置和长度等。
值得注意的 是文件头部存在魔术字符(7f 45 4c 46),即字符串 \177ELF 当文件被映射到内存时,可以通过搜索该字符确定映射地址这在dump内存时非常有用。
Dump内存指的是将程序运行时的内存状态导出到文件中的过程。这个操作通常用于调试和分析程序的行为,尤其是在出现崩溃或异常时。通过查看dump出来的内存数据,开发者可以了解程序在特定时刻的状态,包括:
- 变量的值:检查各个变量是否包含预期的数据。
- 调用栈:确定导致问题的函数调用序列。
- 注册信息:CPU寄存器的内容,有助于追踪执行流程。
- 内存泄漏:查找未释放的资源。
节头表
一个目标文件中包含许多节,这些节的信息保存在节头表中,表的每一项都是一个Elf64_Shdr结构体,记录了节的名字、长度、偏移、读写权限等信息。
字符串表中包含了以null结尾的字符序列,用来表示符号名和节名,引用字符串时只需给出字符序列在表中的偏移即可。字符串表的第一个字符和最后一个字符都是null字符,以确保所有字符串的开始和终止。
符号表记录了目标文件中所用到的所有符号信息,通常分为.dynsym和.symtab,前者是后者的子集。.dynsym保存了引用自外部文件的符号,只能在运行时被解析,而.symtab还保存了本地符号,用于调试和链接。目标文件通过一个符号在表中的索引值来使用该符号。索引值从0开始计数,但值为0的表项不具有实际的意义,它表示未定义的符号。每个符号都有一个符号值,对于变量和函数,该值就是符号的地址。
重定位是连接符号定义与符号引用的过程。可重定位文件在构建可执行文件或共享目标文件时,需要把节中的符号引用换成这些符号在进程空间中的虚拟地址。包含这些转换信息的数据就是重定位项。
运行视角and可执行文件的装载(学了结构体再来看)
当运行一个可执行文件时,首先需要将该文件和动态链接库装载到进程空间中,形成一个进程镜像。每个进程都拥有独立的虚拟地址空间,这个空间如何布局是由记录在段头表中的程序头决定的。
通常一个可执行文件至少有一个PT_LOAD类型的段,用于描述可装载的节,而动态链接的可执行文件则包含两个,将.data和.text分开存放。动态段PT_DYNAMIC包含了一些动态链接器所必须的信息,如共享库列表,GOT表和重定位表等。
静态链接
地址空间分配
该部分讲述两个或多个不同的目标文件是如何组成一个可执行文件的
要想将两个或多个不同的目标文件组合在一起成一个可执行文件需要进行链接
链接由链接器完成,根据发生的时间不同,可分为编译时链接、加载时链接和运行时链接
现给出main.c和fun.c两个目标文件,将这目标文件链接成一个可执行文件有两种方案:
按序叠加:弊端是如果参与链接的目标文件过多,那么暑促的可执行文件会非常零散,而段的装载地址和空间以页为单位对齐,不足一页的代码节或数据节也要占用一页,这样就造成了内存空间的浪费
相似合并:将不同目标文件相同属性的节合并为一个节。这种方案被当前的链接器所采用,首先对各个节的长度、属性和偏移进行分析,然后将输入目标文件中的符号表的符号定义与符号引用统一生成全局符号表,最后读取输入文件的各类信息对符号进行解析、重定位等操作。相似节的合并就发生在重定位时。完成后,程序中的每条指令和全局变量就都有唯一的运行时内存地址了。
静态链接详细过程
该部分准备了main.c和fun.c两个程序分别如下
链接两个程序并生成中间文件
1 | gcc -static -fno-stack-protector main.c fun.c -save-temps --verbose -o fun.ELF |
- -static:告诉编译器在链接时只使用静态库。
- -fno-stack-protector:禁用堆栈保护,这通常用于减少程序大小或提高性能。
- main.c fun.c:指定要编译的源文件。
- -save-temps:保存临时文件(如汇编代码和预处理后的C代码)。
- –verbose:显示详细的编译过程信息。
- -o fun.ELF:输出的可执行文件名为fun.ELF。
1 | //main.c |
为了构造可执行文件指令文件链接器必须完成两个重要工作:符号解析和重定位。
- 符号解析是将每个符号(函数、全局变量、静态变量)的引用与其定义进行关联。
- 重定位则是将每个符号的定义与一个内存地址进行关联,然后修改这些符号的引用,使其指向这个内存地址。
1 | objdump -h fun.ELF |
其中VMA是虚拟地址,LMA是加载地址,一般情况下两者是相同的,可以看到尚未进行链接的目标文件main.o的VMA都是0。而在链接完成后的fun.ELF中,相似节被合并,且完成了虚拟地址的分配。
对main.o进行反汇编
可以看到main.o函数的地址从0开始。其中,对fun.o函数的调用在偏移0x26处,0xe8是CALL指令的操作码,后四个字节是被调用函数相对于调用指令的下一条指令的偏移量。此时符号还没有重定位,相对偏移为0x00000000,在这个目标文件中,CALL指令下一条MOV指令的地址为0x26,因此CALL指令调用的地址为0x26+(-0)=0x26,这只是一个临时地址,编译器其实并不知道位于一个文件中的fu.o函数的实际地址,于是就把地址计算的工作交给链接器;链接器将根据上一步的结果对重定位符号的地址进行修正。
链接完成后分配具体函数地址可在IDA中查看
可重定位文件中最重要的就是包含重定位表,用于告诉连接器如何修改节内容。每一个重定位表对应一个需要被重定义的节。
静态链接库
后缀名为.a的文件是静态链接库文件,如常见的libc.a。一个静态链接库可以视为一组目标文件经过压缩打包后形成的集合。执行各种编译任务时,需要许多不同的目标文件,比如输入输出有printf.o、scanf.o,内存管理有malloc.o等。为了方便管理,人们使用ar工具将这些目标文件进行了压缩、编号和索引,就形成了libc.a。
动态链接
什么是动态链接
当大部分可执行文件都需要glibc,那么在静态链接时就要把libc.a和编写的代码链接进去会造成内存占用过多,两个静态链接的可执行文件都包含同意个.o文件,那么在装载如内存时两个相同的库也会装载进去,造成内存空间的浪费。静态链接的另一个明显的缺点是,如果对标准函数做了改动,都需要重新编译整个源文件,使得开发和维护很艰难。
如果把系统库和自己编写的代码分割成两个独立的模块,等到程序运行时,再把这两个模块进行链接,就可以节省磁盘空间,并且内存中的一个系统库可以被多个程序共同使用,还节省了物理内存空间,这种在运行或加载时,在内存中完成链接的过程叫做动态链接,这些用于动态链接的系统库称为共享库,或者共享对象,整个过程由动态连接器完成。
GCC默认使用动态链接编译,通过下面的命令将fun.c编译为共享库,然后使用这个库编译main.c。参数-shared表示生成共享库,-fpic表示生成与位置无关的代码。这样可执行文件fun.ELF2就会在加载时与fun.so进行动态链接。ldd 指令在 Linux 操作系统中用于显示可执行文件或库的依赖信息。它列出了指定程序所依赖的所有动态链接库(共享库),以及这些库的具体路径。需要注意的是动态加载器ld-linux.so本身就是一个共享库,因此加载器会加载并运行动态加载器,并由动态加载器来完成其他共享库以及符号的重定位。
1 | gcc -shared -fpic -o fun.so fun.c |
- objdump:这是一个常用于查看和分析二进制文件的工具,可以提取、打印出程序中包含的信息。
- -d-M intel:
-d
参数表示对指定的文件进行反汇编。-M intel
参数指定使用Intel语法风格来输出反汇编结果,这种格式更接近人类可读的形式。- –section=.text:这个参数告诉 objdump 只关注并显示
.text
节的内容,即程序的执行代码部分。- func.ELE2:这是要被反汇编的目标ELF(Executable and Linkable Format)文件名。
- | grep -A 11 “<main>”
- 使用管道符将 objdump 的输出传递给 grep。
-A 11
参数表示在匹配到“”字符串后继续向下显示11行内容。 - “
” 是grep搜索的关键词,这里用来定位 main 函数的位置。
位置无关代码
可以加载而无需重定位的代码称为位置无关代码,它是共享库必须具有的属性,通过GCC传递-fpic参数可以生成PIC。通过PIC,一个共享库的代码可以被无限多个进程所共享,从而节约内存资源。
由于一个程序的数据段和代码段的相对距离总是保持不变的,因此,指令和变量之间的距离是一个运行时常量,与绝对内存地址无关。于是就有了全局偏移量表(GOT),它位于数据段的开头,用于保存全局变量和库函数的引用,每个条目占8个字节,在加载时会进行重定位并填入符号的绝对地址。
实际上,为了引入RELRO保护机制,GOT被拆分为.got节和.got.plt节两个部分,不需要延迟绑定的前者用于保存全局变量引用,加载到内存后被标记为只读;需要延迟绑定的后者则用于保存函数引用,具有读写权限(ret2libc题即利用该表)
延迟绑定
由于动态链接是由动态链接器在程序加载时进行的,当需要重定位的符号(库函数)多了之后,势必会影响性能。延迟绑定就是为了解决这一问题,其基本思想是当函数第一次被调用时,动态链接器才进行符号查找、重定位等操作,如果未被调用则不进行绑定。
ELF文件通过过程链接表和GOT的配合来实现延迟绑定,每个被调用的库函数都有一组对应的PLT和GOT。(plt里存放的是汇编指令,用于跳转到got,got里存放了地址 )
位于代码段.plt节的PLT是一个数组,每个条目占16个字节.其中PLT[0]用于跳转到动态链接器,PLT[1]用于调用系统启动函数__libc_start_main(),我们熟悉的main()函数就是在这里面调用的,从PLT[2]开始就是被调用的各个函数条目。
位于数据段.got.plt节的GOT也是一个数组,每个条目占8个字节。其中GOT[0]和GOT[1]包含动态链接器在解析函数地址时所需要的两个地址(.dynamic和relor条目),GOT[2]是动态连接器ld-linux.so的入口点,从GOT[3]开始就是被调用的各个函数条目,这些条目默认指向对应PLT条目的第二条指令,完成绑定后才会被修改为函数的实际地址。
RIP 寄存器(Instruction Pointer Register)是 x86-64 架构中的一个特殊寄存器,用于存储当前正在执行的指令的下一条指令的地址。在 x86-64 架构中,RIP 寄存器通常被称为“程序计数器”或“指令指针”。
主要功能
- 指示下一条指令的位置:
- RIP 始终指向即将执行的下一条指令的地址。
- 这使得 CPU 可以按顺序执行指令流。
- 支持相对寻址:
- 在 x86-64 汇编中,RIP 相对寻址是一种常见的内存寻址方式。
- 通过使用
[rip + offset]
形式的地址表达式,可以方便地访问与当前指令位置相关的数据。工作原理
- 当 CPU 执行完一条指令后,RIP 会自动更新为下一条指令的地址。
- 如果遇到跳转、调用或返回等控制转移指令,RIP 会被显式设置为目标地址。
QWORD PTR [rip + 0x200a72]
是 x86-64 汇编语言中的一种内存寻址方式。具体解释如下:
- QWORD:表示一个 64 位的数据类型(Quad Word)。
- PTR:指针操作符,用于指定后面的地址表达式所指向的数据类型。
- **[rip + 0x200a72]**:这是一个相对于当前指令指针(RIP)的偏移量。