关于 ELF 可执行文件的 Reloction 的笔记 2/2

这是关于 .reloc 分段的一系列文章的第一章。本文主要内容翻译自 Eli Bendersky 的文章 Load-time relocation of shared libraries。本文的实验平台为 x86_64 GNU/Linux,编译器则采用的是 gcc 9.1.0。

Relocs 和 内存空间

程序在运行前,首先会被加载进入到一定的内存空间,PE、ELF头则指定了程序各个段应该载入内存的何处。只有各个段被载入到相应的内存地址,样程序才能正常寻址。因此程序在编译过程中,编译器将为各个段设定起始地址,所有寻址都以这个地址为基准。

现代操作系统,为了使系统更加安全稳定,采用了一项名为 装载时重定位(Load-time relocation) 的技术,在 windows 中又被称为 基址重置(Rebasing)技术,即忽略PE、ELF头指定的基址,随机选择一个内存区间载入程序。该方法会导致程序的内存地址不确定,使得恶意代码在注入后无法正常工作,同时“正常”程序也无法使用编译时“假设” 的基址寻址。所以需要在载入时根据实际分配的随机的基址,更改(fix up)写在二进制可执行文件中的操作数地址,使得程序在被映射到随机的内存空间后仍能正常执行。因此 PE、ELF 可执行文件将带有一个称为 relocs 的表,这个表将记录这些修改(fix up)。

载入动态链接库(Shared libraries)

在实际情况中,对于可执行文件 .reloc 并不是必须的,这意味着删除 .reloc 后程序仍然能够正常装载运行。这是因为可执行文件总是第一个载入内存空间(Virtual Memory)中。可是对于动态链接库就没有这么幸运了,它随着程序的载入一起载入的,同一个动态链接库要为不同程序服务,各个程序使用的内存地址各不相同,所以动态连接库无法保证自己永远被载入到某一固定的内存地址。因此必须要使用 装载时重定位 技术,动态链接库才能正常工作。

现今,有两种主流的方法来提供动态装载时内存地址不确定的问题:

  1. 装载时重定位 (Load-time relocation)
  2. 位置无关代码 (Position independent code, PIC)

即使PIC是现今更推荐的解决方法,甚至在64位Linux平台上PIC已经成为动态链接库的默认选项,但是本文还是着重于 装载时重定位(Load-time relocation) 方法。

注意:本文着重点在 Linux 平台上的关于 装载时重定位 技术的内容,我本人使用的系统为 x86_64 GNU/Linux,编译器采用的 gcc 9.1.0 ,情多注意。

首先,让我们做一个简单的实验,将下列 C 代码保存为 ml_main.c

1
2
3
4
5
6
7
int myglob = 42;

int ml_func(int a, int b)
{
myglob += a;
return b + myglob;
}

记住,因为动态链接库不知道自己将会被载入何处,所以自然无法在编译时确定 myglob 变量的实际内存地址,该地址应该是动态链接库被载入内存时确定。

将其编译为32位动态连接库

1
2
$ gcc -m32 -g -c ml_main.c -o ml_mainreloc.o -fno-pic
$ gcc -m32 -shared -o libmlreloc.so ml_mainreloc.o -fno-pic

首先,确认下该动态连接库的入口地址:

1
2
3
4
5
6
7
$ readelf -h libmlreloc.so
ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
[...] 略过
Entry point address: 0x1030
[...] 略过

该动态链接库的入口地址为 0x1030 ,对于Linux上的动态链接库,其入口地址即为 .text 段的首地址,这说明编译器(链接器)假设该动态链接库的 .text 段被载入到内存 0x1030 处。但我们知道,该动态链接库绝不可能载入到该内存区域。

我们看一看该动态链接库的反汇编:

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
$ objdump -d -Mintel libmlreloc.so
libmlreloc.so: file format elf32-i386

[...] 略过代码

Disassembly of section .text:

00001030 <__x86.get_pc_thunk.bx>:
1030: 8b 1c 24 mov ebx,DWORD PTR [esp]
1033: c3 ret

[...] 略过代码

0000112d <ml_func>:
112d: 55 push ebp
112e: 89 e5 mov ebp,esp
1130: 8b 15 00 00 00 00 mov edx,DWORD PTR ds:0x0
1136: 8b 45 08 mov eax,DWORD PTR [ebp+0x8]
1139: 01 d0 add eax,edx
113b: a3 00 00 00 00 mov ds:0x0,eax
1140: 8b 15 00 00 00 00 mov edx,DWORD PTR ds:0x0
1146: 8b 45 0c mov eax,DWORD PTR [ebp+0xc]
1149: 01 d0 add eax,edx
114b: 5d pop ebp
114c: c3 ret

[...] 略过代码

可见 .text 段确实被假设在 0x1030 处, .text 段的第一个函数为 __x86.get_pc_thunk.bx ,而我们的函数 ml_func 则在 0x112d` 处。

让我们重点关注 ml_func 函数,观察它是如何完成 myglob += a 的。0x1136 处从 EBP+0x8 中取值到 EAX 我们可以很容易推断出这就是传入参数 a ,同时因为 0x1139 处 EAXEDX 相加,那么 myglob 的值被存在 EDX 中。再往上看,发现 MOV edx, DWORD PTR ds:0x0 这段代码,那么 ds:0x0 则为全局变量 myglob 所在处了。

但是等等,为什么是 ds:0x0 ? (现代操作系统早就不再象DOS系统一样,利用段寄存器来划分内存段了,同时,仔细分析objdump的输出我们会发现程序被载入到 0x1000 处)0x0 处根本就不是程序可以正常利用的空间,但是 MOV 指令确实从这里取值了。这时我们应该反应过来,装载时重定位应当在这里发挥作用, 0x0 应当是类似于C语言中 #DEFINE 一样的存在,在载入时被替换为实际可用的,指向 myglob 变量的内存地址。让我们确认下:

1
2
3
4
5
6
7
8
9
$ readelf -r libmlreloc.so

Relocation section '.rel.dyn' at offset 0x2b4 contains 10 entries:
Offset Info Type Sym.Value Sym. Name
[...] 省略部分输出
00001132 00000501 R_386_32 00004010 myglob
0000113c 00000501 R_386_32 00004010 myglob
00001142 00000501 R_386_32 00004010 myglob
[...] 省略部分输出

rel.dyn 是ELF中为装载时重定位提供信息的分段。一共有三条关于 myglob 的记录,这意味着 myglob 在程序中有三次引用,让我们翻译下第一条记录:

在偏移量为 0x1132 处,对一个类型为 R_386_32 的记录进行重定位操作。

我们很容易发现 R_386_32 指的是32位数据,表示该记录从 0x1132 开始,是一个四个字节长的记录。我们返回去看便宜量为 1132 处的指令,发现它在 ml_func 内,该指令为:

1
1130:	8b 15 00 00 00 00    	mov    edx,DWORD PTR ds:0x0

1130 到 1131 处为 8b 15,应该是 MOV 指令和第一个操作数 edx,而 1132 到 1135 处为 00 00 00 00 指令,即 ds:0x0

这条记录即说:在载入时,将 0x1132 处的 myglob 变量的 32 位地址更换为实际的地址。是不是很简单呢?

值得注意的是,在 .reloc 段的 Offset 列,即记录将要被替换掉的记录的地址,这个地址是 myglob 在内存地址(Virtual Memory)中的地址。如之前提到过,编译链接过程中,编译器(链接器)假设动态链接库的 .text 段被加载到内存地址(Virtual Memory)的0x1000处。所以 Sym.value 中的地址并非文件偏移量,而是“基于假设基地址的偏移量”,或者更准确说是相对虚拟地址(Relative Virtual Address, RVA)。

同样应该注意的还有 Sym.Value 列, 0x4010 指的是变量 myglob 在 RVA 中的位置,如前文所提到的,程序镜像被载入到地址为 0x0 的位置, .text 段被载入到 0x1030 位置。 可以用如下命令更加直观地分析各个段的文件位置和RVA:

1
2
3
4
5
6
7
8
9
10
11
$ readelf -S libmlreloc.so 
There are 29 section headers, starting at offset 0x37e0:

Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[....] 省略部分输出
[ 9] .text PROGBITS 00001030 001030 00011d 00 AX 0 0 16
[....] 省略部分输出
[18] .data PROGBITS 0000400c 00300c 000008 00 WA 0 0 4
[....] 省略部分输出

该输出印证了之前的观点,那么 Sym.Value 中 0x4010 指的就很明确了,即回答 myglob 的实际地址该怎么计算的问题。这里 Addr 列即 RVA, Off 列即文件偏移地址。data 段被假设载入到 0x400c ,此时 myglob 则被载入到 0x4010 ,当 data 段被载入到一个实际的地址时, myglob 的实际地址则为 .data 的实际地址 + 0x4010 - .data 假设地址(0x400C),是不是豁然开朗了呢?