编辑
2023-03-24
Binary Analysis
00
请注意,本文编写于 741 天前,最后修改于 741 天前,其中某些信息可能已经过时。

目录

C编译过程
预处理
编译
汇编
链接
静态库
动态库
完整流程
符号和剥离
查看符号信息
剥离符号
反汇编
对象文件
二进制程序
加载

👴🏻又开新坑🌶

C编译过程

预处理 -> 编译 -> 汇编 -> 链接

预处理

c
#include <stdio.h> #define FORMAT_STRING "%s" #define MESSAGE "Hello,World!\n" int main(void) { printf(FORMAT_STRING, MESSAGE); return 0; }

使GCC在预处理阶段之后停下 需要使用-E选项 -P选项忽略调试信息

bash
gcc -E -P 1.c
c
typedef long unsigned int size_t; typedef unsigned char __u_char; typedef unsigned short int __u_short; typedef unsigned int __u_int; ... ... int main(void) { printf("%s", "Hello,World!\n"); return 0; }

可以看到gcc会输出预处理之后的源代码 可以发现此时gcc会将我们源代码中的stdio.h头文件全部包含进源代码中 并且对我们在源码中的define也会进行处理 直接替换进源码中

编译

编译阶段GCC会将预处理之后的源代码转换成汇编代码 大多数编译器都可以在此阶段选择进行优化

为什么编译器不直接生成机器代码而是先转换一步汇编代码?

从单一语言出发这一步是没有必要的 但是从整体编程语言出发 如果编译器直接生成机器代码 那么每个编译器都需要实现一套机器代码生成器 这样会造成大量的重复工作 但是如果编译器先生成汇编代码 那么每个编译器只需要实现一套汇编代码生成器就可以了

这里可以使用-S要求GCC在编译阶段后停下

bash
gcc -S -masm=intel 1.c

汇编

汇编阶段的输入是在编译阶段生成的汇编语言集 输出是一组对象文件
对象文件原则上包含可以由处理器执行的机器指令
可以使用-c参数让GCC输出对象文件

bash
gcc -c 1.c

file命令显示了改对象文件符合二进制可执行文件的ELF规范 并且是LSB(最低有效位) 即内存排序从小到大生长 并且是relocatable[可重定位的]
这里的可重定位的概念和操作系统中的可重定位一致 即装载程序时不依赖于具体的内存地址 这样做方便对象文件相互独立编译并且可以按照任意顺序将对象文件链接到一起从而形成完整的二进制程序

链接

编译过程的最后阶段 链接会将前面编译出来的所有对象文件链接到一个二进制可执行文件中 链接阶段通过链接器以实现目的 链接器与编译器通常是相互独立工作的[原理之前讲过] 编译器负责执行以上所有步骤 链接器负责执行链接
之前的所有对象文件因为是独立编译的 所以就编译器自身是没有办法确定对象文件在最后执行的时候在整体代码段的具体地址上[无法确定基址] 并且对象文件也会引用其他对象文件中或者库文件中的数据[函数或者变量] 所以对象文件中包含的所有引用都只包含重定位符号 这些重定位符号决定最终如何解析函数和变量引用

在链接上下文忠依赖于重定位符号的引用称为符号引用
同时因为基址是不确定的 所以即使在对象内部使用绝对地址引用自己的函数或者变量该地址也会被符号化

链接库的执行流程就是获取该程序的所有对象文件 然后解析引用将其合并为一个连贯的可执行文件 架子啊到特定的内存地址 其中在解析库文件的时候会根据库文件的类型决定对其是否完全解析

静态库

静态库会完全合并进二进制可执行文件中 允许完全解析所有对静态库的引用
这种方法也就是我们通常说的静态编译

动态库

动态库也叫共享库 所有在操作系统上运行的程序共享同一个库文件 对于动态库的引用在最后实际执行加载到内存的时候才会解析对动态库的引用

完整流程

bash
gcc 1.c

可以看到编译出来的文件是ELF规范的64位LSB可执行文件 并且是动态编译的 也就是动态库还没有装入程序

符号和剥离

符号其实就是当初编程者自己命名的函数和变量名称 在编译时 编译器会翻衣服好 根据符号跟踪其名称 比如高级函数名称到第一个地址到每个函数大小的映射 链接器会在链接时使用到这些信息 并且符号也可以帮助我们调试

查看符号信息

bash
readelf --syms a.out

bash
Symbol table '.dynsym' contains 4 entries: Num: Value Size Type Bind Vis Ndx Name 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 1: 0000000000000000 0 FUNC GLOBAL DEFAULT UND puts@GLIBC_2.2.5 (2) 2: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __libc_start_main@GLIBC_2.2.5 (2) 3: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__ Symbol table '.symtab' contains 67 entries: Num: Value Size Type Bind Vis Ndx Name 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 1: 0000000000400238 0 SECTION LOCAL DEFAULT 1 2: 0000000000400254 0 SECTION LOCAL DEFAULT 2 ... 54: 0000000000601028 0 NOTYPE GLOBAL DEFAULT 25 __data_start 55: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__ 56: 0000000000601030 0 OBJECT GLOBAL HIDDEN 25 __dso_handle 57: 00000000004005c0 4 OBJECT GLOBAL DEFAULT 16 _IO_stdin_used 58: 0000000000400540 101 FUNC GLOBAL DEFAULT 14 __libc_csu_init 59: 0000000000601040 0 NOTYPE GLOBAL DEFAULT 26 _end 60: 0000000000400430 42 FUNC GLOBAL DEFAULT 14 _start 61: 0000000000601038 0 NOTYPE GLOBAL DEFAULT 26 __bss_start 62: 0000000000400526 21 FUNC GLOBAL DEFAULT 14 main 63: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _Jv_RegisterClasses 64: 0000000000601038 0 OBJECT GLOBAL HIDDEN 25 __TMC_END__ 65: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_registerTMCloneTable 66: 00000000004003c8 0 FUNC GLOBAL DEFAULT 11 _init

其中我们可以看到二进制文件加载到内存中指定main函数的指定加载地址为0x400526

剥离符号

很显然 之前我们编译出来的a.out是没有经过任何处理的 所以他包含着符号 同时file的输出也佐证了这一点(no stripped)

可以使用strip来剥离符号

bash
strip --strip-all a.out

这里仅剩的一些符号都是动态库上的符号了 这些符号是不能被剥除的 毕竟只有等装载的时候才能确定其地址 现在剥除了之后装载的时候就找不到函数了

反汇编

对象文件

.rodata段内存储着所有的只读数据 二进制文件中的所有常量都存储在该段中

bash
objdump -sj .rodata 1.o

objdump可以使用-M来指定汇编风格

bash
objdump -M intel -d 1.o

这里只有一个main函数的汇编是因为这是源文件中唯一定义的函数 其中奇怪的是我们可以发现在9的位置这里调用了一个main函数内部的位置 且其参数为0 通过对比源文件很显然此处应该是pust才对
这里其实是因为我们反汇编的是对象文件 还没有进行链接 对于对象文件中的引用还没有开始解析 对象文件需要链接器对此处的引用来填充正确的值

bash
readelf --relocs 1.o

第一行是告诉链接器解析对字符串的引用 指向.rodata段
第二行是告诉链接器解析对puts的引用
这里有个规律是readelf指出的偏移值是需要固定的指令的偏移量+1 因为我们通过前面反汇编的结果可以发现 这里我们的操作码都是指定好的 例如movcall真正需要覆盖的只是操作数而已 那么跳过操作码的一个字节就是偏移量+1了

二进制程序

这里其实没什么讲的 跟上述操作一样 只不过可以发现剥离符号之后的二进制程序杂乱许多(废话)

加载

当运行一个二进制文件的时候 操作系统会为该程序创建进程 然后分配资源 其中包括虚拟地址空间
然后操作系统会将解释器映射到虚拟内存中

解释器: 一个用户态程序 其知道如何加载程序并执行必要的重定位
在Linux中其通常为一个名为ld-linux.so的共享库
在Windows中其功能通常为一个叫ntdll.dll的动态库实现

在加载解释器之后内核将控制权交给解释器 随后解释器开始其在用户态的工作

其中在Linux中ELF二进制文件包含一个.interp的特殊段 该段内存储着解释器的路径

bash
readelf -p .interp a.out

解释器将二进制文件加载进虚拟地址空间 然后解析器会找到二进制程序使用的动态库 并且也将动态库映射到虚拟地址空间 最后在二进制代码中执行所有必要的重定位 填充正确的地址以引用动态库
实际上因为Linux存在延迟绑定机制 也就是通常上会将动态库中对函数的引用的解释过程推迟 直到调用函数的时候才解析

本文作者:Du4t

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!