二进制安全学习-0x01.compiler&linker

前言

还在学习二进制的过程中摸索,推荐一下Sakura师傅的知识星球。长期的计划是读完csapp并完成lab与相关代码编写后,再进行内核学习。研究生选择的方向是IoT安全,可能还要抽时间进行固件仿真/IoT文件结构/fuzz的学习。

前段时间拜读了《程序员的自我修养》,本文主要对书中程序的链接与可执行文件的装载过程做一个简要总结与相关lab,下一篇准备总结一下函数调用堆栈过程及栈溢出。《程序员的自我修养》较为基础地讲解了Linux下程序的loader&linker,值得反复阅读。初次阅读,文中难免有错误之处,还请师傅们指正。

环境

Ubuntu虚拟机

0x00.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include<stdio.h>

extern int gdata10;
extern int sum(int, int);

int gdata1 = 10; //初始化不为0 .data段
int gdata2 = 0; //初始化为0 .bss段
int gdata3;

static int gdata4 = 11;
static int gdata5 = 0;
static int gdata6;

int main(void){
int a = 12; //代码段
int b = 0; //
// int c; //
int c = gdata10;
static int d = 13;
static int e = 0;
static int f;

sum(a,b);

//getchar();
//getchar();

return 0;
}

sum.c

1
2
3
4
5
6
int gdata10 = 13;

int sum(int a, int b)
{
return a+b;
}

Compiler

compiler&linker过程

一个编写好的main.c程序想要执行,需要生成可执行文件。首先,main.c程序经过以下环节的编译过程,最终输出一个二进制可重定位文件,再把可重定位文件进行链接,从而获得可执行文件。

  • 预编译:去注释/去预编译指令 //gcc -E hello.c -o hello.i
  • 编译:代码优化/汇总所有符号 //gcc -S hello.i/hello.c -o hello.s
  • 汇编:将汇编代码转变成机器可执行的指令 //gcc -c hello.s/hello.c -o hello.o

链接:只对所有可重定位文件的global符号进行处理,local符号(例如static)不处理

  1. 合并所有obj文件的段->调整段偏移和长度->合并符号表->进行符号解析->分配内存地址。
  2. 符号重定位(核心)
  3. 链接obj文件命令:ld -e main -o run *.o //-e 程序的开始执行点符号 -o 生成文件名

程序虚拟地址空间与ELF文件格式

虚拟内存是个抽象概念,它为每个进程提供了一个假象,即每个进程都在独占地使用主存。每个进程看到的内存是一样的,称为每个进程的虚拟地址空间。 ——《深入理解计算机系统》

以x86 32位linux系统为例,将程序虚拟地址空间手绘,64位下的指针长度为64位8字节,空间布局与32位类似:

每个进程看到的虚拟地址空间由大量准确定义的区构成,每个区都有专门的功能。前128M的地址空间(0x00000000-0x08048000)是预留的不可访问的,接下来分别是程序代码和数据、堆、共享库、栈和内核虚拟内存。这里简要介绍几点:

  1. 代码和数据去在进程一开始运行就被制定了大小,与此不同,当调用类似malloc/free这样的c语言标准库函数时,堆可以在运行时动态地扩展和收缩。
  2. 共享库是一块用来存放C标准库和数学库这样地共享库的代码和数据的区域。
  3. 栈用来实现函数调用,具体的工作方式在下一篇文章详述,栈的高地址方向还存了命令行参数与环境变量。

ELF文件结构:

text段为代码段,data段和bss段都为数据段,区别是data段存放初始化了的全局变量和静态变量,bss段存放未初始化或初始化为0的全局变量和静态变量。bss段只是为未初始化的全局变量和局部静态变量预留位置,并没有内容,不占文件的空间,占虚拟内存空间。程序运行时,bss段的数据初始值默认为0,通过段表记录将来要占多大内存。局部非静态变量是代码段内容,在使用其值时将其压入堆栈,将在下篇文章中详细叙述。除此之外,完整的obj文件结构还可能具有rodata段/段表/重定位表/符号表/等,这里不一一叙述,可以参考原书。这里只介绍一下段表(section stable)中保存了每个段的详细信息,而段表的地址又存在ELF文件头里,这样程序运行时系统通过阅读ELF Header就可以得到该程序的整个文件结构。

相关知识补充

  1. text段中只有函数名存在符号表

  2. objdump命令参数:

    • -h 打印各个段基本信息
    • -t 打印符号表
    • -d/S 打印函数反汇编
    • -s 打印每个段存储的内容

    readelf命令参数:

    • -h 打印elf header
    • -S 打印段信息
    • -l 打印program headers(后续介绍)
  3. 使用gcc -c xx.c命令将0x00.c和sum.c编译为两个obj文件,继续使用上述相关知识补充[2]的命令对本文介绍的文件格式信息进行查看,以加深印象。

Linker

回顾一下链接操作的大致流程:

  1. 合并所有obj文件的段->调整段偏移和长度->合并符号表->进行符号解析->分配内存地址。
  2. 符号重定位(核心)

链接过程中的符号解析:所有obj符号表中对符号引用的地方都要找到该符号定义的地方,例如引用其他文件中的符号UND gdata10,要找到sum.o中.data段的gdata10。如果两个文件同时存在.data段的gdata10,会出现符号重定义错误。

这里可以引出强符号与弱符号的概念:在发生符号重复定义问题时,初始化了的符号(包括初始值为0)是强符号,未初始化的是弱符号,弱符号存储在COMMON块中。这时根据情况选择强符号或占用空间最大的弱符号,如果存在两个以上强符号,链接器报错。

关于COMMON块的讨论

早期的Fortran没有动态分配空间的机制,程序员必须事先声明它所需要的临时空间大小,这种空间叫COMMON块。

先谈结论:对于全局变量来说,如果初始化了不为0的值,那么该全局变量则被保存在data段,如果初始化的值为0,那么将其保存在bss段,如果没有初始化,则将其保存在common段,等到链接时再将其放入到BSS段。关于第三点不同编译器行为会不同,有的编译器会把没有初始化的全局变量直接放到BSS段(目前总体来看)。

test1.c

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>

int init = 0;

void init1()
{
if (0 == init) {
init = 1;
printf("init1\n");
}
}

test2.c

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>

int init = 1;

void init2()
{
if (init) {
init = 0;
printf("init2\n");
}
}

main.c

1
2
3
4
5
6
7
8
9
10
void init1();
void init2();

int main()
{
init1();
init2();

return 0;
}

如果把test1.c中的int init = 0改为int init,再重新编译test1.c。对于test1.c来说,这个改动不影响其逻辑,因为init如果未初始化,其值也应该是0。 在部分编译环境中,此时就不报错了。因为在修改之前。test1.c中的init被初始化为0,它是一个强符号。而test2.c中,定义了init为1,也是一个强符号,所以引发了错误。修改之后,当test1.c中的init不进行初始化,尽管其值仍然为0,但是其被保存在common段,为一个弱符号。当test2.c中定义了init为1一个强符号,那么在链接的过程中,gcc会用这个强符号覆盖掉弱符号,并不会引起链接冲突错误。

但是,总体来看,还是会把没有初始化的全局变量直接放到BSS段,导致仍无法解决错误。

所以,综上所述,我们需要注意,当定义全局变量时,有两点需要注意:

  1. 如果只有本文件使用,那么需要添加上static;
  2. 即使没有使用static,那么一定要为该全局变量定义初值,即使这个值就是0。这样可以保证该变量为强符号,当名字冲突时,可以发现,而不是被未知的值覆盖。

注:在编译阶段,还可以通过-fno-common选项来禁止将未初始化的全局变量放入到common段。

符号重定位

可执行文件会按页面对齐(以页面为单位,32位常用页面4K),因此obj段合并时,所有相同属性的段进行合并,并组织在一个页面上。

这里对两个obj文件进行链接:ld -e main -o run *.o

符号重定位后,新的地址存的是相对下一条指令地址(即pc寄存器值)的偏移量:即下一条指令的地址+偏移=该全局变量实际地址

例1

例2

可执行文件

链接得到的可执行文件run的格式相比起obj文件的格式,在ELF Header之后增加了名为program header的块,回忆使用readelf -S run命令打印段信息时,打印的是一个个段的section header,section header用来描述每个section的特征,而program header用于描述segment的特性,可执行文件格式中的各个段分门别类地按页面对齐形成segment,一个segment包含一个或多个现有的section,相当于从程序执行的角度来看待这些section。obj目标文件不存在program header,这也是它不能运行的关键。

通过使用readelf -h run命令查看可执行文件run的ELF Header信息,可以发现该文件有7个文件头。总结一下:从ELF Header可以得到program headers和section headers的信息,而后又可以通过program header来获得每个segment的属性,通过section header来获得每个section的属性。

使用readelf -l run命令打印program headers信息,每个program header对应下方一个segment section。

其中四个LOAD项用来加载对应的四组包含原obj文件中各个段的四组segment,表面哪些段加载到哪个LOAD页面上。LOAD项的对齐方式为0x1000,即一个页面大小4k。

如果不使用ld进行手动链接,而使用gcc -o run *.o链接,会自动链接许多c++库,再通过readelf -l run查看一个LOAD页面对应哪些段,会发现program header和段的数目都增加了。

程序的运行

程序的运行->进程的步骤

  1. 创建虚拟地址空间到物理内存的映射(创建内核地址映射结构体),创建页目录和页表

  2. 加载代码段和数据段

  3. 把可执行文件的入口地址写到CPU的PC寄存器

运行一个新的程序

磁盘中的可执行文件最初不是直接按页存储的,而是按段存储,但LOAD项指明了当前文件哪些段加载时需要放到一个页面,下图表明了文件从磁盘映射到虚拟地址空间再映射到物理内存的过程。

Linux下,运行一个新的程序,需要先fork一下,继承父进程的所有命令行参数和环境变量,再调用execve函数替换当前子进程的代码段数据段,最后调用mmap函数把磁盘上的页面向虚拟地址空间映射。除此之外,如果用到c语言库里的函数,要从c语言共享库libc.so加载到虚拟地址空间内堆栈段中间的共享库位置,虚拟地址空间的页面在使用时通过多级页表最终映射到物理内存。

fork机制

fork+execve

进程的虚拟地址空间

上一节介绍了在运行一个新的程序时,磁盘上的程序通过mmap函数映射到虚拟地址空间上。进程虚拟地址空间的分配可以参考前文程序虚拟地址空间图,为了让程序运行不会一下结束,在代码中添加getchar()函数让程序停住。

执行./run &命令让程序在后台运行,再通过ps -u命令查看进程的PID,最后执行cat /proc/PID/maps命令查看该进程的虚拟地址空间。

总结

《程序员的自我修养》还需要进一步阅读,接下来先完成csapp的相关学习,并抽时间进行pwnable.tw 的练习。

Reference

https://blog.csdn.net/chris_magic/article/details/7275318

《程序员的自我修养 —链接、装载与库》

《深入理解计算机系统》