一、程序的编译与执行
Program Header
通常,我们写的代码都是编译、链接一气呵成,直接生成可执行文件,并且程序编译出来的虚拟起始地址通常是0x08048000,操作系统做了很多工作。例如将program.c编译和链接成可运行的文件:
1 | gcc program.c -o program |
这其中经过了编译和链接两个步骤:
1 | gcc program.c -o program.o |
得到的program.o只是一个待重定位文件,文件里面的符号(函数和变量)还没有安排地址,将来在链接的时候与其他文件“组合”成一个可执行文件时再重新定位(安排地址)。“组合”指的就是链接。因为在编译期间不知道会链接那些文件,所以干脆在链接的阶段一起编址,形成一个可重定位文件。
程序之间调用的最简单的方式是call和jmp,例如BIOS调用MBR以及MBR调用Loader,MBR的物理地址是0x7c00,而Loader的地址可以是0x900,通常事先约定好调用地址。这种方法非常不灵活,因此一种灵活的方法便是程序的入口地址信息与程序绑定,在程序文件中专门腾出一个空间来写程序的入口地址、程序的大小等等信息。原先的可执行二进制文件(program body)加上新的文件头(program header),就形成了一种新的文件格式这种具有程序头文件格式的程序文件从外存读入内存中后,从该程序文件的头读出程序入口地址,跨过程序头,跳转到入口地址执行。
ELF文件格式
ELF文件格式整体视图
Windows系统下的可执行文件格式是PE(Portable Executable,exe是文件拓展名,不是真正的格式),而Linux的可执行文件格式是ELF(Executable and Linkable Format,可执行连接格式)。ELF文件是经过编译和链接之后,可以直接运行的二进制可执行文件。Linux中的.o文件和可执行二进制文件都是ELF格式的文件。ELF文件格式可以在/usr/include/elf.h中可以找到ELF文件格式的所有定义。
程序最重要的概念是段(segment)和节(section),其中section是程序员在进行汇编程序设计时显示划分出的数据区、代码区、栈区等等,而不同程序在链接时,链接器将多个目标文件相同属性的section链接成一个segment,形成了可执行内存空间中的数据段、代码段等等。因此ELF格式重要有相应的数据结构来描述程序中不同的section和segment,一个段头(Program header,也叫程序头)用来描述一个段,一个节头(Section header)用来描述一个section,也就有了程序头表(Program header table,也可以称之为段头表)和节头表(Section header table),本质就是用来分别存储段头和节头的两个数组。而程序头表和节头表的条目个数和表长也是不确定的,因此还需要另一个结构来描述程序头表和节头表,也就是ELF头(ELF Header),因此整个ELF文件格式看起来如下图所示(图片来源:《操作系统真相还原》)。ELF文件格式真正的作用在链接和运行阶段,因此ELF文件格式布局也从这两方面展示。
ELF文件格式视图
链接视图 | 运行视图 |
ELF Header(elf头) | ELF Header(elf头) |
Program Header Table(程序头表,非必须,可选) | Program Header Table(程序头表) |
Section 1(节 1) | Segment 1(段1) |
... | |
... | Segment 2(段2) |
Section n(节 n) | |
... | ... |
Section Header Table(节头表) | Section Header Table(节头表,非必须,可选) |
... | ... |
待重定位文件体(Program Body) | 可执行文件体(Program Body) |
ELF Header数据结构
ELF Header结构定义在/usr/include/elf.h中的struct Elf32_Ehdr中,结构体定义如下所示:
1 | typedef struct |
其中e_ident[16]是16字节大小的数组,用来表示魔数以及其他的信息,具体含义如下表所示。
e_ident[16]数组
e_ident[]成员 | 意义 |
e_ident[0] = 0x7f | 这4位是ELF文件的魔数(magic number),表明这是一个ELF文件,e_ident[1]~e_ident[3]这3个变量表示‘E’、‘L’、‘F’这三个字符 |
e_ident[1] = 'E' | |
e_ident[2] = 'L' | |
e_ident[3] = 'F' | |
e_ident[4] | ELF文件类型,值为0表示不可识别,值为1表示32位elf文件,值2表示64位elf文件 |
e_ident[5] | 编码格式,值为0:非法编码,值为1:小端字节序LSB,值为2:大端字节序MSB |
e_ident[6] | 版本信息,默认为1 |
e_ident[7~15] | 保留位,初始化为0 |
而struct Elf32_Ehdr中的所有成员的定义如下:
struct Elf32_Ehdr成员定义
e_ident[16] | 魔数和其他信息 |
e_type | 2字节,目标文件类型,值0:位置格式文件,值1:可重定位文件,值2:可执行文件,值3:动态共享目标文件,值4:core文件(程序崩溃时内存映像转储格式),其他值无需关注 |
e_machine | 2字节,elf文件所属体系结构,值2:SPARC,值3:Intel 80386,值7:Intel 80860,值8:MPIS RS3000等等 |
e_version | 4字节,版本信息 |
e_entry | 4字节,操作系统运行该程序时,将控制权转交到的虚拟地址 |
e_phoff | 4字节,程序头表(program header table)在文件内的字节偏移量,若没有程序头表,则该值为0 |
e_shoff | 4字节,节头表(section header table)在文件内的字节偏移量,若没有节头表,则该值为0 |
e_flags | 4字节,处理器相关的标志 |
e_ehsize | 2字节,elf header的字节大小 |
e_phentsize | 2字节,程序头表(program header table)的每个条目(entry)的字节大小,该条目就是后面将要引出的struct Elf32_Phdr |
e_phnum | 2字节,程序头表(program header table)的条目(entry)个数,即程序中有多少个段 |
e_shentsize | 2字节,节头表(section header table)的每个条目的字节大小 |
e_shnum | 2字节,节头表(section header table)的条目个数 |
e_shstrndx | 2字节,Section header string table在节头表中的索引 |
接下来是程序头表中条目的数据结构,也就是用来描述各个段(segment)的信息,其结构体为struct Elf32_Phdr,如下所示:
1 | /* Program segment header. */ |
结构体中的各个成员的信息如下表所示:
struct Elf32_Phdr成员定义
p_type | 4字节,表示程序中该段的类型,值1:可加载程序段,值2:动态链接信息,值3:动态加载器名称,值6:程序头表,以及其他无需关注的信息 |
p_offset | 4字节,本段在文件内的起始偏移地址 |
p_vaddr | 4字节,本段在内存中的起始虚拟地址 |
p_paddr | 4字节,仅用于与物理地址相关的系统如System V中 |
p_filesz | 4字节,本段在文件中的大小 |
p_memsz | 4字节,本段在内存中的大小 |
p_flags | 4字节,本段相关的标志,0b1:可执行,0b10:可写,0b100:可读,以及其他标志 |
p_align | 4字节,本段在内存和文件中的对齐方式,若值为0或值1,则不对齐,否则其为2的幂次 |
实验程序实例
一共两个程序,分别为parent.c和child.c,其中在parent.c中父进程会fork一个子进程,子进程会利用execve系统调用来将child.c加载进自己的进程空间,执行这一段新的程序。
这个是父进程执行的程序:
1 | /*parent.c*/ |
这个是子进程加载的新程序:
1 | /*child.c*/ |
ELF Header实例
可以利用xxd命令来查看ELF文件格式信息,新建一个xxd.sh脚本,其内容为:
1 | xxd -g 1 -s $2 -l $3 $1 |
然后在命令行中输入
1 | sh ./xxd.sh ./child 0 300 |
即可查看child文件的ELF格式信息。上面参数的意思是查看0~300字节的ELF文件格式信息。在学校提供的实验平台上得到的结果如下所示:
1 | 00000000: 7f 45 4c 46 01 01 01 03 00 00 00 00 00 00 00 00 .ELF............ |
因此child文件的ELF格式信息具体含义如下,其中0x00到0x33号内存单元是ELF Header(即struct Elf)的内容,0x34到0xf4为program header(即struct Elf32_Phdr)得内容,首先分析ELF Header信息:
child文件的ELF Header信息
成员 | 值 | 含义 |
e_ident[4] | 0x01 | 32位的elf文件 |
e_ident[5] | 0x01 | 小端字节序 |
e_type | 0x0002 | 可执行文件 |
e_machine | 0x0003 | intel 80386平台 |
e_entry | 0x08048d0a | 程序的虚拟入口地址为0x08048d0a |
e_phoff | 0x00000034 | 程序头表在文件中的偏移量是0x34 |
e_shoff | 0x000a340c | 节头表在文件中的偏移量 |
e_ehsize | 0x0034 | elf header大小为0x34,可见程序头表跟着elf头 |
e_phentsize | 0x0020 | 程序头表program header每个条目(struct Elf32_Phdr)的大小为0x20 |
e_phnum | 0x0006 | 程序头表中的元素个数为6,即有6个段 |
e_shentsize | 0x0028 | 节头表中各个节的大小 |
e_shnum | 0x0024 | 节头表中元素个数,说明一共0x24=36个节 |
e_shstrndx | 0x0021 | string name table在节头表中的索引为0x21 |
然后分析程序头表program header的内容(第4行的0x34开始),刚才ELF Header中可以知道,程序一共有6个段,且每个段的条目的大小为0x20。
child文件的Program Header信息
成员 | 值 | 含义 |
p_type | 0x00000001 | 该程序为可加载程序段 |
p_offset | 0x00000000 | 本段在文件内的偏移量为0x00 |
p_vaddr | 0x08048000 | 该段被加载到内存后的起始虚拟地址!Elf Header中的e_entry是整个程序的入口地址(0x08048d0a),而整个程序的起始地址是0x08048000。 |
p_paddr | 0x08048000 | 和p_vaddr相似,不用管这个 |
p_filesz | 0x000a006f | 本段在文件中的字节大小 |
p_memsz | 0x000a006f | 本段在内存中的字节大小,等于p_filesz |
p_flags | 0x00000005 | 5=4+1,因此该段可读可执行,据此推测出该段应该是代码段。 |
p_align | 0x00001000 | 本段的对齐方式为32字节对齐 |
接下来看数据段的信息,在gdb调试窗口中打印str2的地址,为0x80ea080,并且数据段线性区的地址范围为0x80e9000到0x80ec000。
除了利用xxd命令查看ELF文件格式外,还可以利用readelf命令来查看。简单的输入:
1 | readelf -e child |
即可得到如下信息:
1 | ELF Header: |
上面包含了ELF Header,所有Program Header以及Section Header的信息,最后还指明了每个section会整合进哪一个segment中,可以看到数据段应该是第2个segment。
进程的创建与程序加载
整体视图
在shell终端输入./parent,shell进程会调用fork()和execve()来创建新进程并加载./parent文件映像到父进程空间,然后parent进程又会同样调用fork()和execve()来创建新进程并加载./child文件映像到子进程空间。
Fork系统调用
fork函数的原型是pid_t fork(void),返回值有3种:子进程pid、0、-1。如果fork失败,那么返回-1。为了让父进程知道自己创建的子进程号,fork会给父进程返回子进程的pid,并且没有pid为0的的进程,因此fork给子进程返回0,通过返回值将父子进程区分开。调用fork之后,子进程会完全拷贝父进程的地址空间,因此两份进程的代码是一样的,只不过子进程是在fork系统调用才开始执行代码的,两者在if语句分道扬镳,就像一个叉子一样。
在Unix系统中提供了3种创建进程相关的系统调用: fork、vfork和clone,三种系统调用的区别如下:
系统调用 | 区别 |
---|---|
fork | 无参数,子进程是父进程完整拷贝:复制父进程所有资源包括地址空间(mm_struct:包含指向页目录表和页表的指针)、页表、打开文件表、信号处理等,新版内核增加了写时复制COW,fork的代价仅剩拷贝父进程页表 |
vfork | 无参数,父子进程共享地址空间:同一个mm_struct,无需复制,子进程完全运行在父进程的地址空间上,因此子进程修改变量,父进程的变量也会改变。为防止父进程重写子进程需要的数据,阻塞父进程执行,直到子进程退出或者使用exec加载新的程序 |
clone | 有参数,父进程的资源有选择性的拷贝给子进程:clone_flags参数共享哪些资源,其余资源进行复制 |
在调用fork系统调用时,创建的新任务具有父进程的所有相关数据的副本,更高版本的内核增加了写时复制(Copy On Write),父子进程共享一组资源,例如数据段,设置为只读,如果子进程修改了数据段中的变量,数据段中的内容拷贝到新的内存中再进行修改,因此子进程对变量修改不会影响父进程。调用clone系统调用时,创建的新任务并不具有所有数据的拷贝,clone_flags参数决定共享哪些资源,例如CLONE_VM决定共享相同的内存空间,CLONE_FILES决定共享相同的打开文件。如果不设置这些参数,那么clone与fork功能类似。在内核中三种函数的执行流程如下:
- 注:在Linux中fork()利用clone()来实现,在C程序中调用fork()函数会触发120号系统调用clone,不会触发2号系统调用fork!我猜应该是Linux中采用了写时复制技术,很多资源父子进程共享,所以不用全部拷贝,部份拷贝,其余资源共享即可,所以用clone系统调用。(以上过程在i386-32位平台上实现)
因此,parent.c程序中执行fork和execve系统调用时,数据段中的str1字符串发生的改变如下:
1、刚进入main函数,父进程指向str1所在物理页面,且页面可写;
2、fork()函数执行后,父子进程共同指向str1物理页面,且页面只读;
3、子进程试图修改str1的内容,发生写时复制,str1拷贝到新内存区域防止篡改父进程数据;
4、执行execve后子进程的mm_struct以及页目录项、页表项全部改变;
如果将C程序中的fork()改为vfork(),则父子进程的mm_struct、页目录和页表项均共享,可以直接篡改对方进程的数据:
Execve系统调用
execve()函数是exec函数家族的一员,exec函数簇的6个函数功能类似,差别在于程序变量的表示方式和是否传入环境变量。exec会将可执行文件的绝对路径作为参数,把当前正在运行的用户的进程体(代码段、数据段、堆、栈、)用该可执行文件的进程体替换。
在shell终端输入一个可执行程序的文件名,shell程序会先用fork系统调用创建子进程,然后再调用execve系统调用,利用新的可执行文件的进程体替换fork出来的子进程的进程体,从而实现新进程执行完全不一样的新程序。
execve函数定义如下
1 | int execve(const char *filename, char *const argv[], char *const envp[]); |
三个参数分别是是可执行文件名、命令行参数和环境变量。execve()函数执行的主要轨迹如下:
1 | execve() |
exec函数簇的6个函数最终都会执行execve()系统调用,在sys_execve()服务例程中,do_execve()函数调用do_execveat_common()来完成结构体struct linux_binprm bprm的初始化,用来记录可执行文件的信息。该函数还会调用path_look_up()和dentry_open()获得可执行文件相关的目录项对象、文件对象和inode对象(《深入理解Linux内核》),执行完该函数后,进入load_elf_binary()函数之前,struct linux_binprm bprm中的内容如下所示:
1 | { |
其中buf为128字节,这些字节包含的是ELF文件格式的魔数和其他信息(即ELF Header),用八进制表示,可以看到开头的’\177ELF’就是前面说到的ELF Header的开头几个魔数。并且此时的vma(vm_area_Struct)和mm(mm_struct)已经分配好了但是还没有初始化,将来会用新的mm和vma来替换当前的mm和vma。会在接下来的load_elf_binary()函数中完成该过程。
此时内存的情况如下所示:
load_elf_binary()函数为可执行文件的接口,执行的过程如下:
1、动态创建一个结构体:
1
2
3
4
5struct
{
struct elfhdr elf_ex;
struct elfhdr interp_elf_ex;
} *loc;然后从传入参数的bprm->buf中获得elf header的信息,并进行校验:魔数必须匹配,程序的类型必须为ET_EXEC或者ET_DYN,检查bprm->file->f_op->mmap,指向的文件是否已经映射到内存中;
2、查找解释器段
遍历所有程序头(一共6个),通过遍历每个段,找到PT_INTERP类型段,也即是解释器段,找到说明需要运行过程中的动态链接。“解释器”段实际上只是一个字符串,即解释器的文件名,最终记录在elf_interpreter变量中。另外child程序是静态编译的程序,不需要动态链接,也就没有解析器段。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20elf_phdata = load_elf_phdrs(&loc->elf_ex, bprm->file);
elf_ppnt = elf_phdata;
for (i = 0; i < loc->elf_ex.e_phnum; i++)
{
if (elf_ppnt->p_type == PT_INTERP)
{
...
elf_interpreter = kmalloc(elf_ppnt->p_filesz, GFP_KERNEL);
//根据其位置的p_offset和大小p_filesz把整个"解释器"段的内容读入缓冲区
kernel_read(bprm->file, elf_ppnt->p_offset, elf_interpreter,elf_ppnt->p_filesz);
//struct *file类型的interpreter指针指向解释器段
interpreter = open_exec(elf_interpreter);
//读入其开头的128个字节,即解释器文件的elf头部。
kernel_read(interpreter, 0, (void*)&loc->interp_elf_ex, sizeof(loc->interp_elf_ex));
...
break;
}
elf_ppnt++;
}3、清除前一个计算的所有资源,并分配新资源
1
2
3flush_old_exec(bprm);
...
setup_new_exec(bprm);4、设置栈段
检查所有的程序段,如果某一个段的类型为PT_GNU_STACK,即栈段,那么检查标志并设定相应的值,然后调用setup_arg_pages()函数利用do_execve()生成的参数页面的信息,来设定本程序struct linux_binprm bprm的栈顶地址,然后mm->start_stack = bprm->p,将mm的栈的起始地址设定为bprm中栈顶指针指向的位置。
跳过中间一些处理器相关的检查操作,直接来到ELF段载入阶段
5、加载目标程序必须的段,即将ELF文件中的映像载入内存。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24for(i = 0, elf_ppnt = elf_phdata; i < loc->elf_ex.e_phnum; i++, elf_ppnt++)
{
int elf_prot = 0, elf_flags;
unsigned long k, vaddr;
unsigned long total_size = 0;
//搜索PT_LOAD段,也就是需要加载的段,只有代码段和数据段需要装入
if (elf_ppnt->p_type != PT_LOAD)
continue;
//检查标志、页面信息
if (elf_ppnt->p_flags & PF_R) elf_prot |= PROT_READ;
if (elf_ppnt->p_flags & PF_W) elf_prot |= PROT_WRITE;
if (elf_ppnt->p_flags & PF_X) elf_prot |= PROT_EXEC;
vaddr = elf_ppnt->p_vaddr;
//设置e_flags标志
//...
total_size = total_mapping_size(elf_phdata, loc->elf_ex.e_phnum);
//通过elf_map()来将用户虚拟地址load + vaddr和文件映像中的区域映射
elf_map(bprm->file, load_bias + vaddr, elf_ppnt, elf_prot, elf_flags, total_size);
}遍历所有段,如果该段是可加载段(child程序的可加载段就是代码段和数据段),就确定装入内存的地址load_bias + vaddr,并通过elf_map()来建立用户虚拟地址和文件映像中的区域映射。这里的load_bias是随机生成的偏移量,在映射到进程的虚拟地址空间时,栈、堆、解析器段的起始地址往往加上一个随机偏移量。因为整个程序的虚拟起始地址固定为0x08048000,敏感的栈区域容易被算出地址,被黑客利用。elf_map利用vm_map来建立虚拟地址到文件映像的映射,与mmap类似。
mmap会将文件从交换空间加载进内存,并为进程新创建一个vm_area_struct,同时建立vm_area_struct与进程段的映射关系,例如代码段的vm_area_struct的vm_start字段表明代码起始地址为0x08048000,vm_end字段表明代码段的结束位置。现在代码段和数据段已经加载到内存中了。并且代码段和数据段的vm_area_struct也已经设置好了,插入了进程的mmap链表中:6、接下来是填写程序的入口地址。
1
2
3
4
5
6
7
8
9
10
11if (elf_interpreter)
{
unsigned long interp_map_addr = 0;
elf_entry = load_elf_interp(&loc->interp_elf_ex, interpreter,
&interp_map_addr, load_bias, interp_elf_phdata);
}
else
{
elf_entry = loc->elf_ex.e_entry;
}如果存在解释器段,就通过load_elf_interp()将其映像装入内存, 并把将来进入用户空间的入口地址elf_entry设置成解释器映像的入口地址,这样返回用户空间时先执行解析器程序,将需要的共享库(shared lib)映射到进程的虚拟地址空间中。如果没有解释器段,也就是child程序的情况,那么直接从ELF Header的e_entry字段获得程序入口地址(child文件映像的入口地址)。
此时入口地址应该是0x08048d0a!7、执行前的准备
首先调用create_elf_tables()函数填写目标文件的命令行参数、环境变量等信息。这些信息需要复制到用户空间,使它们在PC跳转到解释器或目标映像的程序入口地址时出现在用户空间堆栈上。1
create_elf_tables(bprm, &loc->elf_ex, load_addr, interp_load_addr);
vm_area_struct线性区结构体的映射由mmap完成,现在完成mm_struct的初始化。
1
2
3
4
5
6
7
8
9current->mm->end_code = end_code;
current->mm->start_code = start_code;
current->mm->start_data = start_data;
current->mm->end_data = end_data;
current->mm->start_stack = bprm->p;
if ((current->flags & PF_RANDOMIZE) && (randomize_va_space > 1))
{
current->mm->brk = current->mm->start_brk = arch_randomize_brk(current->mm);
}8、调用start_thread()函数准备执行此ELF程序
该函数是一个与体系结构相关的函数,在i386中其核心函数如下:1
2
3
4
5
6
7
8regs->fs = 0;
regs->ds = __USER_DS;
regs->es = __USER_DS;
regs->ss = __USER_DS;
regs->cs = __USER_CS;
regs->ip = new_ip;
regs->sp = new_sp;
regs->flags = X86_EFLAGS_IF;最后,函数跳转到regs->ip处(地址0x8048d0a)执行,execve系统调用结束。
最终内存的情况如下图所示:
至此,程序的创建和可执行文件的加载过程全部结束!