1.ELF符号
符号是对某些类型的数据或者代码(全局变量、函数等)的符号引用,例如printf函数会在动态符号表.dynsym中存有一个指向该函数的符号条目。
.dynsym保存了引用来自外部文件符号的全局符号,.symtab中还保存了可执行文件的本地符号,如全局变量等,.dynsym保存的符号是.symtab保存的符号的子集。
.dynsym在程序运行时会被分配并装载进内存,主要用于动态链接可执行文件的执行。而.symtab则不会,它主要用来进行调试和链接的。
ELF文件符号项的结构体如下:
typedef struct {
uint32_t st_name; //保存了指向符号表中字符串表的偏移地址(.dynstr或.strtab)
unsigned char st_info; //制定符号类型及绑定属性
unsigned char st_other; //定义符号的可见性
uint16_t st_shndx; //每个符号表的条目的定义都与某些节对应,该变量保存了相关节头表的索引
Elf64_Addr st_value; //存放符号的值(地址或者位置偏移量)
uint64_t st_size; //存放符号的大小
} Elf64_Sym;
这里我们可以观察一个实例:
1.1.symtab符号表分析
首先用readelf -S查看节信息:
[29] .symtab SYMTAB 0000000000000000 00001070
0000000000000648 0000000000000018 30 47 8
[30] .strtab STRTAB 0000000000000000 000016b8
0000000000000216 0000000000000000 0 0 1
然后再用hexedit来查看程序代码:
.symtab节:(这里没有从0x1070顶头截取,0x1310是第28项的起始)
00001310 01 00 00 00 04 00 F1 FF 00 00 00 00 00 00 00 00 ................
00001320 00 00 00 00 00 00 00 00 0C 00 00 00 01 00 15 00 ................
00001330 20 0E 60 00 00 00 00 00 00 00 00 00 00 00 00 00 .`.............
00001340 19 00 00 00 02 00 0E 00 60 04 40 00 00 00 00 00 ........`.@.....
00001350 00 00 00 00 00 00 00 00 1B 00 00 00 02 00 0E 00 ................
00001360 A0 04 40 00 00 00 00 00 00 00 00 00 00 00 00 00 ..@.............
00001370 2E 00 00 00 02 00 0E 00 E0 04 40 00 00 00 00 00 ..........@.....
.strtab节:
00 63 72 74 73 74 75 66 .........crtstuf
000016C0 66 2E 63 00 5F 5F 4A 43 52 5F 4C 49 53 54 5F 5F f.c.__JCR_LIST__
000016D0 00 64 65 72 65 67 69 73 74 65 72 5F 74 6D 5F 63 .deregister_tm_c
000016E0 6C 6F 6E 65 73 00 5F 5F 64 6F 5F 67 6C 6F 62 61 lones.__do_globa
000016F0 6C 5F 64 74 6F 72 73 5F 61 75 78 00 63 6F 6D 70 l_dtors_aux.comp
00001700 6C 65 74 65 64 2E 37 35 39 34 00 5F 5F 64 6F 5F leted.7594.__do_
00001710 67 6C 6F 62 61 6C 5F 64 74 6F 72 73 5F 61 75 78 global_dtors_aux
我们可以计算一下:
每一个符号项的大小为:4+1+1+2+8+8=24
则这一段代码中包含的符号项数量为:0x648/24=67项,然后我们用如下命令查看一波符号表:
readelf -s hello.out
输出为:
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
证明我们的计算是正确的。
拿.dynsym中的第三个符号项为例子分析一下:
19 00 00 00 02 00 0E 00 60 04 40 00 00 00 00 00 00 00 00 00 00 00 00 00
首先知道偏移量为0x19,这样的话它的字符串在.strtab中的位置就是:0x19+0x16B8=0x16D1,结束位置为下一个0x00所在位置,即0x16E5
即:“deregister_tm_clones”,它的st_info字段的值为0x02,st_other字段的值为0x00,st_shndex的值为0x0E,st_value的值为0x400460,st_size的值为0x00。接下来我们再打印一波符号表然后看看第31项:
30: 0000000000400460 0 FUNC LOCAL DEFAULT 14 deregister_tm_clones
看来与我们的计算也是相同的。
1.2.dyntab符号表分析
刚才分析的表的地址已经超出了数据段和代码段的地址了,这也说明程序运行时并不会把他们加载进内存,所以如果我们要分析与程序运行相关的符号表就要看.dyntab表,首先查看节信息:
[ 5] .dynsym DYNSYM 00000000004002b8 000002b8
0000000000000060 0000000000000018 A 6 1 8
[ 6] .dynstr STRTAB 0000000000400318 00000318
000000000000003f 0000000000000000 A 0 0 1
然后找出对应的内容:
.dyntab:
00 00 00 00 00 00 00 00 ................
000002C0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
000002D0 0B 00 00 00 12 00 00 00 00 00 00 00 00 00 00 00 ................
000002E0 00 00 00 00 00 00 00 00 12 00 00 00 12 00 00 00 ................
000002F0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
00000300 24 00 00 00 20 00 00 00 00 00 00 00 00 00 00 00 $... ...........
00000310 00 00 00 00 00 00 00 00
.dynstr:
00 6C 69 62 63 2E 73 6F .........libc.so
00000320 2E 36 00 70 72 69 6E 74 66 00 5F 5F 6C 69 62 63 .6.printf.__libc
00000330 5F 73 74 61 72 74 5F 6D 61 69 6E 00 5F 5F 67 6D _start_main.__gm
00000340 6F 6E 5F 73 74 61 72 74 5F 5F 00 47 4C 49 42 43 on_start__.GLIBC
00000350 5F 32 2E 32 2E 35 00 00 00 00 02 00 02 00 00 00 _2.2.5..........
这里首先也简单计算一下吧,一共长度为0x60字节,每项24字节大小,共有0x60/24=4项,可以打印一下符号表来验证一下:
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 printf@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__
说明计算是正确的。
这里还是用第三项来分析,第三项的代码为:
12 00 00 00 12 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
偏移量为0x12,即strtab中的0x32a,一直到0x33B,也就是如下字符:"__libc_start_main",它的 st_info字段为0x12,其余的字段都是0。
但是现在可以发现一个有趣的现象,那就是符号表打印的并不是"__libc_start_main",而是是"__libc_start_main@GLIBC_2.2.5 (2)",
我想这也与st_info字段的取值有关,这个字段决定了打印结果中的两列的取值—type与bind。
符号类型type有如下几种:
-
STT_NOTYPE 符号未定义
-
STT_FUNC 表示该符号与函数或者其他可执行代码关联
-
STT_OBJECT 表明该符号与数据目标文件关联
符号绑定bind有如下几种:
-
STB_LOCAL 本地符号,在目标文件之外都是不可见的,如一个声明为static的函数
-
STB_GLOBAL 全局符号,对于所有要合并的目标文件来说都是可见的
-
STB_WEAK 与全局绑定类似,不过比STB_GLOBAL优先级低,甚至可能会被同名的未标记为STB_WEAK的符号覆盖
所以我觉得应该是因为这两个符号是全局符号的原因,所以需要在后缀添加上这些信息。
2.ELF重定位
重定位就是将符号定义和符号引用进行连接的过程,包括描述如何修改节内容的相关信息,从而使得可执行文件和共享目标文件能够保存进程的程序镜像所需的正确信息。重定位条目就是我们上面说的相关信息。
重定位记录保存了如何对给定的符号的对应代码进行补充的相关信息,重定位实际上是一种给二进制文件打补丁的机制。
简单点来说,就是两个目标文件输出可执行文件之前,是无法确定各自符号和代码在内存中的位置的,而重定位之后,目标文件中的代码会被重定位到可执行文件段中的一个给定的地址。
看一下64位的重定位条目:
typedef struct {
Elf64_Addr r_offset;
uint64_t r_info;
} Elf64
有的条目还需要append字段:
typedef struct {
Elf64_Addr r_offset; //指向需要进行重定位操作的位置
uint64_t r_info; //指定必须对其进行重定位符号表索引以及要应用的重定位类型
int64_t r_addend; //制定常量加数,用于计算存储在可重定位字段中的值
} Elf64_Rela;
这里我突然想起来之前分析节的时候看见的.rela.dyn、.rela.plt节:
[ 9] .rela.dyn RELA 0000000000400380 00000380
0000000000000018 0000000000000018 A 5 0 8
[10] .rela.plt RELA 0000000000400398 00000398
0000000000000030 0000000000000018 AI 5 24 8
我想这些部分与重定位密切相关。
到这里我参考的书提到了隐式加数的概念,不过鉴于64位往往采用显式存储,所以这里就不去探究了。
2.1.重定位项查看
重定位项是可以直接查看的,用如下命令:
readelf -r hello.out
得到如下的输出:
重定位节 '.rela.dyn' 位于偏移量 0x380 含有 1 个条目:
偏移量 信息 类型 符号值 符号名称 + 加数
000000600ff8 000300000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0
重定位节 '.rela.plt' 位于偏移量 0x398 含有 2 个条目:
偏移量 信息 类型 符号值 符号名称 + 加数
000000601018 000100000007 R_X86_64_JUMP_SLO 0000000000000000 printf@GLIBC_2.2.5 + 0
000000601020 000200000007 R_X86_64_JUMP_SLO 0000000000000000 __libc_start_main@GLIBC_2.2.5 + 0
这里也可以简单的进行一个计算:
每个重定位条目大小为3*8=24=0x18,所以一个条目是0x18,两个条目就是0x30,我们再去分析一下对应地址的二进制部分:
.dela.dyn对应的:
00000380 F8 0F 60 00 00 00 00 00 06 00 00 00 03 00 00 00 ..`.............
00000390 00 00 00 00 00 00 00 00
从这里,我们可以看出0~8字节为偏移量r_offset=0x600FF8,8 ~16字节为信息r_info=0x300000006,加数r_append=0x0,也正好与我们的条目相对应。
2.2.偏移计算
为了说明这个计算,首先我们需要生成一个目标文件,源代码依然用最简单的hello world:
gcc -c hello.c
然后我们目录下会出现这个hello.o,然后我们查看一下它的指令部分:
0000000000000000 <main>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: bf 00 00 00 00 mov $0x0,%edi
9: b8 00 00 00 00 mov $0x0,%eax
e: e8 00 00 00 00 callq 13 <main+0x13>
13: b8 00 00 00 00 mov $0x0,%eax
18: 5d pop %rbp
19: c3 retq
只有这么一小部分,但是可以看到它的函数调用部分是callq 0x13,按我参考书上的说法,这是因为此时目标文件并没有printf函数的地址,而当生成可执行文件时,链接器会对该位置进行修改,在printf函数被包含进可执行文件时,链接器会通过偏移补齐4个字节,这样也就相当于存储了foo的实际偏移地址,这里再打印一下重定位表:
重定位节 '.rela.text' 位于偏移量 0x1f8 含有 2 个条目:
偏移量 信息 类型 符号值 符号名称 + 加数
000000000005 00050000000a R_X86_64_32 0000000000000000 .rodata + 0
00000000000f 000a00000002 R_X86_64_PC32 0000000000000000 printf - 4
重定位节 '.rela.eh_frame' 位于偏移量 0x228 含有 1 个条目:
偏移量 信息 类型 符号值 符号名称 + 加数
000000000020 000200000002 R_X86_64_PC32 0000000000000000 .text + 0
这里可以通过简单计算:0x13-4=0xF,也就是说第二个偏移条目printf的函数的地址会被添加到这个位置,也就是callq指令的部分,而这个等待被补全的地址刚好是四个字节,也可以解释加数4了。
接下来便是生成可执行文件,然后看一下它重定位后的结果:
gcc hello.o -o hello.out
objdump -d hello.out
可以看到如下输出:
0000000000400400 <printf@plt>:
400400: ff 25 12 0c 20 00 jmpq *0x200c12(%rip) # 601018 <_GLOBAL_OFFSET_TABLE_+0x18>
400406: 68 00 00 00 00 pushq $0x0
40040b: e9 e0 ff ff ff jmpq 4003f0 <_init+0x28>
........
0000000000400526 <main>:
400526: 55 push %rbp
400527: 48 89 e5 mov %rsp,%rbp
40052a: bf c4 05 40 00 mov $0x4005c4,%edi
40052f: b8 00 00 00 00 mov $0x0,%eax
400534: e8 c7 fe ff ff callq 400400 <printf@plt>
400539: b8 00 00 00 00 mov $0x0,%eax
40053e: 5d pop %rbp
40053f: c3 retq
可以看到,callq这里的地址的确被替换掉了。
每种类型都有各自的计算方式,比如这个R_X86_64_PC32,被替换的值符合S+A-P的方式。
这里S是调用printf函数地址指令所在的地址,即0x400535,A为.o文件时打印重定位项的加数-4,P则是要进行重定位的存储单元的地址,在这里是printf函数所在的地址,即0x400400,所以偏移量就是 0x400535-4+0x400400=-0x139=0xFFFFFEC7。
所以这也是我之前做注入的小实验时那个讲究的4的解释了。