本文主要讨论 Dynamic Segment
里面的几个子表。
总体结构介绍
我们先总体看下 Dynamic Segment
:
这里我们只需要关心图中两个画圆圈的地方。
第一个是虚拟地址
由于 Dynamic Segment
的子表内容都在可加载段的范围里面,所以对这些子表的访问都是在虚拟内存里面进行的。
p_offset_FROM_FILE_BEGIN
这个值我们可以自己静态分析使用,但是解析的时候不要用这个地址。包括 p_filesz_SEGMENT_FILE_LENGTH
p_memsz_SEGMENT_RAM_LENGTH
,这些值都没用。
有一个需要注意的地方,就是p_offset_FROM_FILE_BEGIN
的值要比 p_vaddr_VIRTUAL_ADDRESS
的值要大一个 PAGE 值。这是为啥呢?
是因为第二个可加载段的映射偏差:
可以看到在第二个可加载段在做内存映射的时候就有一个 PAGE 值的偏差,由于只要是放在第二个段里面的内容,就会有一个 PAGE 的偏差。
第二个是子表
第二个画圈的地方说明了,子表有 39 项。但是实际上最后几项都是 NULL:
所以解析子表的时候,不会去先计算子表的大小,然后用 大小/sizeof(xxx)
这样的方式,而是直接从头开始解析,直到遇到 DT_NULL。
子表的结构体是 struct Elf64_Dyn
:
arduino
typedef struct
{
Elf64_Sxword d_tag; /* Dynamic entry type */
union
{
Elf64_Xword d_val; /* Integer value */
Elf64_Addr d_ptr; /* Address value */
} d_un;
} Elf64_Dyn;
一共16个字节,前8个字节表示类型,后8个字节表示地址(注意表示的是虚拟地址)或者整数值。
我们先研究字符串表,其 type 为 5:
字符串表
我们找到字符串表的虚拟地址位置 0x2328,它是在第一个段里面。其实这个地址也能在 section header 里面找到:
只不过 section header 里面的这个地址容易被篡改而已。
根据第一个段的mmap映射关系,我们知道,其文件位置也是 0x2328,我们跳过去看看:
明显看出,这里确实是字符串表。
这个表要如何访问呢?使用相对于字符串表的偏移。 一个访问规律就是尽可能使用较小的相对偏移,我猜测是为了减少数据大小。像数组表,就使用数组索引来访问,而字符串元素大小不固定,所以就使用偏移。
我们以导入库表为例,顺便介绍对字符串表的访问。
导入库表
在子表里面,type 为 1 的,就是导入库表。这里有8项,说明这个 ELF 依赖了其他8个 so。以第一项为例:
这里的 d_ptr 的值是 0xD1B,这个值就是字符串表的相对偏移,我们算一下其真实虚拟地址:
ini
0xD1B + 0x2328 = 0x3043
可以看到,其 so 的名字为 liblog.so:
符号表
对于符号表来说,就是描述的一个映射关系:一个符号对应着一个地址。
符号表 type 为 6:
其数据结构为:
arduino
typedef struct
{
Elf64_Word st_name; /* Symbol name (string tbl index) */
unsigned char st_info; /* Symbol type and binding */
unsigned char st_other; /* Symbol visibility */
Elf64_Section st_shndx; /* Section index */
Elf64_Addr st_value; /* Symbol value */
Elf64_Xword st_size; /* Symbol size */
} Elf64_Sym;
里面的字段就不解释了,我还记得之前的笔记里面做过详细的解释,但是现在也忘记的差不过了,只要有个印象就行,后面真的遇到了再回过头看或者查资料。
该数据结构一共占24个字节:
arduino
typedef struct {
Elf64_Word st_name; // 4 B (B for bytes)
unsigned char st_info; // 1 B
unsigned char st_other; // 1 B
Elf64_Half st_shndx; // 2 B
Elf64_Addr st_value; // 8 B
Elf64_Xword st_size; // 8 B
} Elf64_Sym; // total size = 24 B
符号表的第一项全是0值,我们不管。
我们以linker中的某一项为例:
前4个字节表示字符串的偏移,值为 rtld_db_dlactivity
。再看其 st_value,是 0232D8
,到 IDA 中看看这个地址里面是什么东西:
我们发现,字符串名字与 IDA 中导出符号的名字是一样的。
但是,同一个地址可以对应多个不同的字符串,地址可以有多个名字,不是一对一的关系。
ELF文件的最后也详细的解释了这个动态符号表:
重定位表(rela.plt)
重定位表起始就是对需要重定位符号的描述:
- 其真实储存地址
- 其类型与符号索引
- attend
其 type 为 23。
请参见过程链接表(特定于处理器)。此元素要求同时存在 DT_PLTRELSZ 和 DT_PLTREL 元素。
- DT_PLTRELSZ:PLT类型重定位的大小
- DT_PLTREL:指明PLT重定位的类型,这一项的value就高级了,value的值是DT_REL(17)或DT_RELA(7)宏的值。
其结构体如下,24个字节:
arduino
typedef struct
{
Elf64_Addr r_offset; /* Address */
Elf64_Xword r_info; /* Relocation type and symbol index */
Elf64_Sxword r_addend; /* Addend */
} Elf64_Rela;
从 r_info 的解释可知,重定位表里面的符号集合是符号表的子表。
从 section 里面还可以看出,它是对应的这个:
跳转到 9C10 位置,分析第一项:
第一项是地址,我们去 IDA 里面看+7一下:
css
.got.plt:000000000005F658 68 59 06 00 00 00 00 00 off_5F658 DCQ __libc_init ; DATA XREF: .__libc_init↑o
.got.plt:000000000005F658 ; .__libc_init+4↑r
.got.plt:000000000005F658 ; .__libc_init+8↑o
这个地址里面储存的值是68 59 06 00 00 00 00 00
,而这个值代表了一个函数(__libc_init)的地址,当然现在是没有值的:
css
extern:0000000000065968 00 00 00 00 00 00 00 00 IMPORT __libc_init ; CODE XREF: .__libc_init+C↑j
extern:0000000000065968 ; DATA XREF: .got.plt:off_5F658↑o
需要等程序链接后,将 __libc_init
的真实地址给填入到5F658
的位置,这个过程其实就是 plt 的过程。
后面的8个字节分为两部分:
- 0402,这个是重定位类型,具体为 #define R_AARCH64_JUMP_SLOT 1026,详细看源码:cs.android.com/android/pla...
- 01 ,这个是符号表的索引,我们找到符号表,然后看其第一项
这就是说,对于导入表,linker 需要先加载该 ELF 所依赖的 so,然后再找到这个符号,最后将这个符号的真实地址填入到 5F658
这个位置。
有一个地方需要注意,就是linker在寻找符号的时候,它不知道这个符号存放在哪个so里面。我们可以从 IDA 中得到验证:
这里的 Library 写的是 .dynsym,但是屁用没有。
所以,当两个 so 出现了相同的符号时,那么就以先找到的为准,可以理解为 linker 会去遍历所依赖的 so,在里面找导出符号,找到匹配的了就直接返回。
其实这个过程,看过 csapp 的应该好理解,而且 010 中体现的也很明显:
由于 0x05F658 的大小已经超过了第一个可加载段的长度,所以这个地址在第二个可加载段中,算一下偏移,它在文件中的地址为 0x05E658 处:
这里有很多的 F0 B8 值,这因为 plt 懒加载的原因,plt 项全部都指向一个特定的地址,然后从这个地址开始执行,解析符号,得到地址后,再回填到这个地址,后面就不用在走这个过程了。
最后
由于64位 so 的重定位类型相当之多,所以想模拟一下程序的加载还是很有难度的,需要写很多个 switch case,暂时就不写了。我贴一下 linker 源码:
perl
case R_ARM_JUMP_SLOT:
COUNT_RELOC(RELOC_ABSOLUTE);
MARK(rel->r_offset);
TRACE_TYPE(RELO, "%5d RELO JMP_SLOT %08x <- %08x %s\n", pid,
reloc, sym_addr, sym_name);
*((unsigned*)reloc) = sym_addr;
break;
case R_ARM_GLOB_DAT:
COUNT_RELOC(RELOC_ABSOLUTE);
MARK(rel->r_offset);
TRACE_TYPE(RELO, "%5d RELO GLOB_DAT %08x <- %08x %s\n", pid,
reloc, sym_addr, sym_name);
*((unsigned*)reloc) = sym_addr;
break;
case R_ARM_ABS32:
COUNT_RELOC(RELOC_ABSOLUTE);
MARK(rel->r_offset);
TRACE_TYPE(RELO, "%5d RELO ABS %08x <- %08x %s\n", pid,
reloc, sym_addr, sym_name);
*((unsigned*)reloc) += sym_addr;
break;
在 2.x 版本的时候,switch case 还不多,对于理解核心逻辑非常有帮助。核心代码就一行:
arduino
*((unsigned*)reloc) = sym_addr;
*((unsigned*)reloc) += sym_addr;
不过是,不同的重定位类型有不同的处理。
对于像 R_ARM_GLOB_DAT
与 R_ARM_JUMP_SLOT
,就是直接赋值符号真实地址就好了。
对于像 R_ARM_ABS32
之类的,需要加上符号的真实地址,这个有可能是因为访问了结构体或者数组元素,编译器在编译文件时就写入了一个结构偏移量,而符号的地址是结构的起始地址,所以最终需要加上这个地址。
当然要完全的理解这些东西,需要去看文档。不同的符号,编译器在生成时会有不同的处理方式,给符号赋值不同的类型与重定位方式,然后 linker 会根据一定的规则对这些符号进行重定位,最后将值写入导入表地址中。